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.
175 lines
5.6 KiB
Go
175 lines
5.6 KiB
Go
package services
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/jmoiron/sqlx"
|
|
|
|
"mgit.msbls.de/m/patholo/internal/models"
|
|
"mgit.msbls.de/m/patholo/internal/offices"
|
|
)
|
|
|
|
// Sentinel errors returned by UserService.
|
|
var (
|
|
// ErrUserAlreadyOnboarded is returned when POST /api/onboarding is called
|
|
// for a paliad.users row that already exists (409 Conflict on the wire).
|
|
ErrUserAlreadyOnboarded = errors.New("user already onboarded")
|
|
// ErrAdminBootstrapOnly signals an attempt to self-assign the 'admin' role
|
|
// when other paliad.users rows already exist. Only the very first user can
|
|
// bootstrap themselves as admin (403 Forbidden on the wire).
|
|
ErrAdminBootstrapOnly = errors.New("admin role reserved for the first user")
|
|
)
|
|
|
|
// Valid role values — mirrors the CHECK constraint on paliad.users.role
|
|
// (migration 002). Keep in sync.
|
|
var validRoles = map[string]bool{
|
|
"partner": true,
|
|
"associate": true,
|
|
"pa": true,
|
|
"admin": true,
|
|
}
|
|
|
|
// UserService reads paliad.users. Writes happen via the Phase D onboarding
|
|
// endpoint and are not exposed here yet.
|
|
type UserService struct {
|
|
db *sqlx.DB
|
|
}
|
|
|
|
// NewUserService wires the service to the pool.
|
|
func NewUserService(db *sqlx.DB) *UserService {
|
|
return &UserService{db: db}
|
|
}
|
|
|
|
const userColumns = `id, email, display_name, office, practice_group, role,
|
|
created_at, updated_at`
|
|
|
|
// GetByID returns the user row, or (nil, nil) if the user hasn't completed
|
|
// onboarding yet. Real errors bubble up.
|
|
func (s *UserService) GetByID(ctx context.Context, id uuid.UUID) (*models.User, error) {
|
|
var u models.User
|
|
err := s.db.GetContext(ctx, &u,
|
|
`SELECT `+userColumns+` FROM paliad.users WHERE id = $1`, id)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return nil, nil
|
|
}
|
|
if err != nil {
|
|
return nil, fmt.Errorf("get user: %w", err)
|
|
}
|
|
return &u, nil
|
|
}
|
|
|
|
// CreateUserInput is the payload for the onboarding flow (POST /api/onboarding).
|
|
type CreateUserInput struct {
|
|
DisplayName string `json:"display_name"`
|
|
Office string `json:"office"`
|
|
Role string `json:"role"`
|
|
PracticeGroup *string `json:"practice_group,omitempty"`
|
|
}
|
|
|
|
// Create inserts the paliad.users row for the authenticated user. The caller
|
|
// owns the (id, email) pair — they come from the verified JWT claims, never
|
|
// from the request body, which prevents a user from creating a row for a
|
|
// different auth.uid().
|
|
//
|
|
// Role validation:
|
|
// - Must be one of partner/associate/pa/admin (CHECK constraint backup).
|
|
// - 'admin' is reserved: only allowed when the paliad.users table is empty
|
|
// (bootstrap admin). Subsequent users who ask for 'admin' are rejected —
|
|
// an existing admin must promote them via SQL / future admin UI.
|
|
//
|
|
// Returns ErrUserAlreadyOnboarded if the row exists (callers map to 409).
|
|
func (s *UserService) Create(ctx context.Context, id uuid.UUID, email string, input CreateUserInput) (*models.User, error) {
|
|
displayName := strings.TrimSpace(input.DisplayName)
|
|
if displayName == "" {
|
|
return nil, fmt.Errorf("display_name is required")
|
|
}
|
|
if !offices.IsValid(input.Office) {
|
|
return nil, fmt.Errorf("invalid office %q", input.Office)
|
|
}
|
|
role := input.Role
|
|
if role == "" {
|
|
role = "associate"
|
|
}
|
|
if !validRoles[role] {
|
|
return nil, fmt.Errorf("invalid role %q", role)
|
|
}
|
|
|
|
var practiceGroup *string
|
|
if input.PracticeGroup != nil {
|
|
trimmed := strings.TrimSpace(*input.PracticeGroup)
|
|
if trimmed != "" {
|
|
practiceGroup = &trimmed
|
|
}
|
|
}
|
|
|
|
tx, err := s.db.BeginTxx(ctx, nil)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("begin tx: %w", err)
|
|
}
|
|
defer tx.Rollback()
|
|
|
|
// Refuse a second row for the same auth.uid(). The PRIMARY KEY would also
|
|
// catch this, but we want a typed error (not a raw pq unique-violation).
|
|
var exists bool
|
|
if err := tx.GetContext(ctx, &exists,
|
|
`SELECT EXISTS (SELECT 1 FROM paliad.users WHERE id = $1)`, id); err != nil {
|
|
return nil, fmt.Errorf("check existing user: %w", err)
|
|
}
|
|
if exists {
|
|
return nil, ErrUserAlreadyOnboarded
|
|
}
|
|
|
|
// Admin bootstrap gate: only allow 'admin' when no other users exist yet.
|
|
// Under Postgres' default READ COMMITTED isolation, two concurrent
|
|
// first-logins both asking for 'admin' could both see count=0 and both
|
|
// succeed. A transaction-scoped advisory lock serialises the check+insert
|
|
// so only one bootstrap can win; the lock is auto-released on commit or
|
|
// rollback. The constant is arbitrary but stable — every admin-bootstrap
|
|
// tx takes the same lock.
|
|
if role == "admin" {
|
|
if _, err := tx.ExecContext(ctx,
|
|
`SELECT pg_advisory_xact_lock(7346298141)`); err != nil {
|
|
return nil, fmt.Errorf("lock for admin bootstrap: %w", err)
|
|
}
|
|
var count int
|
|
if err := tx.GetContext(ctx, &count,
|
|
`SELECT count(*) FROM paliad.users`); err != nil {
|
|
return nil, fmt.Errorf("count users: %w", err)
|
|
}
|
|
if count > 0 {
|
|
return nil, ErrAdminBootstrapOnly
|
|
}
|
|
}
|
|
|
|
if _, err := tx.ExecContext(ctx,
|
|
`INSERT INTO paliad.users (id, email, display_name, office, practice_group, role)
|
|
VALUES ($1, $2, $3, $4, $5, $6)`,
|
|
id, email, displayName, input.Office, practiceGroup, role,
|
|
); err != nil {
|
|
return nil, fmt.Errorf("insert user: %w", err)
|
|
}
|
|
|
|
if err := tx.Commit(); err != nil {
|
|
return nil, fmt.Errorf("commit create user: %w", err)
|
|
}
|
|
|
|
return s.GetByID(ctx, id)
|
|
}
|
|
|
|
// List returns all users (used by collaborator-picker in Phase D).
|
|
func (s *UserService) List(ctx context.Context) ([]models.User, error) {
|
|
var users []models.User
|
|
if err := s.db.SelectContext(ctx, &users,
|
|
`SELECT `+userColumns+`
|
|
FROM paliad.users
|
|
ORDER BY display_name, email`); err != nil {
|
|
return nil, fmt.Errorf("list users: %w", err)
|
|
}
|
|
return users, nil
|
|
}
|