people management, account self-service, password reset by link

Until now nobody could be invited without a script and a forgotten
password was a locked account. The identity layer already had invites,
sessions and the capability map; this wires the levers to them.

Admin API (people:invite_leader and up; the break-glass token cannot act
here, it has no person): list people with roles, memberships, live
sessions and keys, plus open invites and what YOU may grant; invite with
the URL returned once (no outbound mail yet, hand it over yourself);
revoke an invite; replace roles and memberships in full (owner only);
disable and enable (owner only, never yourself, never the last owner;
disabling ends sessions now and keys die through can()); mint a one-time
24-hour reset link (admin may reset anyone but an owner). Every one logs
who, whom and what.

Site: /account grows details and change-password forms (current password
required; the other devices are signed out, this one stays). /reset/{token}
sets a password, burns the link, ends every session and signs the person
in. Expired, used, revoked and never-existed read the same.

tests/smoke_identity.py 92 -> 123. Driven end to end on a throwaway with
a DB copy: invite, accept, account forms, reset link used then reused
(410), disable (session dies, login 401), enable, roles.
This commit is contained in:
2026-09-04 18:18:39 -04:00
parent 7287f0b515
commit aaa77be14d
4 changed files with 598 additions and 6 deletions
+139 -5
View File
@@ -254,14 +254,148 @@ def account(request: Request):
if not rows:
rows.append('<div class="rrow"><span class="v">No roles assigned yet.</span></div>')
return HTMLResponse(_render("Your account", _account_page(person, request.query_params.get("done"))))
def _account_page(person, done=None, error=None, error_form=None):
rows = []
if person.get("global_role"):
rows.append('<div class="rrow"><span class="k" style="min-width:110px">Site-wide</span>'
'<span class="v">%s</span></div>'
% _esc(ROLE_LABEL.get(person["global_role"], person["global_role"])))
for m in person["memberships"]:
title = " · %s" % _esc(m["title"]) if m["title"] else ""
rows.append('<div class="rrow"><span class="k" style="min-width:110px">%s</span>'
'<span class="v">%s%s</span></div>'
% (_esc(m["short_name"]), _esc(ROLE_LABEL.get(m["role"], m["role"])), title))
if not rows:
rows.append('<div class="rrow"><span class="v">No roles assigned yet.</span></div>')
name = person.get("preferred_name") or person.get("full_name") or person["email"]
flash = {"details": "Details saved.", "password": "Password changed. Your other devices were signed out."}.get(done or "")
err_d = '<div class="autherr">%s</div>' % _esc(error) if error and error_form == "details" else ""
err_p = '<div class="autherr">%s</div>' % _esc(error) if error and error_form == "password" else ""
body = _shell(
"Your account", _esc(person["email"]),
f"""<div class="mcard" style="padding:18px 20px;margin:0 0 18px">
f"""{'<div class="success">%s</div>' % _esc(flash) if flash else ''}<div class="mcard" style="padding:18px 20px;margin:0 0 18px">
{''.join(rows)}
</div>
<form method="post" action="/logout"><button class="cta" type="submit">Sign out</button></form>
<p class="authhint">Changing your own details is not built yet. Ask an administrator.</p>""")
return HTMLResponse(_render("Your account", body.replace(
<form method="post" action="/account/details" class="mcard" style="padding:18px 20px;margin:0 0 18px">
<h2 class="sec" style="font-size:1.1rem;margin:0 0 4px">Your details</h2>{err_d}
<label for="full_name">Full name</label>
<input id="full_name" name="full_name" required value="{_esc(person.get('full_name'))}">
<label for="preferred_name">Preferred name <span style="font-weight:400;color:#6B7280">(optional)</span></label>
<input id="preferred_name" name="preferred_name" value="{_esc(person.get('preferred_name'))}">
<label for="phone">Mobile <span style="font-weight:400;color:#6B7280">(optional)</span></label>
<input id="phone" name="phone" type="tel" value="{_esc(person.get('phone'))}">
<button class="cta" type="submit" style="margin-top:16px">Save details</button>
</form>
<form method="post" action="/account/password" class="mcard" style="padding:18px 20px;margin:0 0 18px">
<h2 class="sec" style="font-size:1.1rem;margin:0 0 4px">Change password</h2>{err_p}
<label for="current">Current password</label>
<input id="current" name="current" type="password" autocomplete="current-password" required>
<label for="new">New password</label>
<input id="new" name="new" type="password" autocomplete="new-password" required minlength="12">
<label for="confirm">Confirm new password</label>
<input id="confirm" name="confirm" type="password" autocomplete="new-password" required minlength="12">
<button class="cta" type="submit" style="margin-top:16px">Change password</button>
<p class="authhint">At least 12 characters. Changing it signs out your other devices.</p>
</form>
<form method="post" action="/logout"><button class="cta" type="submit" style="background:#1E2F52;color:#fff">Sign out</button></form>
<p class="authhint">Your email address and roles are set by an administrator.</p>""")
return body.replace(
"<h1 class=\"sec\" style=\"margin:0 0 6px\">Your account</h1>",
"<h1 class=\"sec\" style=\"margin:0 0 6px\">Hello, %s</h1>" % _esc(name))))
"<h1 class=\"sec\" style=\"margin:0 0 6px\">Hello, %s</h1>" % _esc(name))
@router.post("/account/details")
def account_details(request: Request, full_name: str = Form(""), preferred_name: str = Form(""),
phone: str = Form("")):
person = current_person(request)
if not person:
return RedirectResponse(url="/login", status_code=303)
try:
identity.update_own_details(person["id"], full_name, preferred_name, phone)
except identity.IdentityError as e:
return HTMLResponse(_render("Your account", _account_page(person, error=e.detail, error_form="details")),
status_code=e.status)
return RedirectResponse(url="/account?done=details", status_code=303)
@router.post("/account/password")
def account_password(request: Request, current: str = Form(""), new: str = Form(""), confirm: str = Form("")):
person = current_person(request)
if not person:
return RedirectResponse(url="/login", status_code=303)
if new != confirm:
return HTMLResponse(_render("Your account", _account_page(person, error="Those two passwords do not match.",
error_form="password")), status_code=422)
try:
identity.change_password(person["id"], current, new, ip=_client_ip(request))
except identity.IdentityError as e:
return HTMLResponse(_render("Your account", _account_page(person, error=e.detail, error_form="password")),
status_code=e.status)
# End the other sessions, keep this one: the person is still here.
tok = request.cookies.get(COOKIE)
con = identity.connect()
try:
con.execute("UPDATE sessions SET revoked_at=? WHERE person_id=? AND revoked_at IS NULL AND token_hash<>?",
(identity._now(), person["id"], identity._hash_token(tok or "")))
con.commit()
finally:
con.close()
return RedirectResponse(url="/account?done=password", status_code=303)
# ---------------------------------------------------------------------------
# Password reset by link. The link is minted by an admin on the console and
# handed over out of band; there is no outbound mail. Same posture as
# invites: expired, used, revoked and never-existed all read the same.
# ---------------------------------------------------------------------------
DEAD_RESET = ("This reset link is no longer valid. It may have been used already, replaced by "
"a newer one, or expired. Ask an administrator for a fresh link.")
def _reset_form(token, person, error=None):
return _shell(
"Choose a new password", "For <strong>%s</strong>." % _esc(person["email"]),
f"""<form method="post" action="/reset/{_esc(token)}">
<label for="password">New password</label>
<input id="password" name="password" type="password" autocomplete="new-password" required minlength="12">
<label for="confirm">Confirm password</label>
<input id="confirm" name="confirm" type="password" autocomplete="new-password" required minlength="12">
<button class="cta" type="submit">Set password and sign in</button>
</form>
<p class="authhint">At least 12 characters. Every device signed in as you will be signed out.</p>""",
error)
@router.get("/reset/{token}", response_class=HTMLResponse)
def reset_form(request: Request, token: str):
person = identity.peek_reset(token)
if not person:
return HTMLResponse(_render("Reset", _shell("Reset", "", "", DEAD_RESET)), status_code=410)
return HTMLResponse(_render("Choose a new password", _reset_form(token, person)))
@router.post("/reset/{token}")
def reset_accept(request: Request, token: str, password: str = Form(""), confirm: str = Form("")):
person = identity.peek_reset(token)
if not person:
return HTMLResponse(_render("Reset", _shell("Reset", "", "", DEAD_RESET)), status_code=410)
if password != confirm:
return HTMLResponse(_render("Choose a new password",
_reset_form(token, person, "Those two passwords do not match.")), status_code=422)
try:
person = identity.consume_reset(token, password, ip=_client_ip(request))
except identity.IdentityError as e:
if e.status == 410:
return HTMLResponse(_render("Reset", _shell("Reset", "", "", DEAD_RESET)), status_code=410)
return HTMLResponse(_render("Choose a new password", _reset_form(token, person, e.detail)),
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.set_cookie(COOKIE, tok, max_age=identity.SESSION_ABSOLUTE_DAYS * 86400,
httponly=True, secure=COOKIE_SECURE, samesite="lax", path="/")
return resp