Merge: user onboarding flow (first-login profile setup)
This commit is contained in:
@@ -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/");
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
|
||||
138
frontend/src/client/onboarding.ts
Normal file
138
frontend/src/client/onboarding.ts
Normal 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);
|
||||
});
|
||||
75
frontend/src/onboarding.tsx
Normal file
75
frontend/src/onboarding.tsx
Normal 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 — 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ändigen Sie Ihr Profil, damit Ihnen Akten, Fristen und Termine angezeigt werden kö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ü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>
|
||||
);
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
74
internal/handlers/onboarding.go
Normal file
74
internal/handlers/onboarding.go
Normal 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)
|
||||
}
|
||||
47
internal/handlers/onboarding_gate.go
Normal file
47
internal/handlers/onboarding_gate.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
162
internal/services/user_service_test.go
Normal file
162
internal/services/user_service_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user