feat(web): public /invite/{token} set-password + account-creation flow

The Stage-1 onboarding path: an invited user opens their emailed link,
sets a password, and Tapir creates their Dex local-password account so
they can log in. Mounted on root OUTSIDE Auth.Middleware — the visitor
has no Dex session yet; the token in the path is the capability.

handleInviteForm previews the token (no consume) and shows the form, or
a clear "expired / already used" page. handleInviteSubmit validates the
password BEFORE consuming the token (a typo is retryable), then claims
the invite exactly once, bcrypt-hashes (cost 12), and creates the Dex
account — mapping ErrPasswordExists -> "log in instead" and ErrForbidden
-> "contact the administrator". Off-cluster (App.Dex nil) it degrades to
a "deployed-only" message without burning the token. On success it sets
an account_created flash and redirects to /auth/login.

Welcome sub-text now states access is invite-only. Handlers depend on
narrow ports (InvitationStore, DexPasswordCreator) so tests use fakes;
cmdServe wires the store + an in-cluster dex.PasswordClient.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-03 23:19:22 +02:00
co-authored by Claude Opus 4.8
parent 893886a60a
commit dece5dec44
10 changed files with 825 additions and 126 deletions
+6 -5
View File
@@ -11,11 +11,12 @@ const flashCookie = "tapir_flash"
// Flash codes. Kept small and stable — the message + severity live in
// flashMessages (view.go), not here, so the cookie never carries free text.
const (
flashConnected = "connected"
flashConnectFailed = "connect_failed"
flashDisconnected = "disconnected"
flashDeleted = "deleted"
flashRegistered = "registered"
flashConnected = "connected"
flashConnectFailed = "connect_failed"
flashDisconnected = "disconnected"
flashDeleted = "deleted"
flashRegistered = "registered"
flashAccountCreated = "account_created"
)
// flashMaxAge bounds how long an unread flash lingers (seconds). Long enough to