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:
@@ -22,6 +22,7 @@ import (
|
||||
"os/signal"
|
||||
"time"
|
||||
|
||||
"gitea.d-ma.be/mathias/tapir/internal/adapters/dex"
|
||||
"gitea.d-ma.be/mathias/tapir/internal/adapters/secrets"
|
||||
"gitea.d-ma.be/mathias/tapir/internal/adapters/store"
|
||||
"gitea.d-ma.be/mathias/tapir/internal/auth"
|
||||
@@ -182,6 +183,20 @@ func cmdServe(ctx context.Context, log *slog.Logger) error {
|
||||
secretStore := secrets.NewFileStore(cfg.SecretsFile)
|
||||
app := &web.App{Store: st, Identity: st, Auth: authn, Secrets: secretStore, Log: log}
|
||||
|
||||
// Email-invite onboarding (public /invite/{token}). The store validates and
|
||||
// consumes tokens; the Dex client creates the local-password account. In-cluster
|
||||
// the SA token mount is present and account creation works; off-cluster (dev) it
|
||||
// is nil and the submit handler degrades to a clear "deployed-only" message.
|
||||
app.Invitations = st
|
||||
if dexClient, err := dex.NewPasswordClient(); err == nil {
|
||||
app.Dex = dexClient
|
||||
log.Info("invite account creation enabled (in-cluster dex password client)")
|
||||
} else if errors.Is(err, dex.ErrNotInCluster) {
|
||||
log.Warn("invite account creation disabled: not in-cluster — /invite is deployed-only")
|
||||
} else {
|
||||
return fmt.Errorf("dex password client: %w", err)
|
||||
}
|
||||
|
||||
// Web-initiated YouTube connect (ADR-006). Mounted only when the OAuth client
|
||||
// credentials are present; the refresh token persists through the SecretStore
|
||||
// under a per-user ref (web.YouTubeTokenRef). Live connect also needs the
|
||||
|
||||
Reference in New Issue
Block a user