Files
paliad/frontend/build.ts
m b8f95f5d7a feat: user onboarding flow — first-login profile capture (t-paliad-019)
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.
2026-04-18 19:13:57 +02:00

118 lines
5.5 KiB
TypeScript

import { mkdir, cp, rm } from "fs/promises";
import { join } from "path";
import { renderIndex } from "./src/index";
import { renderLogin } from "./src/login";
import { renderKostenrechner } from "./src/kostenrechner";
import { renderFristenrechner } from "./src/fristenrechner";
import { renderDownloads } from "./src/downloads";
import { renderLinks } from "./src/links";
import { renderGlossar } from "./src/glossar";
import { renderGebuehrentabellen } from "./src/gebuehrentabellen";
import { renderChecklisten } from "./src/checklisten";
import { renderChecklistenDetail } from "./src/checklisten-detail";
import { renderChecklistenInstance } from "./src/checklisten-instance";
import { renderGerichte } from "./src/gerichte";
import { renderAkten } from "./src/akten";
import { renderAktenNeu } from "./src/akten-neu";
import { renderAktenDetail } from "./src/akten-detail";
import { renderFristen } from "./src/fristen";
import { renderFristenNeu } from "./src/fristen-neu";
import { renderFristenDetail } from "./src/fristen-detail";
import { renderFristenKalender } from "./src/fristen-kalender";
import { renderTermine } from "./src/termine";
import { renderTermineNeu } from "./src/termine-neu";
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");
async function build() {
// Clean dist/
await rm(DIST, { recursive: true, force: true });
await mkdir(join(DIST, "assets"), { recursive: true });
// Bundle client-side JS
const result = await Bun.build({
entrypoints: [
join(import.meta.dir, "src/client/index.ts"),
join(import.meta.dir, "src/client/login.ts"),
join(import.meta.dir, "src/client/kostenrechner.ts"),
join(import.meta.dir, "src/client/fristenrechner.ts"),
join(import.meta.dir, "src/client/downloads.ts"),
join(import.meta.dir, "src/client/links.ts"),
join(import.meta.dir, "src/client/glossar.ts"),
join(import.meta.dir, "src/client/gebuehrentabellen.ts"),
join(import.meta.dir, "src/client/checklisten.ts"),
join(import.meta.dir, "src/client/checklisten-detail.ts"),
join(import.meta.dir, "src/client/checklisten-instance.ts"),
join(import.meta.dir, "src/client/gerichte.ts"),
join(import.meta.dir, "src/client/akten.ts"),
join(import.meta.dir, "src/client/akten-neu.ts"),
join(import.meta.dir, "src/client/akten-detail.ts"),
join(import.meta.dir, "src/client/fristen.ts"),
join(import.meta.dir, "src/client/fristen-neu.ts"),
join(import.meta.dir, "src/client/fristen-detail.ts"),
join(import.meta.dir, "src/client/fristen-kalender.ts"),
join(import.meta.dir, "src/client/termine.ts"),
join(import.meta.dir, "src/client/termine-neu.ts"),
join(import.meta.dir, "src/client/termine-detail.ts"),
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",
minify: true,
});
if (!result.success) {
console.error("JS build failed:");
for (const log of result.logs) {
console.error(log);
}
process.exit(1);
}
// Copy CSS
await cp(
join(import.meta.dir, "src/styles/global.css"),
join(DIST, "assets/global.css"),
);
// Render HTML pages
await Bun.write(join(DIST, "index.html"), renderIndex());
await Bun.write(join(DIST, "login.html"), renderLogin("login.js"));
await Bun.write(join(DIST, "kostenrechner.html"), renderKostenrechner());
await Bun.write(join(DIST, "fristenrechner.html"), renderFristenrechner());
await Bun.write(join(DIST, "downloads.html"), renderDownloads());
await Bun.write(join(DIST, "links.html"), renderLinks());
await Bun.write(join(DIST, "glossar.html"), renderGlossar());
await Bun.write(join(DIST, "gebuehrentabellen.html"), renderGebuehrentabellen());
await Bun.write(join(DIST, "checklisten.html"), renderChecklisten());
await Bun.write(join(DIST, "checklisten-detail.html"), renderChecklistenDetail());
await Bun.write(join(DIST, "checklisten-instance.html"), renderChecklistenInstance());
await Bun.write(join(DIST, "gerichte.html"), renderGerichte());
await Bun.write(join(DIST, "akten.html"), renderAkten());
await Bun.write(join(DIST, "akten-neu.html"), renderAktenNeu());
await Bun.write(join(DIST, "akten-detail.html"), renderAktenDetail());
await Bun.write(join(DIST, "fristen.html"), renderFristen());
await Bun.write(join(DIST, "fristen-neu.html"), renderFristenNeu());
await Bun.write(join(DIST, "fristen-detail.html"), renderFristenDetail());
await Bun.write(join(DIST, "fristen-kalender.html"), renderFristenKalender());
await Bun.write(join(DIST, "termine.html"), renderTermine());
await Bun.write(join(DIST, "termine-neu.html"), renderTermineNeu());
await Bun.write(join(DIST, "termine-detail.html"), renderTermineDetail());
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/");
}
build();