New users were stuck on the dashboard with a dead-end "Bitte schließen Sie das Onboarding ab" message because nothing created the paliad.users row that all matter-management features depend on. This adds the missing Phase D flow. Backend - UserService.Create: validates display_name / office / role, inserts the paliad.users row with (id, email) from the verified JWT claims (never from the request body — prevents onboarding as someone else). - Admin bootstrap: only the very first paliad.users row may self-assign role='admin'; subsequent requests get ErrAdminBootstrapOnly (403). Guarded by pg_advisory_xact_lock so two concurrent first-logins can't race past the count=0 check under READ COMMITTED. - POST /api/onboarding + GET /onboarding; the page is authenticated but NOT behind the onboarding gate (it's the one page users without a paliad.users row may reach). - gateOnboarded middleware wraps the matter-management pages (Dashboard, Akten, Fristen, Termine, Einstellungen/CalDAV) and 302s to /onboarding when the caller has no paliad.users row. Knowledge-platform pages (Kostenrechner, Glossar, Links, Downloads, Gerichte, Gebührentabellen, Checklisten, Fristenrechner) stay ungated. - auth.VerifiedClaims now carries the email claim; auth.ClaimsFromContext exposes it to handlers. GET /api/me includes the email in the 404 body so the onboarding form can pre-fill the display name from the local-part. Frontend - frontend/src/onboarding.tsx + src/client/onboarding.ts: centred card on the existing .login-card styling. Fields: display_name (required, pre-filled from email local-part), office (dropdown from /api/offices), role (dropdown, default associate), practice_group (optional). - Dashboard client: toggleOnboardingHint now redirects to /onboarding instead of showing the dead-end hint — belt-and-braces behind the server gate in case the DB lookup fell through. - DE + EN i18n keys for every label, placeholder, and error. - Added onboarding to build.ts. Tests: internal/services/user_service_test.go covers the valid path, per-field validation, duplicate (ErrUserAlreadyOnboarded), and the admin-bootstrap gate. Follows the existing TEST_DATABASE_URL skip pattern.
68 lines
2.1 KiB
Go
68 lines
2.1 KiB
Go
package auth
|
|
|
|
import (
|
|
"context"
|
|
"net/http"
|
|
|
|
"github.com/google/uuid"
|
|
)
|
|
|
|
type contextKey string
|
|
|
|
const (
|
|
userIDContextKey contextKey = "paliad.userID"
|
|
claimsContextKey contextKey = "paliad.claims"
|
|
)
|
|
|
|
// UserIDFromContext returns the authenticated user's UUID, populated by
|
|
// WithUserID middleware (which runs after the session middleware).
|
|
// Returns (uuid.Nil, false) if no user is in context.
|
|
func UserIDFromContext(ctx context.Context) (uuid.UUID, bool) {
|
|
v, ok := ctx.Value(userIDContextKey).(uuid.UUID)
|
|
if !ok {
|
|
return uuid.Nil, false
|
|
}
|
|
return v, true
|
|
}
|
|
|
|
// withVerifiedClaims stores signature-verified JWT claims in the request
|
|
// context. Called only from Client.Middleware after VerifyToken succeeds.
|
|
func withVerifiedClaims(ctx context.Context, claims *VerifiedClaims) context.Context {
|
|
return context.WithValue(ctx, claimsContextKey, claims)
|
|
}
|
|
|
|
// verifiedClaimsFromContext returns the signature-verified JWT claims
|
|
// attached by Client.Middleware.
|
|
func verifiedClaimsFromContext(ctx context.Context) (*VerifiedClaims, bool) {
|
|
v, ok := ctx.Value(claimsContextKey).(*VerifiedClaims)
|
|
return v, ok
|
|
}
|
|
|
|
// ClaimsFromContext is the public accessor for the verified JWT claims
|
|
// attached by Client.Middleware. Handlers that need the raw email claim
|
|
// (onboarding uses it to seed paliad.users.email) go through this.
|
|
func ClaimsFromContext(ctx context.Context) (*VerifiedClaims, bool) {
|
|
return verifiedClaimsFromContext(ctx)
|
|
}
|
|
|
|
// WithUserID reads the `sub` claim from verified JWT claims attached by
|
|
// Client.Middleware and injects the user's UUID into the request context.
|
|
// Must run after Client.Middleware — the claims are only set there, after
|
|
// signature verification succeeds.
|
|
func (c *Client) WithUserID(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
claims, ok := verifiedClaimsFromContext(r.Context())
|
|
if !ok {
|
|
next.ServeHTTP(w, r)
|
|
return
|
|
}
|
|
uid, err := uuid.Parse(claims.Sub)
|
|
if err != nil {
|
|
next.ServeHTTP(w, r)
|
|
return
|
|
}
|
|
ctx := context.WithValue(r.Context(), userIDContextKey, uid)
|
|
next.ServeHTTP(w, r.WithContext(ctx))
|
|
})
|
|
}
|