Merge: user onboarding flow (first-login profile setup)

This commit is contained in:
m
2026-04-18 19:15:54 +02:00
14 changed files with 750 additions and 27 deletions

View File

@@ -25,6 +25,7 @@ import { renderTermineDetail } from "./src/termine-detail";
import { renderTermineKalender } from "./src/termine-kalender";
import { renderEinstellungenCalDAV } from "./src/einstellungen-caldav";
import { renderDashboard } from "./src/dashboard";
import { renderOnboarding } from "./src/onboarding";
const DIST = join(import.meta.dir, "dist");
@@ -61,6 +62,7 @@ async function build() {
join(import.meta.dir, "src/client/termine-kalender.ts"),
join(import.meta.dir, "src/client/einstellungen-caldav.ts"),
join(import.meta.dir, "src/client/dashboard.ts"),
join(import.meta.dir, "src/client/onboarding.ts"),
],
outdir: join(DIST, "assets"),
naming: "[name].js",
@@ -107,6 +109,7 @@ async function build() {
await Bun.write(join(DIST, "termine-kalender.html"), renderTermineKalender());
await Bun.write(join(DIST, "einstellungen-caldav.html"), renderEinstellungenCalDAV());
await Bun.write(join(DIST, "dashboard.html"), renderDashboard());
await Bun.write(join(DIST, "onboarding.html"), renderOnboarding());
console.log("Build complete \u2192 dist/");
}

View File

