footer sign-in link; sign-in, invite and reset land on the console

One quiet 'Sign in · leaders & families' in the public footer, after the
site's own links and before Join, which stays the only call to action.
Nothing on the public site pointed at /login before; the only ways in
were typing the URL or an invite link. A fresh sign-in, an accepted
invite and a used reset link now land on /leaders/, which sends a
member on to Family and a leader to Summary. tests/smoke_identity.py
123 -> 125.
This commit is contained in:
2026-09-04 20:39:45 -04:00
parent 752fb08d73
commit 77dd250436
4 changed files with 19 additions and 11 deletions
+1 -1
View File
@@ -493,7 +493,7 @@ def page(title, body, active=""):
<div style="font-size:14px;line-height:1.7">Chartered by St. Luke's Lutheran Church · Zieglerville, PA<br>Cradle of Liberty Council · A family program. Every kid welcome.</div></div>
<div><div class="fh">VISIT</div><div style="font-size:14px;line-height:1.9">{MEETING_DAYS} · Pack {PACK_CLOCK} · Troop {TROOP_CLOCK}<br>St. Luke's Lutheran Church<br>Zieglerville, PA</div></div>
<div style="display:flex;flex-direction:column;gap:8px"><div class="fh" style="margin-bottom:4px">EXPLORE</div>
<a href="/cubs">Cub Scouts · Pack 73</a><a href="/troop">Troop 73 · Ages 11-17</a><a href="/calendar">{PROGRAM_YEAR} Calendar</a><a href="/documents">Forms &amp; documents</a><a href="/find-a-unit">Find a unit near you</a><a class="gold" href="/join">Join us</a></div>
<a href="/cubs">Cub Scouts · Pack 73</a><a href="/troop">Troop 73 · Ages 11-17</a><a href="/calendar">{PROGRAM_YEAR} Calendar</a><a href="/documents">Forms &amp; documents</a><a href="/find-a-unit">Find a unit near you</a><a href="/login" style="color:#C3CBDA">Sign in · leaders &amp; families</a><a class="gold" href="/join">Join us</a></div>
<div style="display:flex;flex-direction:column;gap:8px"><div class="fh" style="margin-bottom:4px">FOLLOW</div>
<a class="fb" href="https://www.facebook.com/Pack73GreenLane" target="_blank" rel="noopener" aria-label="Pack 73 on Facebook"><svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor" aria-hidden="true" style="flex:none"><path d="M9.101 23.691v-7.98H6.627v-3.667h2.474v-1.58c0-4.085 1.848-5.978 5.858-5.978.401 0 .955.042 1.468.103a8.68 8.68 0 0 1 1.141.195v3.325a8.623 8.623 0 0 0-.653-.036 26.805 26.805 0 0 0-.733-.009c-.707 0-1.259.096-1.675.309a1.686 1.686 0 0 0-.679.622c-.258.42-.374.995-.374 1.752v1.297h3.919l-.386 2.103-.287 1.564h-3.246v8.245C19.396 23.238 24 18.179 24 12.044c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.628 3.874 10.35 9.101 11.647Z"/></svg>Pack 73</a><a class="fb" href="https://www.facebook.com/Troop73GreenLane" target="_blank" rel="noopener" aria-label="Troop 73 on Facebook"><svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor" aria-hidden="true" style="flex:none"><path d="M9.101 23.691v-7.98H6.627v-3.667h2.474v-1.58c0-4.085 1.848-5.978 5.858-5.978.401 0 .955.042 1.468.103a8.68 8.68 0 0 1 1.141.195v3.325a8.623 8.623 0 0 0-.653-.036 26.805 26.805 0 0 0-.733-.009c-.707 0-1.259.096-1.675.309a1.686 1.686 0 0 0-.679.622c-.258.42-.374.995-.374 1.752v1.297h3.919l-.386 2.103-.287 1.564h-3.246v8.245C19.396 23.238 24 18.179 24 12.044c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.628 3.874 10.35 9.101 11.647Z"/></svg>Troop 73</a></div>
</div></footer>
+2 -2
View File
@@ -220,7 +220,7 @@ def invite_accept(request: Request, token: str, full_name: str = Form(""),
tok = identity.start_session(person["id"], ip=_client_ip(request),
user_agent=request.headers.get("user-agent"))
resp = RedirectResponse(url="/account", status_code=303)
resp = RedirectResponse(url="/leaders/", status_code=303)
resp.set_cookie(COOKIE, tok, max_age=identity.SESSION_ABSOLUTE_DAYS * 86400,
httponly=True, secure=COOKIE_SECURE, samesite="lax", path="/")
return resp
@@ -395,7 +395,7 @@ def reset_accept(request: Request, token: str, password: str = Form(""), confirm
status_code=e.status)
tok = identity.start_session(person["id"], ip=_client_ip(request),
user_agent=request.headers.get("user-agent"))
resp = RedirectResponse(url="/account", status_code=303)
resp = RedirectResponse(url="/leaders/", status_code=303)
resp.set_cookie(COOKIE, tok, max_age=identity.SESSION_ABSOLUTE_DAYS * 86400,
httponly=True, secure=COOKIE_SECURE, samesite="lax", path="/")
return resp
+1 -1
View File
@@ -692,7 +692,7 @@ def consume_invite(token, full_name, password, preferred_name=None, phone=None,
# Rows, not signed cookies. A leader stepping down has to be revocable now
# rather than at token expiry, and "sign out everywhere" has to be possible.
def safe_next(value, default="/account"):
def safe_next(value, default="/leaders/"):
"""Where to send someone after login. Only a same-origin relative path
survives: a single leading slash, no scheme, no protocol-relative `//`,
no backslash or control character that a browser might normalise into
+15 -7
View File
@@ -266,6 +266,14 @@ kinds = {r["kind"] for r in I.list_events(limit=500)}
check("people actions in the log", {"invite.revoked", "person.roles", "person.disabled", "person.enabled",
"password.reset_issued", "password.reset", "password.changed"} <= kinds)
print("\nfooter sign-in")
import os as _os
_src = open(_os.path.join(_os.path.dirname(__file__), "..", "app", "app.py")).read()
_foot = _src[_src.find("<footer"):_src.find("</footer>")]
check("public footer carries one quiet sign-in link before Join", _foot.count('href="/login"') == 1 and _foot.index('href="/login"') < _foot.index('href="/join"'))
check("sign-in lands on the console by default; unsafe next still refused",
I.safe_next("") == "/leaders/" and I.safe_next("//evil") == "/leaders/" and I.safe_next("/leaders/roster") == "/leaders/roster")
print("\nmeeting words")
D = dict(MEETING_DAY="Tuesday", MEETING_DAYS="Tuesdays", MEETING_DAY_ABBR="Tue", PACK_TIME="6:00 PM",
TROOP_TIME="7:30 PM", PACK_CLOCK="6:00", TROOP_CLOCK="7:30")
@@ -288,13 +296,13 @@ check("live seed rows reproduce the constants", I.meeting_words(I.list_units(),
print("\nsafe_next")
check("relative path passes", I.safe_next("/leaders/") == "/leaders/")
check("query string kept", I.safe_next("/leaders/nearby?x=1") == "/leaders/nearby?x=1")
check("empty falls back", I.safe_next("") == "/account")
check("None falls back", I.safe_next(None) == "/account")
check("absolute URL rejected", I.safe_next("https://evil.example/") == "/account")
check("protocol-relative rejected", I.safe_next("//evil.example/") == "/account")
check("backslash form rejected", I.safe_next("/\\evil.example") == "/account")
check("control char rejected", I.safe_next("/leaders\r\nX: y") == "/account")
check("no leading slash rejected", I.safe_next("leaders/") == "/account")
check("empty falls back", I.safe_next("") == "/leaders/")
check("None falls back", I.safe_next(None) == "/leaders/")
check("absolute URL rejected", I.safe_next("https://evil.example/") == "/leaders/")
check("protocol-relative rejected", I.safe_next("//evil.example/") == "/leaders/")
check("backslash form rejected", I.safe_next("/\\evil.example") == "/leaders/")
check("control char rejected", I.safe_next("/leaders\r\nX: y") == "/leaders/")
check("no leading slash rejected", I.safe_next("leaders/") == "/leaders/")
check("custom default honoured", I.safe_next("nope", default="/") == "/")
print("\n%d passed, %d failed" % (PASS, FAIL))