Add a one-shot flash component used across the app — connect success, disconnect, account delete, and registration — instead of per-page ad-hoc markup. setFlash queues a short-lived HttpOnly+SameSite cookie carrying an opaque code; takeFlash consumes it on the next full-page render (not on HTMX fragments). flashBanner maps the code to a styled, role=status banner; the message text lives server-side in flashMessages so the cookie never carries free text and a forged/unknown code renders nothing. Wire it into the list page (the PRG landing spot for connect/registration) and set it on registration and connect-callback success. Styled with the existing design-system tokens; header gains an Account nav link. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
134 lines
4.7 KiB
Go
134 lines
4.7 KiB
Go
package web
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"net/http"
|
|
"strings"
|
|
)
|
|
|
|
// Identity is the narrow port the web layer uses to resolve a Dex subject to a
|
|
// tapir user and to register new ones (ADR-012). *store.Store satisfies it; tests
|
|
// can substitute a fake. It is deliberately separate from Store: identity
|
|
// resolution runs pre-scope (un-RLS'd map), whereas Store runs user-scoped.
|
|
type Identity interface {
|
|
UserBySubject(ctx context.Context, subject string) (userID string, found bool, err error)
|
|
RegisterUser(ctx context.Context, subject, displayName string) (userID string, err error)
|
|
}
|
|
|
|
// errNoCurrentUser indicates a scoped handler ran without a resolved user_id —
|
|
// only possible if it was reached outside the registration gate (a wiring bug).
|
|
var errNoCurrentUser = errors.New("web: no current user in request context")
|
|
|
|
// userIDCtxKey types the per-request resolved tapir user_id stored by the
|
|
// registration gate. Unexported so only this package can set it.
|
|
type userIDCtxKey struct{}
|
|
|
|
func withUserID(ctx context.Context, id string) context.Context {
|
|
return context.WithValue(ctx, userIDCtxKey{}, id)
|
|
}
|
|
|
|
// CurrentUserID returns the tapir user_id (UUID) the registration gate resolved
|
|
// for the request from the authenticated Dex subject. ok is false for requests
|
|
// that never passed the gate (e.g. /register, /auth/*). This is the seam handlers
|
|
// — and downstream features (per-user YouTube connect, account management) —
|
|
// scope every store access by.
|
|
func CurrentUserID(r *http.Request) (string, bool) {
|
|
id, ok := r.Context().Value(userIDCtxKey{}).(string)
|
|
return id, ok && id != ""
|
|
}
|
|
|
|
// registrationGate sits inside Auth.Middleware. For a gated request it resolves
|
|
// the authenticated subject → tapir user_id once and stashes it for handlers; a
|
|
// subject with no tapir user is redirected to /register. Exempt paths pass
|
|
// straight through (/register so an unregistered user can reach the form; /auth/*
|
|
// and /healthz are already public but listed for safety).
|
|
func (a *App) registrationGate(h http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if isRegistrationExempt(r.URL.Path) {
|
|
h.ServeHTTP(w, r)
|
|
return
|
|
}
|
|
user, ok := a.Auth.CurrentUser(r)
|
|
if !ok {
|
|
// Auth.Middleware should have caught this; redirect defensively.
|
|
http.Redirect(w, r, loginPath, http.StatusFound)
|
|
return
|
|
}
|
|
userID, found, err := a.Identity.UserBySubject(r.Context(), user.Subject)
|
|
if err != nil {
|
|
a.serverError(w, r, "resolve identity", err)
|
|
return
|
|
}
|
|
if !found {
|
|
http.Redirect(w, r, registerPath, http.StatusFound)
|
|
return
|
|
}
|
|
h.ServeHTTP(w, r.WithContext(withUserID(r.Context(), userID)))
|
|
})
|
|
}
|
|
|
|
// handleRegisterForm renders the registration form for an authenticated, not-yet-
|
|
// registered subject. An already-registered subject is sent to the app root.
|
|
func (a *App) handleRegisterForm(w http.ResponseWriter, r *http.Request) {
|
|
user, ok := a.Auth.CurrentUser(r)
|
|
if !ok {
|
|
http.Redirect(w, r, loginPath, http.StatusFound)
|
|
return
|
|
}
|
|
if _, found, err := a.Identity.UserBySubject(r.Context(), user.Subject); err != nil {
|
|
a.serverError(w, r, "resolve identity", err)
|
|
return
|
|
} else if found {
|
|
http.Redirect(w, r, "/", http.StatusSeeOther)
|
|
return
|
|
}
|
|
a.render(w, r, RegisterPage(user.Email, ""))
|
|
}
|
|
|
|
// handleRegister creates the tapir user for the authenticated subject from the
|
|
// submitted display name (terms must be accepted), then redirects to the app
|
|
// root. A double-submit by an already-registered subject is idempotent.
|
|
func (a *App) handleRegister(w http.ResponseWriter, r *http.Request) {
|
|
user, ok := a.Auth.CurrentUser(r)
|
|
if !ok {
|
|
http.Redirect(w, r, loginPath, http.StatusFound)
|
|
return
|
|
}
|
|
if _, found, err := a.Identity.UserBySubject(r.Context(), user.Subject); err != nil {
|
|
a.serverError(w, r, "resolve identity", err)
|
|
return
|
|
} else if found {
|
|
http.Redirect(w, r, "/", http.StatusSeeOther)
|
|
return
|
|
}
|
|
|
|
if err := r.ParseForm(); err != nil {
|
|
http.Error(w, "bad form", http.StatusBadRequest)
|
|
return
|
|
}
|
|
displayName := strings.TrimSpace(r.FormValue("display_name"))
|
|
accepted := r.FormValue("accept_terms") != ""
|
|
if displayName == "" || !accepted {
|
|
a.renderStatus(w, r, http.StatusBadRequest,
|
|
RegisterPage(user.Email, "Enter a display name and accept the terms to continue."))
|
|
return
|
|
}
|
|
|
|
if _, err := a.Identity.RegisterUser(r.Context(), user.Subject, displayName); err != nil {
|
|
a.serverError(w, r, "register user", err)
|
|
return
|
|
}
|
|
setFlash(w, flashRegistered)
|
|
http.Redirect(w, r, "/", http.StatusSeeOther)
|
|
}
|
|
|
|
const (
|
|
registerPath = "/register"
|
|
loginPath = "/auth/login"
|
|
)
|
|
|
|
func isRegistrationExempt(p string) bool {
|
|
return p == registerPath || p == "/healthz" || strings.HasPrefix(p, "/auth/")
|
|
}
|