Repository navigation
feat(admin): password reset, 2FA reset and a first-admin invite that works on the distroless image - #113
Merged
Conversation
…works on the distroless image - Forgot password on the sign-in page (same answer for any address) and a /reset-password page; another admin can send a reset (admin.password_reset). - Reset 2FA for another admin from the Admins page (admin.reset_two_factor, reconcile verdict !twoFactorEnabled) and `admin reset-2fa <email>` for the last admin (system:cli, exact email required). - Spent backup codes are audited (admin.backup_code_used). - `admin migrate` emails the bootstrap admin's invite once, so the first admin needs no shell in the image. - README: lock-out runbook. Ported from jayleaton/auto-visuals#47 (generic part). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…invite Found in the live run: /reset-password with a used token showed the invite page's wording. Also: errorlint in a test, and the audit page callout names used backup codes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ery code JJ's code rules, applied to everything this PR adds or touches: receivers of the types it extends (AdminService, Admins, AdminAccounts, UIAuth, AuthEventService, AuditReconciler), params, locals and closure args, the templ components it changed, and the tests it extends. Named consts for the email-subcommand arg count and the new-password length hint. The UI's u *UI receiver stays: it spans 49 methods in four files, most untouched here, and feat/route-access is reworking ui.go. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
jayleaton
force-pushed
the
feat/admin-2fa-recovery
branch
from
October 23, 2026 09:24
209eb4d to
eac40d9
Compare
jayleaton
marked this pull request as ready for review
October 23, 2026 09:26
This was referenced Oct 23, 2026
jayleaton
added a commit
that referenced
this pull request
Oct 23, 2026
…nce its link expires Rebuilt on main, where #113 already has admin migrate send the bootstrap invite once. What this keeps from the original PR is the part main lacks: InviteBootstrapAdmin resends once the last invite is older than the link lifetime (inviteTTL = auth.DefaultInviteTTL, also set as the admin auth instance's InviteTTL so the two cannot drift), so a first email that never arrived or expired heals on a later deploy, and it never invites a disabled pending admin. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
jayleaton
added a commit
that referenced
this pull request
Oct 23, 2026
…nce its link expires Rebuilt on main, where #113 already has admin migrate send the bootstrap invite once. What this keeps from the original PR is the part main lacks: InviteBootstrapAdmin resends once the last invite is older than the link lifetime (inviteTTL = auth.DefaultInviteTTL, also set as the admin auth instance's InviteTTL so the two cannot drift), so a first email that never arrived or expired heals on a later deploy, and it never invites a disabled pending admin. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
jayleaton
added a commit
that referenced
this pull request
Oct 23, 2026
…nce its link expires Rebuilt on main, where #113 already has admin migrate send the bootstrap invite once. What this keeps from the original PR is the part main lacks: InviteBootstrapAdmin resends once the last invite is older than the link lifetime (inviteTTL = auth.DefaultInviteTTL, also set as the admin auth instance's InviteTTL so the two cannot drift), so a first email that never arrived or expired heals on a later deploy, and it never invites a disabled pending admin. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
jayleaton
added a commit
that referenced
this pull request
Oct 23, 2026
…nce its link expires (#102) Rebuilt on main, where #113 already has admin migrate send the bootstrap invite once. What this keeps from the original PR is the part main lacks: InviteBootstrapAdmin resends once the last invite is older than the link lifetime (inviteTTL = auth.DefaultInviteTTL, also set as the admin auth instance's InviteTTL so the two cannot drift), so a first email that never arrived or expired heals on a later deploy, and it never invites a disabled pending admin. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
An admin who forgot their password or lost their authenticator had no way back in. On the distroless image there was also no way to create the first admin:
admin migrateseededADMIN_BOOTSTRAP_EMAILand then said "send its set-password link withadmin invite", which needs a shell the image doesn't have. This ports the generic part of jayleaton/auto-visuals#47.admin migrate (pre-deploy, runs inside the image) Bootstrap(ADMIN_BOOTSTRAP_EMAIL) seeds a pending admin (unchanged) + InviteBootstrapAdmin emails the set-password link once (pending + no invite on record) /login + "Forgot password?" -> /forgot-password same answer for any address; 1 h link + /reset-password?token= same token kind as invites; other sessions end; 2FA still asked /admins (another admin; never yourself; never a pending invite) + Send password reset admin.password_reset + Reset 2FA (confirm dialog) admin.reset_two_factor, reconcile verdict !twoFactorEnabled auth events + backup code accepted admin.backup_code_used (best-effort, like sign-ins) CLI + admin reset-2fa <email> the last admin lost authenticator + backup codes (system:cli)Also on the JSON API (
POST /api/admin/v1/admins/{id}/password-reset,…/two-factor/reset),make admin-reset-2fa EMAIL=…, and a lock-out runbook indocs/admin.md(rebased onto #107's README split).What was already there and is unchanged:
packages/authalready generated 10 backup codes once at enrolment, stored them as keyed hashes under the data key, and spent each on use (TestBackupCodesAreSingleUse). It also hadUserAdmin.ResetTwoFactorand password-reset tokens thatCompleteSetPasswordaccepts. No change topackages/auth(the OAuth branch owns it). No migration.admin reset-2fais the static binary alone and never starts a shell, so it runs anywhere the binary does.railway sshitself needs a shell in the container it opens, so on Railway the runbook indocs/admin.mdruns the binary fromadmin-postgres(same private network, hassh). That is the method auto-visuals#47 verified on staging. I did not re-run it on Railway here.Evidence
Local, this branch (
bin/adminbuilt from it), dev Postgres frommake db-up,ADMIN_REQUIRE_2FA=true, driven by plain form posts plus one browser pass.First admin without a shell. Before:
admin migrateonly logged "bootstrap admin created; send its set-password link withadmin invite". After, run twice:Enrol, then use a backup code, then the same code again. Before: no backup-code use in the audit log. After:
admin reset-2faon the only admin, then sign in again. Before: no such command.Forgot password.
nobody@example.comandboot@example.comboth got "If that address belongs to an admin, a reset link is on its way." Only boot's reset email was logged. Then:(The live run caught that last message showing the invite wording, "Ask an admin to send a new invite". That is fixed in the second commit.)
Another admin recovers ops@ (Admins page, in a browser, through Datastar): "Send password reset" sent a link to ops@. "Reset 2FA" opened its confirm dialog and then showed "2FA removed. They enrol again at their next sign-in.", with ops' Two-factor column turning Off and the button gone. ops' next
POST /login→303 /two-factor/setup. A second reset → 422 "This admin has no two-factor authentication to reset."Audit log (
admin_audit_log⋈admin_audit_outcome, sign-outs omitted):Screenshots of the login link, forgot-password confirmation, Reset 2FA dialog, Admins page after the reset and the Audit page were taken locally but not uploaded: the template repo shouldn't carry image files.
Checks.
go test -cover: services 100.0 %, api 100.0 %.go vet ./apps/admin/...clean.golangci-lint run ./apps/admin/...0 issues.scripts/check-test-boundaries.shok. The first CI run caught one errorlint finding in a new test, fixed in the second commit; CI on the final commiteac40d9: run 39931611905 succeeded (full run: the Makefile changed). Localmake check-fastcould not finish on this box: compiles were killed from outside the cgroup (oom_kill 0, load ~30), so the wider checks ran on Actions instead.New tests:
TestSendPasswordReset,TestResetTwoFactor,TestResetTwoFactorByEmail,TestInviteBootstrapAdmin,TestBackupCodeUsed. The four new methods are added to the policy table (ResetTwoFactorByEmail,InviteBootstrapAdminare operator-only) and the four mutations to the fail-closed table. Reconcile addsreset 2fa *andpassword reset(undecidable) cases. API routesPOST admin password resetandPOST admin two-factor reset(401/403/400/404/503/200).Code rules (third commit): no single-letter names in anything this PR adds or touches (receivers of the types it extends, params, locals, closure args, the templ components it changed, the tests it extends), and named, commented consts for the email-subcommand arg count and the new-password length hint.
golangci-lint --enable-only mnd ./apps/admin/...(the linterchore/naming-lintadds) reports 14 findings, none on lines this PR changes. Kept on purpose:t *testing.T, and the UI'su *UIreceiver, which spans 49 methods in four files, most of them untouched here, whilefeat/route-accessis reworkingui.go.Seen, not fixed (outside this PR's area): the existing uikit confirm dialog inherits the actions cell's right-aligned, no-wrap text, so long messages clip (the Remove dialog does it on main too). It's a
packages/uikitfix for a separate PR.Merge Danger
Door: two-way. Additive routes, actions and translations; no migration, no
packages/authchange.Blast Radius: admin
admin migratenow sends one email on the first deploy whereADMIN_BOOTSTRAP_EMAILis set and that admin is still pending with no invite on record. A deployment that already invited its bootstrap admin sends nothing.packages/authbehaviour for set-password tokens). A disabled admin can reset but still cannot sign in./forgot-passwordand/reset-passwordare new public pages, rate-limited bypackages/auth(3 emails / min, 3 reset attempts / 10 s).Source: jayleaton/auto-visuals#47 (generic part only).
🤖 Generated with Claude Code