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>
56 lines
1.8 KiB
Go
56 lines
1.8 KiB
Go
package web
|
|
|
|
import "net/http"
|
|
|
|
// flashCookie carries a one-shot notification code between a POST→redirect and
|
|
// the next rendered page (PRG pattern). The value is a non-sensitive code (not
|
|
// user data), so it is not signed; HttpOnly + SameSite=Lax + a short MaxAge bound
|
|
// it. The flashBanner component maps the code to a styled message.
|
|
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"
|
|
flashAccountCreated = "account_created"
|
|
)
|
|
|
|
// flashMaxAge bounds how long an unread flash lingers (seconds). Long enough to
|
|
// survive the redirect, short enough that a stale banner never reappears.
|
|
const flashMaxAge = 60
|
|
|
|
// setFlash queues a one-shot notification surfaced by the next full page render.
|
|
func setFlash(w http.ResponseWriter, code string) {
|
|
http.SetCookie(w, &http.Cookie{
|
|
Name: flashCookie,
|
|
Value: code,
|
|
Path: "/",
|
|
MaxAge: flashMaxAge,
|
|
HttpOnly: true,
|
|
SameSite: http.SameSiteLaxMode,
|
|
})
|
|
}
|
|
|
|
// takeFlash returns the pending flash code (if any) and clears the cookie so the
|
|
// banner shows exactly once. Call it only on full-page renders, not HTMX
|
|
// fragments, so a fragment swap never consumes a flash meant for the next page.
|
|
func takeFlash(w http.ResponseWriter, r *http.Request) string {
|
|
c, err := r.Cookie(flashCookie)
|
|
if err != nil || c.Value == "" {
|
|
return ""
|
|
}
|
|
http.SetCookie(w, &http.Cookie{
|
|
Name: flashCookie,
|
|
Value: "",
|
|
Path: "/",
|
|
MaxAge: -1,
|
|
HttpOnly: true,
|
|
SameSite: http.SameSiteLaxMode,
|
|
})
|
|
return c.Value
|
|
}
|