@@ -238,8 +238,17 @@ function renderActivity(items: ActivityEntry[]): void {
}
function toggleOnboardingHint(user: DashboardUser | null): void {
// Belt-and-braces: the server-side gate (gateOnboarded in handlers.go)
// already redirects users without a paliad.users row to /onboarding before
// the dashboard HTML is served. If the gate ever misses (e.g. DB lookup
// errored and we fell through), push the user to /onboarding here so they
// don't get stuck on a blank dashboard.
if (!user) {
window.location.href = "/onboarding";
return;
}
const onboarding = document.getElementById("dashboard-onboarding")!;
onboarding.style.display = user ? "none" : "block";
onboarding.style.display = "none";
}
function setCount(id: string, n: number): void {

View File

@@ -662,6 +662,28 @@ const translations: Record<Lang, Record<string, string>> = {
"dashboard.action.partei_added": "f\u00fcgte Partei hinzu",
"dashboard.action.partei_removed": "entfernte Partei",
// Onboarding (first-login profile capture)
"onboarding.title": "Willkommen \u2014 Paliad",
"onboarding.heading": "Willkommen bei Paliad",
"onboarding.lede": "Bitte vervollst\u00e4ndigen Sie Ihr Profil, damit Ihnen Akten, Fristen und Termine angezeigt werden k\u00f6nnen.",
"onboarding.display_name": "Anzeigename",
"onboarding.display_name.placeholder": "Vor- und Nachname",
"onboarding.office": "B\u00fcro",
"onboarding.office.placeholder": "Bitte ausw\u00e4hlen",
"onboarding.role": "Rolle",
"onboarding.role.associate": "Associate",
"onboarding.role.partner": "Partner",
"onboarding.role.pa": "PA",
"onboarding.role.admin": "Admin",
"onboarding.practice_group": "Praxisgruppe",
"onboarding.practice_group.placeholder": "z.B. Patent Litigation",
"onboarding.optional": "(optional)",
"onboarding.submit": "Profil anlegen",
"onboarding.error.display_name": "Bitte Anzeigename eingeben.",
"onboarding.error.office": "Bitte B\u00fcro ausw\u00e4hlen.",
"onboarding.error.generic": "Profil konnte nicht angelegt werden.",
"onboarding.error.connection": "Verbindungsfehler. Bitte versuchen Sie es erneut.",
// Termine + CalDAV (Phase F)
"nav.termine": "Termine",
"nav.group.einstellungen": "Einstellungen",
@@ -1440,6 +1462,28 @@ const translations: Record<Lang, Record<string, string>> = {
"dashboard.action.partei_added": "added party",
"dashboard.action.partei_removed": "removed party",
// Onboarding (first-login profile capture)
"onboarding.title": "Welcome \u2014 Paliad",
"onboarding.heading": "Welcome to Paliad",
"onboarding.lede": "Please complete your profile so that matters, deadlines, and appointments can be shown.",
"onboarding.display_name": "Display name",
"onboarding.display_name.placeholder": "First and last name",
"onboarding.office": "Office",
"onboarding.office.placeholder": "Please select",
"onboarding.role": "Role",
"onboarding.role.associate": "Associate",
"onboarding.role.partner": "Partner",
"onboarding.role.pa": "PA",
"onboarding.role.admin": "Admin",
"onboarding.practice_group": "Practice group",
"onboarding.practice_group.placeholder": "e.g. Patent Litigation",
"onboarding.optional": "(optional)",
"onboarding.submit": "Create profile",
"onboarding.error.display_name": "Please enter a display name.",
"onboarding.error.office": "Please select an office.",
"onboarding.error.generic": "Could not create profile.",
"onboarding.error.connection": "Connection error. Please try again.",
// Termine + CalDAV (Phase F)
"nav.termine": "Appointments",
"nav.group.einstellungen": "Settings",

View File

@@ -0,0 +1,138 @@
import { initI18n, onLangChange, getLang, t } from "./i18n";
interface Office {
key: string;
label_de: string;
label_en: string;
}
let offices: Office[] = [];
async function seedDisplayName(): Promise<void> {
const input = document.getElementById("onb-display-name") as HTMLInputElement;
if (!input || input.value) return;
// /api/me returns 404 for users without a paliad.users row, but includes
// the JWT email so we can pre-fill the display name from the local-part
// (e.g. "m.flexsiebels" from "m.flexsiebels@hlc.com"). Best-effort.
try {
const resp = await fetch("/api/me");
if (resp.status !== 404) return;
const body = await resp.json().catch(() => null);
const email = body && typeof body.email === "string" ? body.email : "";
const localPart = email.split("@")[0] || "";
if (localPart) input.value = localPart;
} catch {
/* non-fatal */
}
}
async function loadOffices(): Promise<void> {
try {
const resp = await fetch("/api/offices");
if (!resp.ok) return;
offices = await resp.json();
} catch {
offices = [];
}
renderOfficeOptions();
}
function renderOfficeOptions(): void {
const select = document.getElementById("onb-office") as HTMLSelectElement | null;
if (!select) return;
const isEN = getLang() === "en";
const previous = select.value;
const placeholder = `<option value="" disabled selected>${esc(t("onboarding.office.placeholder"))}</option>`;
select.innerHTML =
placeholder +
offices
.map((o) => {
const label = isEN ? o.label_en : o.label_de;
return `<option value="${esc(o.key)}">${esc(label)}</option>`;
})
.join("");
if (previous && offices.some((o) => o.key === previous)) {
select.value = previous;
}
}
function esc(s: string): string {
const d = document.createElement("div");
d.textContent = s;
return d.innerHTML;
}
function clearMessages(): void {
document.querySelectorAll(".login-error, .login-success").forEach((el) => el.remove());
}
function showMessage(msg: string, cls: "login-error" | "login-success"): void {
clearMessages();
const div = document.createElement("div");
div.className = cls;
div.textContent = msg;
const heading = document.querySelector(".onboarding-lede");
if (heading) heading.after(div);
}
async function submitForm(e: Event): Promise<void> {
e.preventDefault();
clearMessages();
const form = document.getElementById("onboarding-form") as HTMLFormElement;
const submitBtn = form.querySelector<HTMLButtonElement>('button[type="submit"]')!;
const data = new FormData(form);
const displayName = (data.get("display_name") as string || "").trim();
const office = (data.get("office") as string || "").trim();
const role = (data.get("role") as string || "").trim();
const practiceGroup = (data.get("practice_group") as string || "").trim();
if (!displayName) {
showMessage(t("onboarding.error.display_name"), "login-error");
return;
}
if (!office) {
showMessage(t("onboarding.error.office"), "login-error");
return;
}
const payload: Record<string, unknown> = {
display_name: displayName,
office,
role,
};
if (practiceGroup) payload.practice_group = practiceGroup;
submitBtn.disabled = true;
try {
const resp = await fetch("/api/onboarding", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (resp.ok) {
window.location.href = "/dashboard";
return;
}
if (resp.status === 409) {
// Row already exists — user is onboarded. Push them forward.
window.location.href = "/dashboard";
return;
}
const body = await resp.json().catch(() => ({}) as { error?: string });
showMessage(body.error || t("onboarding.error.generic"), "login-error");
submitBtn.disabled = false;
} catch {
showMessage(t("onboarding.error.connection"), "login-error");
submitBtn.disabled = false;
}
}
document.addEventListener("DOMContentLoaded", () => {
initI18n();
seedDisplayName();
document.getElementById("onboarding-form")!.addEventListener("submit", submitForm);
loadOffices();
onLangChange(renderOfficeOptions);
});

View File

@@ -0,0 +1,75 @@
import { h } from "./jsx";
import { Header } from "./components/Header";
import { Footer } from "./components/Footer";
export function renderOnboarding(): string {
return "<!DOCTYPE html>" + (
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title data-i18n="onboarding.title">Willkommen &mdash; Paliad</title>
<link rel="stylesheet" href="/assets/global.css" />
</head>
<body>
<Header />
<main className="login-main">
<div className="login-card onboarding-card">
<h1 className="onboarding-heading" data-i18n="onboarding.heading">Willkommen bei Paliad</h1>
<p className="onboarding-lede" data-i18n="onboarding.lede">
Bitte vervollst&auml;ndigen Sie Ihr Profil, damit Ihnen Akten, Fristen und Termine angezeigt werden k&ouml;nnen.
</p>
<form className="login-form" id="onboarding-form" autocomplete="off">
<label htmlFor="onb-display-name" className="login-label" data-i18n="onboarding.display_name">Anzeigename</label>
<input
type="text"
id="onb-display-name"
name="display_name"
required
autofocus
autocomplete="name"
className="login-input"
data-i18n-placeholder="onboarding.display_name.placeholder"
placeholder="Vor- und Nachname"
/>
<label htmlFor="onb-office" className="login-label" data-i18n="onboarding.office">B&uuml;ro</label>
<select id="onb-office" name="office" required className="login-input">
{/* Options populated from /api/offices at init. */}
</select>
<label htmlFor="onb-role" className="login-label" data-i18n="onboarding.role">Rolle</label>
<select id="onb-role" name="role" required className="login-input">
<option value="associate" data-i18n="onboarding.role.associate">Associate</option>
<option value="partner" data-i18n="onboarding.role.partner">Partner</option>
<option value="pa" data-i18n="onboarding.role.pa">PA</option>
<option value="admin" data-i18n="onboarding.role.admin">Admin</option>
</select>
<label htmlFor="onb-practice-group" className="login-label" data-i18n="onboarding.practice_group">
Praxisgruppe <span className="login-label-optional" data-i18n="onboarding.optional">(optional)</span>
</label>
<input
type="text"
id="onb-practice-group"
name="practice_group"
autocomplete="off"
className="login-input"
data-i18n-placeholder="onboarding.practice_group.placeholder"
placeholder="z.B. Patent Litigation"
/>
<button type="submit" className="login-button" data-i18n="onboarding.submit">Profil anlegen</button>
</form>
</div>
</main>
<Footer />
<script src="/assets/onboarding.js"></script>
</body>
</html>
);
}

View File

@@ -434,6 +434,32 @@ main {
margin-top: 1.5rem;
}
/* --- Onboarding (first-login profile capture) --- */
.onboarding-card {
max-width: 460px;
}
.onboarding-heading {
font-size: 1.5rem;
font-weight: 600;
margin: 0 0 0.5rem;
color: var(--color-text);
}
.onboarding-lede {
font-size: 0.9rem;
color: var(--color-text-muted);
line-height: 1.5;
margin: 0 0 1.5rem;
}
.login-label-optional {
font-weight: 400;
color: var(--color-text-muted);
margin-left: 0.25rem;
}
/* --- Nav Links --- */
.nav-link {

View File

@@ -169,8 +169,9 @@ func (c *Client) SignOut(accessToken string) {
// VerifiedClaims is the subset of Supabase JWT claims the app cares about.
// Populated only after signature + expiry verification succeed.
type VerifiedClaims struct {
Sub string
Exp time.Time
Sub string
Email string
Exp time.Time
}
// VerifyToken parses and fully validates a Supabase access token. It verifies
@@ -196,11 +197,12 @@ func (c *Client) VerifyToken(token string) (*VerifiedClaims, error) {
if sub == "" {
return nil, errors.New("no sub claim")
}
email, _ := claims["email"].(string)
var exp time.Time
if expClaim, err := claims.GetExpirationTime(); err == nil && expClaim != nil {
exp = expClaim.Time
}
return &VerifiedClaims{Sub: sub, Exp: exp}, nil
return &VerifiedClaims{Sub: sub, Email: email, Exp: exp}, nil
}
// readSessionCookie returns the value of the session cookie and whether the

View File

@@ -38,6 +38,13 @@ func verifiedClaimsFromContext(ctx context.Context) (*VerifiedClaims, bool) {
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

View File

@@ -159,39 +159,44 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
protected.HandleFunc("GET /api/offices", handleListOffices)
protected.HandleFunc("GET /api/dashboard", handleDashboardAPI)
// First-login profile capture — authenticated but NOT behind the
// onboarding gate (it's the one page a user without paliad.users may reach).
protected.HandleFunc("GET /onboarding", handleOnboardingPage)
protected.HandleFunc("POST /api/onboarding", handleCreateOnboarding)
// Phase G — Dashboard (logged-in landing). Server-renders the data
// payload inline; client boots from window.__PALIAD_DASHBOARD__ with no
// waterfall fetch (design audit §2.3).
protected.HandleFunc("GET /dashboard", handleDashboardPage)
protected.HandleFunc("GET /dashboard", gateOnboarded(handleDashboardPage))
// Phase D — server-rendered Akten pages (pre-built HTML; client TS calls
// the JSON APIs above). Sub-routes share the same detail HTML; the client
// reads window.location to pick the active tab.
protected.HandleFunc("GET /akten", handleAktenListPage)
protected.HandleFunc("GET /akten/neu", handleAktenNewPage)
protected.HandleFunc("GET /akten/{id}", handleAktenDetailPage)
protected.HandleFunc("GET /akten/{id}/verlauf", handleAktenDetailPage)
protected.HandleFunc("GET /akten/{id}/parteien", handleAktenDetailPage)
protected.HandleFunc("GET /akten/{id}/fristen", handleAktenDetailPage)
protected.HandleFunc("GET /akten/{id}/termine", handleAktenDetailPage)
protected.HandleFunc("GET /akten/{id}/dokumente", handleAktenDetailPage)
protected.HandleFunc("GET /akten/{id}/notizen", handleAktenDetailPage)
protected.HandleFunc("GET /akten/{id}/checklisten", handleAktenDetailPage)
protected.HandleFunc("GET /akten", gateOnboarded(handleAktenListPage))
protected.HandleFunc("GET /akten/neu", gateOnboarded(handleAktenNewPage))
protected.HandleFunc("GET /akten/{id}", gateOnboarded(handleAktenDetailPage))
protected.HandleFunc("GET /akten/{id}/verlauf", gateOnboarded(handleAktenDetailPage))
protected.HandleFunc("GET /akten/{id}/parteien", gateOnboarded(handleAktenDetailPage))
protected.HandleFunc("GET /akten/{id}/fristen", gateOnboarded(handleAktenDetailPage))
protected.HandleFunc("GET /akten/{id}/termine", gateOnboarded(handleAktenDetailPage))
protected.HandleFunc("GET /akten/{id}/dokumente", gateOnboarded(handleAktenDetailPage))
protected.HandleFunc("GET /akten/{id}/notizen", gateOnboarded(handleAktenDetailPage))
protected.HandleFunc("GET /akten/{id}/checklisten", gateOnboarded(handleAktenDetailPage))
// Phase E — Fristen (persistent deadline) pages
protected.HandleFunc("GET /fristen", handleFristenListPage)
protected.HandleFunc("GET /fristen/neu", handleFristenNewPage)
protected.HandleFunc("GET /fristen/kalender", handleFristenKalenderPage)
protected.HandleFunc("GET /fristen/{id}", handleFristenDetailPage)
protected.HandleFunc("GET /akten/{id}/fristen/neu", handleFristenNewPage)
protected.HandleFunc("GET /fristen", gateOnboarded(handleFristenListPage))
protected.HandleFunc("GET /fristen/neu", gateOnboarded(handleFristenNewPage))
protected.HandleFunc("GET /fristen/kalender", gateOnboarded(handleFristenKalenderPage))
protected.HandleFunc("GET /fristen/{id}", gateOnboarded(handleFristenDetailPage))
protected.HandleFunc("GET /akten/{id}/fristen/neu", gateOnboarded(handleFristenNewPage))
// Phase F — Termine pages
protected.HandleFunc("GET /termine", handleTermineListPage)
protected.HandleFunc("GET /termine/neu", handleTermineNewPage)
protected.HandleFunc("GET /termine/kalender", handleTermineKalenderPage)
protected.HandleFunc("GET /termine/{id}", handleTermineDetailPage)
protected.HandleFunc("GET /akten/{id}/termine/neu", handleTermineNewPage)
protected.HandleFunc("GET /einstellungen/caldav", handleEinstellungenCalDAVPage)
protected.HandleFunc("GET /termine", gateOnboarded(handleTermineListPage))
protected.HandleFunc("GET /termine/neu", gateOnboarded(handleTermineNewPage))
protected.HandleFunc("GET /termine/kalender", gateOnboarded(handleTermineKalenderPage))
protected.HandleFunc("GET /termine/{id}", gateOnboarded(handleTermineDetailPage))
protected.HandleFunc("GET /akten/{id}/termine/neu", gateOnboarded(handleTermineNewPage))
protected.HandleFunc("GET /einstellungen/caldav", gateOnboarded(handleEinstellungenCalDAVPage))
// Session middleware refreshes tokens; user-id middleware extracts the
// JWT sub claim into the request context for handlers that need it.

View File

@@ -0,0 +1,74 @@
package handlers
import (
"encoding/json"
"errors"
"net/http"
"mgit.msbls.de/m/patholo/internal/auth"
"mgit.msbls.de/m/patholo/internal/services"
)
// GET /onboarding — first-login profile capture. The page is authenticated
// (user must have logged in), but it is NOT behind the onboarding gate — it
// is the one place a user without a paliad.users row is allowed to land.
func handleOnboardingPage(w http.ResponseWriter, r *http.Request) {
// If the user is already onboarded, send them to the dashboard so the
// page doesn't become a dead end after a refresh.
if dbSvc != nil {
if uid, ok := auth.UserIDFromContext(r.Context()); ok {
if u, err := dbSvc.users.GetByID(r.Context(), uid); err == nil && u != nil {
http.Redirect(w, r, "/dashboard", http.StatusFound)
return
}
}
}
http.ServeFile(w, r, "dist/onboarding.html")
}
// POST /api/onboarding — creates the caller's paliad.users row. The id and
// email are pulled from the verified JWT claims so a user can't onboard as
// someone else.
func handleCreateOnboarding(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
claims, ok := auth.ClaimsFromContext(r.Context())
if !ok || claims.Email == "" {
writeJSON(w, http.StatusUnauthorized, map[string]string{
"error": "missing email claim — please sign out and back in",
})
return
}
var input services.CreateUserInput
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
return
}
u, err := dbSvc.users.Create(r.Context(), uid, claims.Email, input)
if err != nil {
switch {
case errors.Is(err, services.ErrUserAlreadyOnboarded):
writeJSON(w, http.StatusConflict, map[string]string{
"error": "onboarding already completed",
})
case errors.Is(err, services.ErrAdminBootstrapOnly):
writeJSON(w, http.StatusForbidden, map[string]string{
"error": "admin role cannot be self-assigned — choose associate, partner, or pa",
})
default:
// Validation errors from the service (bad office, bad role, empty
// display_name) come through as generic errors; surface them so
// the form can show a precise message.
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
}
return
}
writeJSON(w, http.StatusCreated, u)
}

View File

@@ -0,0 +1,47 @@
package handlers
import (
"log"
"net/http"
"mgit.msbls.de/m/patholo/internal/auth"
)
// gateOnboarded wraps a page handler so that an authenticated user who has
// not yet filled in paliad.users is redirected to /onboarding instead of
// landing on a page that will silently return empty data.
//
// Scope: matter-management pages (Dashboard, Akten, Fristen, Termine,
// CalDAV settings). The knowledge-platform pages (Kostenrechner, Glossar,
// Links, Downloads, Gerichte, Gebührentabellen, Checklisten, Fristenrechner)
// work without a paliad.users row and are deliberately NOT gated.
//
// The gate is a no-op when:
// - The DB is not configured (no services available → no row to check).
// - No user id is in context (will have been 302'd to /login already).
// - The lookup errors (we log, then fall through so a DB blip doesn't
// lock users out of pages that can render a graceful error instead).
func gateOnboarded(h http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if dbSvc == nil {
h(w, r)
return
}
uid, ok := auth.UserIDFromContext(r.Context())
if !ok {
h(w, r)
return
}
u, err := dbSvc.users.GetByID(r.Context(), uid)
if err != nil {
log.Printf("onboarding gate: lookup failed for %s: %v", uid, err)
h(w, r)
return
}
if u == nil {
http.Redirect(w, r, "/onboarding", http.StatusFound)
return
}
h(w, r)
}
}

View File

@@ -5,11 +5,16 @@ import (
"strconv"
"github.com/google/uuid"
"mgit.msbls.de/m/patholo/internal/auth"
)
// GET /api/me — returns the caller's paliad.users row (or 404 if onboarding
// hasn't happened yet). The frontend uses this to gate role-specific UI
// (partner/admin-only delete, partner-only firm_wide_visible checkbox, etc.).
//
// The 404 body includes the caller's JWT email so the onboarding form can
// pre-fill the display name from the email prefix without a second request.
func handleGetMe(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
@@ -24,7 +29,13 @@ func handleGetMe(w http.ResponseWriter, r *http.Request) {
return
}
if u == nil {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "no paliad.users row — onboarding required"})
body := map[string]string{
"error": "no paliad.users row — onboarding required",
}
if claims, ok := auth.ClaimsFromContext(r.Context()); ok {
body["email"] = claims.Email
}
writeJSON(w, http.StatusNotFound, body)
return
}
writeJSON(w, http.StatusOK, u)

View File

@@ -5,13 +5,35 @@ import (
"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 {
@@ -41,6 +63,104 @@ func (s *UserService) GetByID(ctx context.Context, id uuid.UUID) (*models.User,
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

View File

@@ -0,0 +1,162 @@
package services
import (
"context"
"errors"
"os"
"testing"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
_ "github.com/lib/pq"
"mgit.msbls.de/m/patholo/internal/db"
)
// user_service_test covers the onboarding Create path end-to-end against a
// real Postgres. Mirrors the akte_service_test setup pattern; skips when
// TEST_DATABASE_URL is unset.
func setupUserTest(t *testing.T) (*UserService, *sqlx.DB, func()) {
t.Helper()
url := os.Getenv("TEST_DATABASE_URL")
if url == "" {
t.Skip("TEST_DATABASE_URL not set — skipping live DB test")
}
if err := db.ApplyMigrations(url); err != nil {
t.Fatalf("apply migrations: %v", err)
}
pool, err := sqlx.Connect("postgres", url)
if err != nil {
t.Fatalf("connect: %v", err)
}
users := NewUserService(pool)
return users, pool, func() { pool.Close() }
}
func seedAuthUser(t *testing.T, pool *sqlx.DB, id uuid.UUID, email string) {
t.Helper()
if _, err := pool.ExecContext(context.Background(),
`INSERT INTO auth.users (id, email) VALUES ($1, $2)
ON CONFLICT (id) DO UPDATE SET email = EXCLUDED.email`, id, email); err != nil {
t.Fatalf("seed auth.users: %v", err)
}
}
func cleanupUsers(t *testing.T, pool *sqlx.DB, ids ...uuid.UUID) {
t.Helper()
ctx := context.Background()
for _, id := range ids {
pool.ExecContext(ctx, `DELETE FROM paliad.users WHERE id = $1`, id)
pool.ExecContext(ctx, `DELETE FROM auth.users WHERE id = $1`, id)
}
}
func TestUserService_Create_Valid(t *testing.T) {
users, pool, done := setupUserTest(t)
defer done()
// Ensure the table is empty so the bootstrap-admin gate is deterministic.
pool.ExecContext(context.Background(), `DELETE FROM paliad.users`)
id := uuid.MustParse("aaaaaaaa-0000-0000-0000-000000000001")
seedAuthUser(t, pool, id, "first@hlc.com")
defer cleanupUsers(t, pool, id)
u, err := users.Create(context.Background(), id, "first@hlc.com", CreateUserInput{
DisplayName: " First User ",
Office: "munich",
Role: "associate",
})
if err != nil {
t.Fatalf("create: %v", err)
}
if u == nil || u.ID != id {
t.Fatalf("unexpected user: %+v", u)
}
if u.DisplayName != "First User" {
t.Errorf("display_name not trimmed: %q", u.DisplayName)
}
if u.Office != "munich" || u.Role != "associate" || u.Email != "first@hlc.com" {
t.Errorf("field mismatch: %+v", u)
}
}
func TestUserService_Create_InvalidInput(t *testing.T) {
users, pool, done := setupUserTest(t)
defer done()
id := uuid.MustParse("aaaaaaaa-0000-0000-0000-000000000002")
seedAuthUser(t, pool, id, "x@hlc.com")
defer cleanupUsers(t, pool, id)
cases := []struct {
name string
input CreateUserInput
}{
{"missing display_name", CreateUserInput{Office: "munich", Role: "associate"}},
{"invalid office", CreateUserInput{DisplayName: "X", Office: "tokyo", Role: "associate"}},
{"invalid role", CreateUserInput{DisplayName: "X", Office: "munich", Role: "wizard"}},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if _, err := users.Create(context.Background(), id, "x@hlc.com", c.input); err == nil {
t.Fatalf("expected error")
}
})
}
}
func TestUserService_Create_DuplicateReturns409(t *testing.T) {
users, pool, done := setupUserTest(t)
defer done()
id := uuid.MustParse("aaaaaaaa-0000-0000-0000-000000000003")
seedAuthUser(t, pool, id, "dup@hlc.com")
defer cleanupUsers(t, pool, id)
ctx := context.Background()
in := CreateUserInput{DisplayName: "Dup", Office: "munich", Role: "associate"}
if _, err := users.Create(ctx, id, "dup@hlc.com", in); err != nil {
t.Fatalf("first create: %v", err)
}
_, err := users.Create(ctx, id, "dup@hlc.com", in)
if !errors.Is(err, ErrUserAlreadyOnboarded) {
t.Fatalf("expected ErrUserAlreadyOnboarded, got %v", err)
}
}
func TestUserService_Create_AdminBootstrapRespected(t *testing.T) {
users, pool, done := setupUserTest(t)
defer done()
ctx := context.Background()
// Start with an empty table so the first admin is the bootstrap admin.
pool.ExecContext(ctx, `DELETE FROM paliad.users`)
first := uuid.MustParse("aaaaaaaa-0000-0000-0000-000000000011")
second := uuid.MustParse("aaaaaaaa-0000-0000-0000-000000000012")
seedAuthUser(t, pool, first, "first@hlc.com")
seedAuthUser(t, pool, second, "second@hlc.com")
defer cleanupUsers(t, pool, first, second)
if _, err := users.Create(ctx, first, "first@hlc.com", CreateUserInput{
DisplayName: "First", Office: "munich", Role: "admin",
}); err != nil {
t.Fatalf("bootstrap admin: %v", err)
}
_, err := users.Create(ctx, second, "second@hlc.com", CreateUserInput{
DisplayName: "Second", Office: "munich", Role: "admin",
})
if !errors.Is(err, ErrAdminBootstrapOnly) {
t.Fatalf("expected ErrAdminBootstrapOnly, got %v", err)
}
// Non-admin role still works for the second user.
if _, err := users.Create(ctx, second, "second@hlc.com", CreateUserInput{
DisplayName: "Second", Office: "munich", Role: "associate",
}); err != nil {
t.Fatalf("second user as associate: %v", err)
}
}