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
+13
View File
@@ -68,6 +68,14 @@ type App struct {
// Processing tracks in-flight immediate summarizations so the status endpoint
// shows the animation until the summary lands. The zero value is ready to use.
Processing ProcessingSet
// Invitations validates and consumes email-invite tokens for the public
// /invite/{token} flow. Nil = the invite routes report "invalid" (the flow is
// effectively off). *store.Store satisfies it.
Invitations InvitationStore
// Dex creates the Dex local-password account when an invite is claimed. Nil =
// not in-cluster (dev): the submit handler degrades to a clear "deployed-only"
// message instead of creating an account. *dex.PasswordClient satisfies it.
Dex DexPasswordCreator
}
func (a *App) logger() *slog.Logger {
@@ -87,6 +95,11 @@ func (a *App) Router() http.Handler {
root.Handle("GET /static/", staticHandler())
root.Handle("/auth/", a.Auth.Routes())
// Email invitation claim (public — the visitor has no Dex session yet, so this
// sits OUTSIDE Auth.Middleware). The token in the path is the capability.
root.HandleFunc("GET /invite/{token}", a.handleInviteForm)
root.HandleFunc("POST /invite/{token}", a.handleInviteSubmit)
app := http.NewServeMux()
app.HandleFunc("GET /{$}", a.handleList)
app.HandleFunc("GET /v/{videoId}", a.handleDetail)