feat(auth): reset a forgotten password from an emailed link (milestone 497)
CI / lint (push) Failing after 3s
CI / integration (push) Successful in 1m37s
CI / unit (push) Failing after 1m43s
CI / publish (push) Skipped

Step #5429 "Reset by email: request, link and new-password page" of
milestone 497 "Passwords: change your own, and reset it by email".

- Sign-in page: "Forgot your password?" while a mail server is set.
- /forgot takes a username or email and answers the same either way; only
  an active local account with a real address gets mail. Requests count
  per address on their own throttle, so they never lock out sign-in.
- password_resets (migration 0039): a one-time key, stored as its sha256,
  working for an hour; asking again replaces it.
- /reset/<key>: the new password, then signed straight in; every other
  browser is signed out (sessions_since). Bad keys count as invite keys do.
- Email "[Steward] Reset your password", in the account's own zone.
- Audited: user.password_reset_requested, user.password_reset_by_email.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-08 20:17:24 -04:00
co-authored by Claude Opus 5.5
parent ea523b8c37
commit 593b583d23
17 changed files with 528 additions and 7 deletions
+3 -1
View File
@@ -451,11 +451,13 @@ a.stat-tile:hover { border-color: var(--border-mid); background: var(--bg-elevat
.form-section > .section-title { margin-bottom: 0.9rem; }
.form-pair { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(14rem, 100%), 1fr)); gap: 0 1rem; }
.form-pair[hidden] { display: none; }
/* The invite page (milestone 496): the login page's centred card, with labelled fields. */
/* The invite, forgot-password and reset pages (milestones 496, 497): the login
page's centred card, with labelled fields. */
.invite-page { max-width: 420px; margin: 4rem auto; }
.invite-title { font-family: var(--font-serif); font-size: 1.5rem; font-weight: 500; margin: 0 0 0.5rem; }
.invite-lede { color: var(--text-muted); margin: 0 0 1.25rem; }
.invite-submit { width: 100%; margin-top: 0.25rem; }
.login-forgot { text-align: center; margin: 1rem 0 0; font-size: 0.85rem; }
/* A link shown once to copy (an invite without mail): the value and its Copy button. */
.copy-once { display: flex; gap: 0.5rem; align-items: center; }
.copy-once .input { flex: 1; font-family: ui-monospace, monospace; font-size: 0.8rem; }