diff --git a/docs/ARBEITSKARTEN.md b/docs/ARBEITSKARTEN.md new file mode 100644 index 00000000..e5b15f63 --- /dev/null +++ b/docs/ARBEITSKARTEN.md @@ -0,0 +1,68 @@ +# Arbeitskarten (Stand 2026-10-27) + +Reihenfolge = Abarbeitungsfolge. Status: offen / blockiert / Entscheidung nötig / erledigt. +Regel je Karte: eigener Branch, eigener PR, Tests auf 131 bzw. 204 (nie archivdms), PR-Beschreibung nennt Auswirkungen. + +## A. common/-Modul + +### A-01 Tag common/v0.1.0 — blockiert +- Ziel: Tag-Workflow `common-tag.yml` erzeugt `common/v0.1.0`. +- Blockiert durch: Runner `nexarch-131-runner` arbeitet CI-Warteschlange ab (Unit `act_runner.service` auf 131). +- Schritte: Warteschlange beobachten; bei Lauf-Fehler Log lesen und Workflow korrigieren; Tag per `git ls-remote --tags origin 'common/*'` prüfen. +- Fertig, wenn: Tag existiert und `go list -m gitea.perlbach24.de/scripte/nexarch/common@v0.1.0` auf 131 mit GOPRIVATE und `.netrc` funktioniert. +- Hinweis: Lesezugriff-Token auf 131 legt der User an; Claude fasst keine Tokens an. + +### A-02 Umstellung archive (Pilot) — wartet auf A-01 +- Vorlage: Branches `refactor/common-modul-pilot`, `refactor/common-batch-2`. +- Schritte: `require .../common v0.1.0` und go.sum; Importe von httpharden, svcauth, auditclient, tenantregistryclient (soweit in archive genutzt) umschreiben; interne Kopien löschen; gofmt wegen Import-Reihenfolge; Pflichttest-Gate von archive um common-Muster erweitern; Modul-Guards wiederherstellen. +- Test: `go build/vet/test ./...` im Modul auf 131 in frischem Wurfverzeichnis; DB-Tests auf 204 mit `dms_dev` und `TEST_ADMIN_DSN`. +- Nachweis: normalisierter md5-Vergleich (ohne Kommentare/Imports) zeigt, dass gelöschte Kopie und common-Paket gleich waren. +- Fertig, wenn: PR offen, Tests grün, Auswirkungsliste in der PR-Beschreibung. + +### A-03 bis A-07 Umstellung weiterer Module — je nach A-02 +Reihenfolge: dms, mail, ai, connect, workflow; danach Root-Modul. +- Pro Modul wie A-02. NICHT nach common: audittrail, cutoverstage, crypto, notifyclient, dms-Varianten von apierror/policyclient/httpharden, Root-httpclient. +- Je Modul eigener PR. + +### A-08 CI-DB-Tests ausweiten — Entscheidung nötig +- Stand: `testdb.AdminDSN` und `.gitea/workflows/db-tests.yml` (Label `nexarch-test`) sind gemergt, Workflow deckt nur `common/` ab. +- Entscheidungen des Users nötig: Runner mit Label `nexarch-test` auf 204 registrieren, Secret `TEST_ADMIN_DSN` (Wegwerf-DB, nie archivdms) setzen. +- Schritte danach: Modul-Tests auf `testdb.AdminDSN` umstellen (jeweils mit A-02..A-07), Workflow-Pfade ergänzen. + +### A-09 Reviewer für common/ — Entscheidung nötig +- Offene Frage: Gitea-Benutzernamen für CODEOWNERS/Review-Pflicht auf `common/`. + +## B. Portal (Vorschlag Fable, Detail siehe Gesprächsverlauf) + +### B-01 Fundament — erledigt auf feature/portal-ausbau (Entscheidung: Ports bleiben) +- Entscheidung: Adressen per Proxy-Pfad `/app//` oder weiter Ports. +- Schritte: `ModuleRegistryEntry` um `group`, `path`, optional `module`; Hrefs relativ, `resolveModuleHref` nur Fallback; Route `GET /api/module-urls` (liest `/.well-known/nexarch-modules`, Env-Override `NEXARCH_MODULE_URL_`, Cache); `ModuleGrid` nach `group` gruppieren; `tenantLabel` in `ModuleNavShell` aus Sitzungs-Identität statt Env. +- Dateien: `web/shl/components/ModuleNavShell.tsx`, `web/portal/lib/modules.ts`, `web/portal/app/ModuleGrid.tsx`, neu `web/portal/app/api/module-urls/route.ts`. +- Test: `web/portal` und `web/shl` vitest auf 131; Betriebsauftrag: Produktionsdatei des Modulverzeichnisses fehlt. + +### B-02 Arbeitsvorrat — erledigt auf feature/portal-ausbau (Entscheidung: X-User-Id als Übergang) +- Entscheidung: Übergang mit `X-User-Id` aus verifiziertem Token-`sub`, solange Workflow kein Bearer kennt. +- Schritte: Route `GET /api/worklist` (Token prüfen, serverseitig `/approvals`, `/tasks`, `/reminders/kanban`, Antwort nur Zähler); Cache-Schlüssel mit `sub`, 15 s, `Cache-Control: no-store`; Badges an Workflow-Kacheln und "Heute"-Leiste; Fehler je Quelle tolerant; Schnellsuche-Zeile als Deep-Link; Env `PORTAL_WORKFLOW_API_URL`. +- Datenschutz: nur eigene Zähler, keine Inhalte. + +### B-03 Admin-/Prüfer-Sicht — erledigt auf feature/portal-ausbau (ohne Favoriten und ohne Ampel an Kacheln, Statusnamen passen nicht sicher zu Registry-Keys) +- Route `GET /api/system-status` (nur nach `authorize(core.tenant.admin)`, Status-Endpunkt nur serverseitig); Route `GET /api/kennzahlen-summary` (Mandant und Rolle aus Claims, nur Totals); Ampel an Kacheln; Favoriten (localStorage, nur Modul-Keys) optional. + +### B-04 Umgebungsvariablen dokumentieren — erledigt (web/portal/README.md) +- Neue Variablen: `PORTAL_CORE_API_URL`, `PORTAL_TOKEN_ISSUER`, `PORTAL_TOKEN_AUDIENCE`, `PORTAL_VISIBLE_MODULES_CACHE_SECONDS`; dazu `PORTAL_POLICY_API_URL`, `PORTAL_POLICY_SERVICE_TOKEN`. Nirgends dokumentiert; Doku im Portal anlegen. + +### B-05 Zielmodul `portal` im Core — Entscheidung nötig +- Der Core stellt Modul-Tokens mit Zielmodul aus (Standard `dms`). Soll `portal` in `NEXARCH_ACCOUNT_MODULE_AUDIENCES` aufgenommen und `PORTAL_TOKEN_AUDIENCE=portal` gesetzt werden? Dann muss die Sitzung `?module=portal` mitschicken. + +### B-06 Workflow auf Bearer umstellen — Folgeticket +- Workflow-API von `X-User-Id` auf Bearer (Muster: `mail/internal/moduleauth`); danach Header im Portal-BFF entfernen. Gleiches gilt für Audit-Log in dms (`caller`-Query). + +## C. Laufender Betrieb + +### C-01 Offene PRs mergen (User) +- #373 (qa*-Gates, migration*), #374 (workflow contracttest), #375 (dms apierror 5xx), #380 (Docs), #372 (SHL: AccountBar, Portal-Route). +- Vor Merge DB-Tests auf 204 für #373, #374, #375 laufen lassen (nie gelaufen). + +### C-02 Aufräumen — nur mit Freigabe +- `/root/manticore-backup-pre29` auf 131 löschen, wenn der User es sagt. +- 204: Dateileichen im Manticore-Datenverzeichnis (`dms_src13_*`, `mail_documents_reindex_*`, `qa10loadtest_*`) nur nach Sichtung durch den User. diff --git a/web/dms-browser/components/AccountBar.tsx b/web/dms-browser/components/AccountBar.tsx index 183315c5..d9c4c826 100644 --- a/web/dms-browser/components/AccountBar.tsx +++ b/web/dms-browser/components/AccountBar.tsx @@ -1,13 +1,19 @@ "use client"; -import { LogoutButton } from "@nexarch/shl"; +import { AccountBar as SharedAccountBar } from "@nexarch/shl"; import { authMode, getSession } from "../lib/http"; -export function AccountBar() { - if (authMode() !== "token") return null; - return ( -
- getSession().logout()} /> -
- ); +// Duenner Wrapper statt kopierter JSX (Fable-Review auf Code-Dopplung): +// die eigentliche Darstellung lebt in @nexarch/shl, authMode()/getSession() +// sind aber App-spezifisch (eigenes lib/http.ts je App) und duerfen als +// Funktionen nicht von layout.tsx (Server Component) ueber die Props-Grenze +// an eine Client Component gereicht werden -- deshalb hier lokal aufgeloest. +async function loadIdentity() { + const s = getSession(); + await s.token(); + return s.identity(); +} + +export function AccountBar() { + return getSession().logout()} loadIdentity={loadIdentity} />; } diff --git a/web/dms-upload/components/AccountBar.tsx b/web/dms-upload/components/AccountBar.tsx index 183315c5..d9c4c826 100644 --- a/web/dms-upload/components/AccountBar.tsx +++ b/web/dms-upload/components/AccountBar.tsx @@ -1,13 +1,19 @@ "use client"; -import { LogoutButton } from "@nexarch/shl"; +import { AccountBar as SharedAccountBar } from "@nexarch/shl"; import { authMode, getSession } from "../lib/http"; -export function AccountBar() { - if (authMode() !== "token") return null; - return ( -
- getSession().logout()} /> -
- ); +// Duenner Wrapper statt kopierter JSX (Fable-Review auf Code-Dopplung): +// die eigentliche Darstellung lebt in @nexarch/shl, authMode()/getSession() +// sind aber App-spezifisch (eigenes lib/http.ts je App) und duerfen als +// Funktionen nicht von layout.tsx (Server Component) ueber die Props-Grenze +// an eine Client Component gereicht werden -- deshalb hier lokal aufgeloest. +async function loadIdentity() { + const s = getSession(); + await s.token(); + return s.identity(); +} + +export function AccountBar() { + return getSession().logout()} loadIdentity={loadIdentity} />; } diff --git a/web/pdf-viewer/components/AccountBar.tsx b/web/pdf-viewer/components/AccountBar.tsx index 183315c5..d9c4c826 100644 --- a/web/pdf-viewer/components/AccountBar.tsx +++ b/web/pdf-viewer/components/AccountBar.tsx @@ -1,13 +1,19 @@ "use client"; -import { LogoutButton } from "@nexarch/shl"; +import { AccountBar as SharedAccountBar } from "@nexarch/shl"; import { authMode, getSession } from "../lib/http"; -export function AccountBar() { - if (authMode() !== "token") return null; - return ( -
- getSession().logout()} /> -
- ); +// Duenner Wrapper statt kopierter JSX (Fable-Review auf Code-Dopplung): +// die eigentliche Darstellung lebt in @nexarch/shl, authMode()/getSession() +// sind aber App-spezifisch (eigenes lib/http.ts je App) und duerfen als +// Funktionen nicht von layout.tsx (Server Component) ueber die Props-Grenze +// an eine Client Component gereicht werden -- deshalb hier lokal aufgeloest. +async function loadIdentity() { + const s = getSession(); + await s.token(); + return s.identity(); +} + +export function AccountBar() { + return getSession().logout()} loadIdentity={loadIdentity} />; } diff --git a/web/portal/README.md b/web/portal/README.md new file mode 100644 index 00000000..7e579e6a --- /dev/null +++ b/web/portal/README.md @@ -0,0 +1,55 @@ +# NEXARCH Portal + +Startseite nach der Anmeldung: Kacheln der Module, die der angemeldete Benutzer sehen darf. +Die Modulliste kommt aus `MODULE_REGISTRY` in `@nexarch/shl`; `/api/visible-modules` filtert sie gegen die policy-api. + +## Umgebungsvariablen + +Browser-seitig (werden beim Build eingebettet): + +| Variable | Pflicht | Bedeutung | +|---|---|---| +| `NEXT_PUBLIC_CORE_API_BASE` | ja | Basis-URL des Core (`/auth/module-token`) | +| `NEXT_PUBLIC_LOGIN_URL` | nein | Ziel der Weiterleitung, wenn die Sitzung fehlt | + +Server-seitig (nur Route `/api/visible-modules`, nie im Browser sichtbar): + +| Variable | Pflicht | Bedeutung | +|---|---|---| +| `PORTAL_POLICY_API_URL` | ja | Basis-URL der policy-api (`POST /authorize`) | +| `PORTAL_POLICY_SERVICE_TOKEN` | ja | Dienst-Token für die policy-api (Secret) | +| `PORTAL_CORE_API_URL` | nein | Basis-URL für `/auth/module-jwks`; Standard: `NEXT_PUBLIC_CORE_API_BASE` | +| `PORTAL_TOKEN_ISSUER` | nein | Wenn gesetzt, muss der Token-Aussteller (`iss`) übereinstimmen | +| `PORTAL_TOKEN_AUDIENCE` | nein | Wenn gesetzt, muss das Token dieses Zielmodul (`aud`) tragen | +| `PORTAL_VISIBLE_MODULES_CACHE_SECONDS` | nein | Cache je Mandant und Rolle, Standard `30`, `0` schaltet ihn ab | +| `PORTAL_WORKFLOW_API_URL` | nein | Basis-URL der Workflow-API für den Arbeitsvorrat; ohne sie antwortet `/api/worklist` mit 503 und die Kacheln bleiben ohne Zähler | +| `PORTAL_WORKLIST_CACHE_SECONDS` | nein | Cache des Arbeitsvorrats je Mandant und Benutzer, Standard `15` | +| `PORTAL_STATUS_API_URL` | nein | Basis-URL der Statusseite (`/status/overview`) für den Betriebsblock (nur Admins) | +| `PORTAL_KENNZAHLEN_API_URL` | nein | Basis-URL der Kennzahlen-API (`/kennzahlen/totals`) für die Summenzeile (Recht `kennzahlen.read`) | +| `NEXARCH_MODULE_URL_` | nein | Ersetzt den festen Port-Href eines Moduls (z. B. `NEXARCH_MODULE_URL_DMS=https://dms.example`), ohne Rebuild | + +Fehlt `PORTAL_POLICY_API_URL`, `PORTAL_POLICY_SERVICE_TOKEN` oder die Core-URL, antwortet die Route mit 503. + +## Verhalten der Route + +- `GET /api/visible-modules` verlangt `Authorization: Bearer `; Signatur (Ed25519, JWKS vom Core) und Ablaufzeit werden geprüft, sonst 401. +- Mandant und Rolle stammen ausschließlich aus dem Token, nie aus der URL. +- Eine Rechteänderung wirkt wegen des Caches bis zu `PORTAL_VISIBLE_MODULES_CACHE_SECONDS` verzögert. +- Fällt die policy-api aus, bleibt das jeweilige Modul unsichtbar (fail-closed). + +## Arbeitsvorrat + +`GET /api/worklist` liefert nur Zähler (`approvals`, `tasks`, `remindersDue`), keine Inhalte. Benutzer und Mandant kommen aus dem Token; die Workflow-API kennt bisher nur den Header `X-User-Id` (Übergangsmodus), den das Portal serverseitig setzt. Gezählt werden nur eigene Einträge: Freigaben des Benutzers, offene Aufgaben mit ihm als Person, von ihm angelegte Wiedervorlagen (überfällig oder diese Woche). Fällt eine Quelle aus, ist ihr Wert `null` und die übrigen bleiben erhalten. Die Antwort trägt `Cache-Control: no-store`. + +## Betriebsblock + +`GET /api/system-status` (Recht `core.tenant.admin`) und `GET /api/kennzahlen-summary` (Recht `kennzahlen.read`) prüfen das Recht über die policy-api, bevor sie die Quelle abfragen, und geben nur Modulname, Status, Zeitpunkt bzw. drei Summenwerte zurück (Objekte, Löschvorgänge, Speicher). Die Kennzahlen-Quelle erhält Mandant und Rolle aus dem Token. Die Statusseite ist selbst nicht authentifiziert und darf nur vom Portal-Server aufgerufen werden. Ohne Recht antworten die Routen mit 403, der Block bleibt ausgeblendet. + +## Oberfläche + +Kacheln sind nach Arbeiten, Verwalten und Prüfen gruppiert (`group` in `MODULE_REGISTRY`). Das Mandanten-Label in der Navigation stammt aus der Sitzung. Die Suchzeile leitet an `/search?q=` weiter und erscheint nur, wenn das DMS für den Benutzer sichtbar ist. + +## Tests + +Auf dem Test-Host (nicht lokal): `npm install && npx vitest run && npx tsc --noEmit`. +Bekannt: `tsc` meldet Fehler zu `process` in `web/shl/components/ModuleNavShell.tsx` (`@types/node` fehlt). diff --git a/web/portal/app/AccountBar.tsx b/web/portal/app/AccountBar.tsx new file mode 100644 index 00000000..a99f3d95 --- /dev/null +++ b/web/portal/app/AccountBar.tsx @@ -0,0 +1,14 @@ +"use client"; + +import { AccountBar as SharedAccountBar } from "@nexarch/shl"; +import { getSession } from "../lib/http"; + +async function loadIdentity() { + const s = getSession(); + await s.token(); + return s.identity(); +} + +export function AccountBar() { + return getSession().logout()} loadIdentity={loadIdentity} />; +} diff --git a/web/portal/app/AdminPanel.tsx b/web/portal/app/AdminPanel.tsx new file mode 100644 index 00000000..7fa4c1ae --- /dev/null +++ b/web/portal/app/AdminPanel.tsx @@ -0,0 +1,63 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useI18n } from "@nexarch/shl"; +import { getSession } from "../lib/http"; +import type { KennzahlenSummary, ModuleStatusRow } from "../lib/adminData"; + +async function getJson(path: string): Promise { + try { + const res = await getSession().fetch(path); + return res.ok ? ((await res.json()) as T) : null; + } catch { + return null; + } +} + +export function statusLabel(status: string): string { + return status === "up" ? "in Betrieb" : status === "down" ? "gestört" : "unbekannt"; +} + +export function AdminPanel() { + const { t } = useI18n(); + const [status, setStatus] = useState(null); + const [kpi, setKpi] = useState(null); + + useEffect(() => { + let cancelled = false; + (async () => { + const [s, k] = await Promise.all([ + getJson<{ modules: ModuleStatusRow[] }>("/api/system-status"), + getJson("/api/kennzahlen-summary"), + ]); + if (cancelled) return; + setStatus(s?.modules ?? null); + setKpi(k); + })(); + return () => { + cancelled = true; + }; + }, []); + + if (!status && !kpi) return null; + return ( +
+

{t("portal.admin", "Betrieb")}

+ {status && ( +
    + {status.map((m) => ( +
  • + {m.name}: {statusLabel(m.status)} +
  • + ))} +
+ )} + {kpi && ( +

+ {t("portal.kpi.objects", "Objekte")}: {kpi.objektBestand} · {t("portal.kpi.deletions", "Löschvorgänge")}: {kpi.loeschvorgaenge} ·{" "} + {t("portal.kpi.storage", "Speicher")}: {(kpi.speichervolumenBytes / 1024 ** 3).toFixed(2)} GB +

+ )} +
+ ); +} diff --git a/web/portal/app/ModuleGrid.test.tsx b/web/portal/app/ModuleGrid.test.tsx index 2c50ff90..d6bcc7d6 100644 --- a/web/portal/app/ModuleGrid.test.tsx +++ b/web/portal/app/ModuleGrid.test.tsx @@ -1,25 +1,24 @@ import { describe, expect, it, vi } from "vitest"; import { resolveModuleHref } from "@nexarch/shl"; -import { fetchVisibleModules } from "./ModuleGrid"; +import { badgeFor, fetchWorklist, groupModules, fetchVisibleModules } from "./ModuleGrid"; describe("fetchVisibleModules", () => { - it("ruft die Portal-eigene Route mit tenant/role auf und gibt die Modulliste zurueck", async () => { + it("ruft die Portal-eigene Route ohne tenant/role in der URL auf und gibt die Modulliste zurueck", async () => { const fetchImpl = vi.fn( async () => new Response(JSON.stringify({ modules: [{ key: "dms", label: "Dokumente", description: "x", href: "http://a", permission: "dms.document.read" }] }), { status: 200 }) ) as unknown as typeof fetch; - const modules = await fetchVisibleModules("acme", "user", fetchImpl); + const modules = await fetchVisibleModules(fetchImpl); expect(modules).toHaveLength(1); expect(modules[0].key).toBe("dms"); const calledUrl = (fetchImpl as ReturnType).mock.calls[0][0] as string; - expect(calledUrl).toContain("tenant=acme"); - expect(calledUrl).toContain("role=user"); + expect(calledUrl).toBe("/api/visible-modules"); }); it("gibt eine leere Liste zurueck wenn die Route fehlschlaegt", async () => { const fetchImpl = vi.fn(async () => new Response("", { status: 500 })) as unknown as typeof fetch; - const modules = await fetchVisibleModules("acme", "user", fetchImpl); + const modules = await fetchVisibleModules(fetchImpl); expect(modules).toEqual([]); }); }); @@ -37,3 +36,27 @@ describe("resolveModuleHref", () => { expect(resolveModuleHref("http://localhost:3107/", "localhost")).toBe("http://localhost:3107/"); }); }); + +describe("groupModules / badgeFor / fetchWorklist", () => { + const m = (key: string, group?: "work" | "admin" | "audit") => ({ key, group, label: key, description: "", href: "http://a", permission: "p" }); + + it("gruppiert in Reihenfolge Arbeiten, Verwalten, Pruefen und sortiert Unbekanntes unter Arbeiten ein", () => { + const g = groupModules([m("a", "audit"), m("b", "admin"), m("c", "work"), m("d")]); + expect(g.map((x) => x.key)).toEqual(["work", "admin", "audit"]); + expect(g[0].modules.map((x) => x.key)).toEqual(["c", "d"]); + }); + + it("zeigt Badges nur fuer Workflow-Kacheln und summiert Freigaben und Aufgaben", () => { + const w = { approvals: 2, tasks: 3, remindersDue: 4 }; + expect(badgeFor("workflow", w)).toBe(5); + expect(badgeFor("workflow_cases", w)).toBe(4); + expect(badgeFor("dms", w)).toBeNull(); + expect(badgeFor("workflow", null)).toBeNull(); + expect(badgeFor("workflow", { approvals: null, tasks: null, remindersDue: null })).toBeNull(); + }); + + it("fetchWorklist liefert null bei Fehlern", async () => { + expect(await fetchWorklist((async () => new Response("", { status: 500 })) as unknown as typeof fetch)).toBeNull(); + expect(await fetchWorklist((async () => { throw new Error("x"); }) as unknown as typeof fetch)).toBeNull(); + }); +}); diff --git a/web/portal/app/ModuleGrid.tsx b/web/portal/app/ModuleGrid.tsx index 434f3581..84f427de 100644 --- a/web/portal/app/ModuleGrid.tsx +++ b/web/portal/app/ModuleGrid.tsx @@ -3,6 +3,7 @@ import { useEffect, useState } from "react"; import { useI18n, resolveModuleHref } from "@nexarch/shl"; import type { ModuleEntry } from "../lib/modules"; +import type { Worklist } from "../lib/worklist"; import { getSession } from "../lib/http"; interface VisibleModulesResponse { @@ -10,23 +11,51 @@ interface VisibleModulesResponse { error?: string; } -export async function fetchVisibleModules( - tenant: string, - role: string, - fetchImpl: typeof fetch = fetch -): Promise { - const res = await fetchImpl( - `/api/visible-modules?tenant=${encodeURIComponent(tenant)}&role=${encodeURIComponent(role)}` - ); +export async function fetchVisibleModules(fetchImpl: typeof fetch): Promise { + const res = await fetchImpl("/api/visible-modules"); if (!res.ok) return []; const body = (await res.json()) as VisibleModulesResponse; return body.modules ?? []; } +export async function fetchWorklist(fetchImpl: typeof fetch): Promise { + try { + const res = await fetchImpl("/api/worklist"); + return res.ok ? ((await res.json()) as Worklist) : null; + } catch { + return null; + } +} + +const GROUPS = [ + { key: "work", title: "Arbeiten" }, + { key: "admin", title: "Verwalten" }, + { key: "audit", title: "Prüfen" }, +] as const; + +export function groupModules(modules: ModuleEntry[]): { key: string; title: string; modules: ModuleEntry[] }[] { + const known = new Set(GROUPS.map((g) => g.key)); + const out = GROUPS.map((g) => ({ ...g, modules: modules.filter((m) => m.group === g.key) })); + const rest = modules.filter((m) => !m.group || !known.has(m.group)); + if (rest.length > 0) out[0].modules.push(...rest); + return out.filter((g) => g.modules.length > 0); +} + +export function badgeFor(key: string, w: Worklist | null): number | null { + if (!w) return null; + if (key === "workflow") { + const parts = [w.approvals, w.tasks].filter((n): n is number => n !== null); + return parts.length ? parts.reduce((a, b) => a + b, 0) : null; + } + if (key === "workflow_cases") return w.remindersDue; + return null; +} + type State = | { status: "loading" } + | { status: "noIdentity" } | { status: "error"; message: string } - | { status: "ready"; modules: ModuleEntry[] }; + | { status: "ready"; modules: ModuleEntry[]; worklist: Worklist | null }; export function ModuleGrid() { const { t } = useI18n(); @@ -38,13 +67,13 @@ export function ModuleGrid() { try { const session = getSession(); await session.token(); - const identity = session.identity(); - if (!identity) { - if (!cancelled) setState({ status: "error", message: t("portal.noIdentity", "Anmeldung erforderlich.") }); + if (!session.identity()) { + if (!cancelled) setState({ status: "noIdentity" }); return; } - const modules = await fetchVisibleModules(identity.tenant, identity.role); - if (!cancelled) setState({ status: "ready", modules }); + const call = (input: RequestInfo | URL, init?: RequestInit) => session.fetch(input, init); + const [modules, worklist] = await Promise.all([fetchVisibleModules(call), fetchWorklist(call)]); + if (!cancelled) setState({ status: "ready", modules, worklist }); } catch (err) { if (!cancelled) { setState({ status: "error", message: (err as Error).message }); @@ -54,44 +83,89 @@ export function ModuleGrid() { return () => { cancelled = true; }; - }, [t]); + }, []); if (state.status === "loading") return

{t("portal.loading", "Lade Übersicht …")}

; + if (state.status === "noIdentity") return

{t("portal.noIdentity", "Anmeldung erforderlich.")}

; if (state.status === "error") return

{state.message}

; if (state.modules.length === 0) { return

{t("portal.noModules", "Für Ihr Konto sind derzeit keine Module freigeschaltet.")}

; } + const { modules, worklist } = state; + const dms = modules.find((m) => m.key === "dms"); + const today = [ + { label: t("portal.approvals", "Offene Freigaben"), value: worklist?.approvals ?? null }, + { label: t("portal.tasks", "Meine Aufgaben"), value: worklist?.tasks ?? null }, + { label: t("portal.reminders", "Fällige Wiedervorlagen"), value: worklist?.remindersDue ?? null }, + ].filter((x) => x.value !== null); + return ( - + ); } diff --git a/web/portal/app/PortalShell.tsx b/web/portal/app/PortalShell.tsx new file mode 100644 index 00000000..a65520ee --- /dev/null +++ b/web/portal/app/PortalShell.tsx @@ -0,0 +1,30 @@ +"use client"; + +import { useEffect, useState } from "react"; +import type { ReactNode } from "react"; +import { ModuleNavShell } from "@nexarch/shl"; +import { getSession } from "../lib/http"; + +export function PortalShell({ children }: { children: ReactNode }) { + const [tenant, setTenant] = useState(undefined); + + useEffect(() => { + let cancelled = false; + const s = getSession(); + s.token().then( + () => { + if (!cancelled) setTenant(s.identity()?.tenant); + }, + () => {}, + ); + return () => { + cancelled = true; + }; + }, []); + + return ( + + {children} + + ); +} diff --git a/web/portal/app/api/kennzahlen-summary/route.ts b/web/portal/app/api/kennzahlen-summary/route.ts new file mode 100644 index 00000000..047859ec --- /dev/null +++ b/web/portal/app/api/kennzahlen-summary/route.ts @@ -0,0 +1,38 @@ +import { NextRequest, NextResponse } from "next/server"; +import { authenticate, coreConfigured } from "../../../lib/auth"; +import { kennzahlenCache } from "../../../lib/caches"; +import { loadKennzahlenSummary } from "../../../lib/adminData"; +import { policyAuthorize } from "../../../lib/policy"; + +export const dynamic = "force-dynamic"; + +const CACHE_TTL_MS = 15_000; + +export async function GET(request: NextRequest) { + const policyBase = process.env.PORTAL_POLICY_API_URL; + const policyToken = process.env.PORTAL_POLICY_SERVICE_TOKEN; + const base = process.env.PORTAL_KENNZAHLEN_API_URL; + if (!policyBase || !policyToken || !base || !coreConfigured()) { + return NextResponse.json({ error: "Portal nicht konfiguriert" }, { status: 503 }); + } + const claims = await authenticate(request); + if (!claims) return NextResponse.json({ error: "Anmeldung erforderlich" }, { status: 401 }); + + let allowed = false; + try { + allowed = await policyAuthorize(policyBase, policyToken)(claims.tenant, claims.role, "kennzahlen.read"); + } catch { + allowed = false; + } + if (!allowed) return NextResponse.json({ error: "Keine Berechtigung" }, { status: 403 }); + + const noStore = { "Cache-Control": "no-store" }; + const key = `${claims.tenant}\u0000${claims.role}`; + const hit = kennzahlenCache.get(key, CACHE_TTL_MS); + if (hit) return NextResponse.json(hit, { headers: noStore }); + + const data = await loadKennzahlenSummary(base, claims.tenant, claims.role); + if (data === null) return NextResponse.json({ error: "Quelle nicht erreichbar" }, { status: 502, headers: noStore }); + kennzahlenCache.set(key, data); + return NextResponse.json(data, { headers: noStore }); +} diff --git a/web/portal/app/api/system-status/route.test.ts b/web/portal/app/api/system-status/route.test.ts new file mode 100644 index 00000000..51a03276 --- /dev/null +++ b/web/portal/app/api/system-status/route.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; +import { NextRequest } from "next/server"; +import { generateKeyPairSync, sign } from "node:crypto"; +import { resetKeyCacheForTests } from "../../../lib/verifyToken"; +import { kennzahlenCache, statusCache } from "../../../lib/caches"; + +const originalFetch = global.fetch; +const { publicKey, privateKey } = generateKeyPairSync("ed25519"); +const rawPub = (publicKey.export({ format: "der", type: "spki" }) as Buffer).subarray(-32); + +function mint(claims: Record): string { + const enc = (o: unknown) => Buffer.from(JSON.stringify(o)).toString("base64url"); + const head = enc({ alg: "EdDSA", typ: "JWT", kid: "k1" }); + const body = enc({ exp: Math.floor(Date.now() / 1000) + 300, ...claims }); + return `${head}.${body}.${sign(null, Buffer.from(`${head}.${body}`), privateKey).toString("base64url")}`; +} + +describe("Admin-Routen: system-status und kennzahlen-summary", () => { + let allowedPerms: Set; + let upstream: string[]; + + const req = (path: string, token?: string) => + new NextRequest(`http://portal.example${path}`, { headers: token ? { authorization: `Bearer ${token}` } : {} }); + + beforeEach(() => { + for (const [k, v] of Object.entries({ + PORTAL_POLICY_API_URL: "http://policy.example", + PORTAL_POLICY_SERVICE_TOKEN: "svc", + PORTAL_CORE_API_URL: "http://core.example", + PORTAL_STATUS_API_URL: "http://status.example", + PORTAL_KENNZAHLEN_API_URL: "http://kz.example", + })) process.env[k] = v; + resetKeyCacheForTests(); + statusCache.clear(); + kennzahlenCache.clear(); + allowedPerms = new Set(); + upstream = []; + global.fetch = vi.fn(async (url: string | URL | Request, init?: RequestInit) => { + const u = String(url); + if (u.endsWith("/auth/module-jwks")) return new Response(JSON.stringify({ keys: [{ kid: "k1", public_key: rawPub.toString("base64") }] }), { status: 200 }); + if (u.endsWith("/authorize")) { + const b = JSON.parse(String(init?.body)); + return new Response(JSON.stringify({ allowed: allowedPerms.has(b.permission) }), { status: 200 }); + } + upstream.push(u); + if (u.includes("/status/overview")) return new Response(JSON.stringify([{ name: "dms", status: "up" }]), { status: 200 }); + return new Response(JSON.stringify({ objekt_bestand: 1, loeschvorgaenge: 0, speichervolumen_bytes: 0 }), { status: 200 }); + }) as typeof fetch; + }); + afterEach(() => { + global.fetch = originalFetch; + for (const k of ["PORTAL_POLICY_API_URL", "PORTAL_POLICY_SERVICE_TOKEN", "PORTAL_CORE_API_URL", "PORTAL_STATUS_API_URL", "PORTAL_KENNZAHLEN_API_URL"]) delete process.env[k]; + }); + + it("system-status: 401 ohne Token, 403 ohne Admin-Recht, 200 mit Recht", async () => { + const { GET } = await import("./route"); + const token = mint({ sub: "u", tenant: "acme", role: "user" }); + expect((await GET(req("/api/system-status"))).status).toBe(401); + expect((await GET(req("/api/system-status", token))).status).toBe(403); + expect(upstream).toEqual([]); + allowedPerms.add("core.tenant.admin"); + const res = await GET(req("/api/system-status", token)); + expect(res.status).toBe(200); + expect((await res.json()).modules[0].name).toBe("dms"); + }); + + it("kennzahlen-summary: Mandant aus dem Token, 403 ohne kennzahlen.read", async () => { + const { GET } = await import("../kennzahlen-summary/route"); + const token = mint({ sub: "u", tenant: "acme", role: "auditor" }); + expect((await GET(req("/api/kennzahlen-summary?tenant=fremd", token))).status).toBe(403); + allowedPerms.add("kennzahlen.read"); + const res = await GET(req("/api/kennzahlen-summary?tenant=fremd", token)); + expect(res.status).toBe(200); + expect(upstream[0]).toContain("tenant=acme"); + expect(upstream[0]).not.toContain("fremd"); + }); +}); diff --git a/web/portal/app/api/system-status/route.ts b/web/portal/app/api/system-status/route.ts new file mode 100644 index 00000000..090e5e54 --- /dev/null +++ b/web/portal/app/api/system-status/route.ts @@ -0,0 +1,38 @@ +import { NextRequest, NextResponse } from "next/server"; +import { authenticate, coreConfigured } from "../../../lib/auth"; +import { statusCache } from "../../../lib/caches"; +import { loadSystemStatus } from "../../../lib/adminData"; +import { policyAuthorize } from "../../../lib/policy"; + +export const dynamic = "force-dynamic"; + +const CACHE_TTL_MS = 15_000; + +export async function GET(request: NextRequest) { + const policyBase = process.env.PORTAL_POLICY_API_URL; + const policyToken = process.env.PORTAL_POLICY_SERVICE_TOKEN; + const base = process.env.PORTAL_STATUS_API_URL; + if (!policyBase || !policyToken || !base || !coreConfigured()) { + return NextResponse.json({ error: "Portal nicht konfiguriert" }, { status: 503 }); + } + const claims = await authenticate(request); + if (!claims) return NextResponse.json({ error: "Anmeldung erforderlich" }, { status: 401 }); + + let allowed = false; + try { + allowed = await policyAuthorize(policyBase, policyToken)(claims.tenant, claims.role, "core.tenant.admin"); + } catch { + allowed = false; + } + if (!allowed) return NextResponse.json({ error: "Keine Berechtigung" }, { status: 403 }); + + const noStore = { "Cache-Control": "no-store" }; + const key = "all"; + const hit = statusCache.get(key, CACHE_TTL_MS); + if (hit) return NextResponse.json({ modules: hit }, { headers: noStore }); + + const data = await loadSystemStatus(base); + if (data === null) return NextResponse.json({ error: "Quelle nicht erreichbar" }, { status: 502, headers: noStore }); + statusCache.set(key, data); + return NextResponse.json({ modules: data }, { headers: noStore }); +} diff --git a/web/portal/app/api/visible-modules/route.test.ts b/web/portal/app/api/visible-modules/route.test.ts index 0fd440bc..3495ec7e 100644 --- a/web/portal/app/api/visible-modules/route.test.ts +++ b/web/portal/app/api/visible-modules/route.test.ts @@ -1,7 +1,9 @@ import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; import { NextRequest } from "next/server"; -import { filterVisibleModules, type Authorize } from "./route"; +import { generateKeyPairSync, sign } from "node:crypto"; +import { filterVisibleModules, type Authorize } from "../../../lib/policy"; import type { ModuleEntry } from "../../../lib/modules"; +import { resetKeyCacheForTests } from "../../../lib/verifyToken"; const originalFetch = global.fetch; @@ -49,40 +51,91 @@ describe("filterVisibleModules (Pruefungen 1 und 4)", () => { }); }); +const { publicKey, privateKey } = generateKeyPairSync("ed25519"); +const rawPub = (publicKey.export({ format: "der", type: "spki" }) as Buffer).subarray(-32); + +function mint(claims: Record, kid = "k1", key = privateKey): string { + const enc = (o: unknown) => Buffer.from(JSON.stringify(o)).toString("base64url"); + const head = enc({ alg: "EdDSA", typ: "JWT", kid }); + const body = enc({ exp: Math.floor(Date.now() / 1000) + 300, ...claims }); + return `${head}.${body}.${sign(null, Buffer.from(`${head}.${body}`), key).toString("base64url")}`; +} + describe("GET /api/visible-modules (HTTP-Schicht)", () => { - beforeEach(() => { + let policyCalls: { tenant: string; role: string }[]; + + function req(token?: string, query = ""): NextRequest { + return new NextRequest(`http://portal.example/api/visible-modules${query}`, { + headers: token ? { authorization: `Bearer ${token}` } : {}, + }); + } + + beforeEach(async () => { process.env.PORTAL_POLICY_API_URL = "http://policy.example"; process.env.PORTAL_POLICY_SERVICE_TOKEN = "test-token"; + process.env.PORTAL_CORE_API_URL = "http://core.example"; + policyCalls = []; + resetKeyCacheForTests(); + (await import("../../../lib/caches")).visibleModulesCache.clear(); + global.fetch = vi.fn(async (url: string | URL | Request, init?: RequestInit) => { + const u = String(url); + if (u.endsWith("/auth/module-jwks")) { + return new Response(JSON.stringify({ keys: [{ kid: "k1", public_key: rawPub.toString("base64") }] }), { status: 200 }); + } + policyCalls.push(JSON.parse(String(init?.body))); + return new Response(JSON.stringify({ allowed: true }), { status: 200 }); + }) as typeof fetch; }); afterEach(() => { global.fetch = originalFetch; - delete process.env.PORTAL_POLICY_API_URL; - delete process.env.PORTAL_POLICY_SERVICE_TOKEN; + for (const k of ["PORTAL_POLICY_API_URL", "PORTAL_POLICY_SERVICE_TOKEN", "PORTAL_CORE_API_URL", "PORTAL_TOKEN_AUDIENCE"]) delete process.env[k]; }); - it("gibt 400 ohne tenant/role zurueck", async () => { + it("gibt 401 ohne Token zurueck", async () => { const { GET } = await import("./route"); - const req = new NextRequest("http://portal.example/api/visible-modules"); - const res = await GET(req); - expect(res.status).toBe(400); + expect((await GET(req())).status).toBe(401); + }); + + it("gibt 401 bei falscher Signatur, abgelaufenem Token oder unbekannter kid", async () => { + const { GET } = await import("./route"); + const other = generateKeyPairSync("ed25519").privateKey; + const claims = { sub: "u", tenant: "acme", role: "user" }; + expect((await GET(req(mint(claims, "k1", other)))).status).toBe(401); + expect((await GET(req(mint({ ...claims, exp: 1 })))).status).toBe(401); + expect((await GET(req(mint(claims, "unbekannt")))).status).toBe(401); }); it("gibt 503 zurueck wenn Portal nicht konfiguriert ist", async () => { delete process.env.PORTAL_POLICY_API_URL; const { GET } = await import("./route"); - const req = new NextRequest("http://portal.example/api/visible-modules?tenant=acme&role=user"); - const res = await GET(req); - expect(res.status).toBe(503); + expect((await GET(req(mint({ sub: "u", tenant: "acme", role: "user" })))).status).toBe(503); }); - it("ruft policy-api mit Dienst-Token auf, Token nie im Response sichtbar", async () => { - global.fetch = vi.fn(async () => new Response(JSON.stringify({ allowed: true }), { status: 200 })) as typeof fetch; + it("nimmt Mandant und Rolle aus dem Token, nicht aus der URL", async () => { const { GET } = await import("./route"); - const req = new NextRequest("http://portal.example/api/visible-modules?tenant=acme&role=user"); - const res = await GET(req); - const body = await res.json(); + const res = await GET(req(mint({ sub: "u", tenant: "acme", role: "user" }), "?tenant=fremd&role=admin")); expect(res.status).toBe(200); - expect(JSON.stringify(body)).not.toContain("test-token"); - expect((global.fetch as ReturnType).mock.calls[0][1].headers["X-Service-Token"]).toBe("test-token"); + expect(policyCalls.length).toBeGreaterThan(0); + expect(policyCalls.every((c) => c.tenant === "acme" && c.role === "user")).toBe(true); + expect(JSON.stringify(await res.json())).not.toContain("test-token"); + }); + + it("verlangt das konfigurierte Zielmodul", async () => { + process.env.PORTAL_TOKEN_AUDIENCE = "portal"; + const { GET } = await import("./route"); + const claims = { sub: "u", tenant: "acme", role: "user" }; + expect((await GET(req(mint({ ...claims, aud: ["dms"] })))).status).toBe(401); + expect((await GET(req(mint({ ...claims, aud: ["portal"] })))).status).toBe(200); + }); + + it("fragt die policy-api je Mandant/Rolle nur einmal innerhalb der Cache-Zeit", async () => { + const { GET } = await import("./route"); + const token = mint({ sub: "u", tenant: "acme", role: "user" }); + await GET(req(token)); + const first = policyCalls.length; + await GET(req(token)); + expect(policyCalls.length).toBe(first); + await GET(req(mint({ sub: "u", tenant: "acme", role: "admin" }))); + expect(policyCalls.length).toBeGreaterThan(first); }); }); diff --git a/web/portal/app/api/visible-modules/route.ts b/web/portal/app/api/visible-modules/route.ts index 82e3c387..d0e8ab9a 100644 --- a/web/portal/app/api/visible-modules/route.ts +++ b/web/portal/app/api/visible-modules/route.ts @@ -1,65 +1,41 @@ import { NextRequest, NextResponse } from "next/server"; -import { loadModuleRegistry, type ModuleEntry } from "../../../lib/modules"; +import { applyUrlOverrides, loadModuleRegistry } from "../../../lib/modules"; +import { authenticate, coreConfigured } from "../../../lib/auth"; +import { visibleModulesCache } from "../../../lib/caches"; +import { filterVisibleModules, policyAuthorize } from "../../../lib/policy"; export const dynamic = "force-dynamic"; -export type Authorize = (tenant: string, role: string, permission: string) => Promise; - -// Reine, testbare Kernlogik: iteriert generisch ueber JEDE Registry, egal -// wie viele/welche Eintraege config/modules.json enthaelt (Akzeptanz- -// kriterium 3 -- ein neues Modul braucht hier keine Codeaenderung). -// Ausfall/Verweigerung von 'authorize' lassen ein Modul nie durch -// (fail-closed, Pruefung 4: kein Modul ohne Berechtigung sichtbar). -export async function filterVisibleModules( - registry: ModuleEntry[], - tenant: string, - role: string, - authorize: Authorize -): Promise { - const results = await Promise.all( - registry.map(async (mod) => { - try { - return (await authorize(tenant, role, mod.permission)) ? mod : null; - } catch { - return null; - } - }) - ); - return results.filter((m): m is ModuleEntry => m !== null); -} - -function policyAuthorize(base: string, token: string): Authorize { - return async (tenant, role, permission) => { - const res = await fetch(`${base.replace(/\/$/, "")}/authorize`, { - method: "POST", - headers: { "X-Service-Token": token, "Content-Type": "application/json" }, - body: JSON.stringify({ tenant, role, permission }), - cache: "no-store", - }); - if (!res.ok) return false; - const body = (await res.json()) as { allowed?: boolean }; - return body.allowed === true; - }; -} +const CACHE_TTL_MS = Number(process.env.PORTAL_VISIBLE_MODULES_CACHE_SECONDS ?? "30") * 1000; +// Mandant und Rolle kommen ausschliesslich aus dem verifizierten Token, +// nie aus der URL. Der Cache haelt nur positive Ergebnisse je Mandant/Rolle +// kurz vor; Rechteaenderungen wirken bis zu CACHE_TTL_MS verzoegert. // Backend-for-Frontend: haelt das Dienst-Token fuer policy-api server- // seitig (der Browser bekommt es nie zu Gesicht, gleiches Prinzip wie // tenant-admins Proxy-Route). Core entscheidet ueber Rolle UND Feature- // Flag in einem Aufruf (AuthorizeRequest), das Portal trifft keine // eigene zweite Entscheidung (Akzeptanzkriterium 2). export async function GET(request: NextRequest) { - const tenant = request.nextUrl.searchParams.get("tenant"); - const role = request.nextUrl.searchParams.get("role"); - if (!tenant || !role) { - return NextResponse.json({ error: "'tenant' und 'role' sind erforderlich" }, { status: 400 }); - } - const base = process.env.PORTAL_POLICY_API_URL; const token = process.env.PORTAL_POLICY_SERVICE_TOKEN; - if (!base || !token) { + if (!base || !token || !coreConfigured()) { return NextResponse.json({ error: "Portal nicht konfiguriert" }, { status: 503 }); } - const modules = await filterVisibleModules(loadModuleRegistry(), tenant, role, policyAuthorize(base, token)); + const claims = await authenticate(request); + if (!claims) { + return NextResponse.json({ error: "Anmeldung erforderlich" }, { status: 401 }); + } + + const cacheKey = `${claims.tenant}\u0000${claims.role}`; + const hit = visibleModulesCache.get(cacheKey, CACHE_TTL_MS); + if (hit) return NextResponse.json({ modules: hit }); + + const modules = applyUrlOverrides( + await filterVisibleModules(loadModuleRegistry(), claims.tenant, claims.role, policyAuthorize(base, token)), + process.env + ); + visibleModulesCache.set(cacheKey, modules); return NextResponse.json({ modules }); } diff --git a/web/portal/app/api/worklist/route.test.ts b/web/portal/app/api/worklist/route.test.ts new file mode 100644 index 00000000..bb7db597 --- /dev/null +++ b/web/portal/app/api/worklist/route.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; +import { NextRequest } from "next/server"; +import { generateKeyPairSync, sign } from "node:crypto"; +import { resetKeyCacheForTests } from "../../../lib/verifyToken"; + +const originalFetch = global.fetch; +const { publicKey, privateKey } = generateKeyPairSync("ed25519"); +const rawPub = (publicKey.export({ format: "der", type: "spki" }) as Buffer).subarray(-32); + +function mint(claims: Record): string { + const enc = (o: unknown) => Buffer.from(JSON.stringify(o)).toString("base64url"); + const head = enc({ alg: "EdDSA", typ: "JWT", kid: "k1" }); + const body = enc({ exp: Math.floor(Date.now() / 1000) + 300, ...claims }); + return `${head}.${body}.${sign(null, Buffer.from(`${head}.${body}`), privateKey).toString("base64url")}`; +} + +describe("GET /api/worklist", () => { + let wfCalls: { url: string; user: string }[]; + + const req = (token?: string, query = "") => + new NextRequest(`http://portal.example/api/worklist${query}`, { headers: token ? { authorization: `Bearer ${token}` } : {} }); + + beforeEach(async () => { + process.env.PORTAL_WORKFLOW_API_URL = "http://wf.example"; + process.env.PORTAL_CORE_API_URL = "http://core.example"; + wfCalls = []; + resetKeyCacheForTests(); + (await import("../../../lib/caches")).worklistCache.clear(); + global.fetch = vi.fn(async (url: string | URL | Request, init?: RequestInit) => { + const u = String(url); + if (u.endsWith("/auth/module-jwks")) { + return new Response(JSON.stringify({ keys: [{ kid: "k1", public_key: rawPub.toString("base64") }] }), { status: 200 }); + } + wfCalls.push({ url: u, user: (init?.headers as Record)["X-User-Id"] }); + return new Response(JSON.stringify([]), { status: 200 }); + }) as typeof fetch; + }); + afterEach(() => { + global.fetch = originalFetch; + delete process.env.PORTAL_WORKFLOW_API_URL; + delete process.env.PORTAL_CORE_API_URL; + }); + + it("gibt 401 ohne Token und 503 ohne Konfiguration zurueck", async () => { + const { GET } = await import("./route"); + expect((await GET(req())).status).toBe(401); + delete process.env.PORTAL_WORKFLOW_API_URL; + expect((await GET(req(mint({ sub: "u1", tenant: "acme" })))).status).toBe(503); + }); + + it("nimmt den Benutzer aus dem Token, nie aus der URL, und sendet no-store", async () => { + const { GET } = await import("./route"); + const res = await GET(req(mint({ sub: "u1", tenant: "acme", role: "user" }), "?userId=fremd")); + expect(res.status).toBe(200); + expect(res.headers.get("cache-control")).toBe("no-store"); + expect(wfCalls.length).toBe(3); + expect(wfCalls.every((c) => c.user === "u1")).toBe(true); + }); + + it("cached je Mandant und Benutzer", async () => { + const { GET } = await import("./route"); + await GET(req(mint({ sub: "u1", tenant: "acme" }))); + await GET(req(mint({ sub: "u1", tenant: "acme" }))); + expect(wfCalls.length).toBe(3); + await GET(req(mint({ sub: "u2", tenant: "acme" }))); + expect(wfCalls.length).toBe(6); + expect(wfCalls.slice(3).every((c) => c.user === "u2")).toBe(true); + }); +}); diff --git a/web/portal/app/api/worklist/route.ts b/web/portal/app/api/worklist/route.ts new file mode 100644 index 00000000..97920865 --- /dev/null +++ b/web/portal/app/api/worklist/route.ts @@ -0,0 +1,27 @@ +import { NextRequest, NextResponse } from "next/server"; +import { authenticate, coreConfigured } from "../../../lib/auth"; +import { worklistCache } from "../../../lib/caches"; +import { loadWorklist } from "../../../lib/worklist"; + +export const dynamic = "force-dynamic"; + +const CACHE_TTL_MS = Number(process.env.PORTAL_WORKLIST_CACHE_SECONDS ?? "15") * 1000; +// Nur Zaehler, keine Inhalte. Benutzer und Mandant stammen aus dem verifizierten Token; +// die Workflow-API kennt bisher nur X-User-Id (Uebergangsmodus), der Header wird hier serverseitig gesetzt. +export async function GET(request: NextRequest) { + const base = process.env.PORTAL_WORKFLOW_API_URL; + if (!base || !coreConfigured()) { + return NextResponse.json({ error: "Portal nicht konfiguriert" }, { status: 503 }); + } + const claims = await authenticate(request); + if (!claims) return NextResponse.json({ error: "Anmeldung erforderlich" }, { status: 401 }); + + const noStore = { "Cache-Control": "no-store" }; + const key = `${claims.tenant}\u0000${claims.sub}`; + const hit = worklistCache.get(key, CACHE_TTL_MS); + if (hit) return NextResponse.json(hit, { headers: noStore }); + + const data = await loadWorklist(base, claims.tenant, claims.sub); + worklistCache.set(key, data); + return NextResponse.json(data, { headers: noStore }); +} diff --git a/web/portal/app/layout.tsx b/web/portal/app/layout.tsx index 3eb7c331..8b70dc49 100644 --- a/web/portal/app/layout.tsx +++ b/web/portal/app/layout.tsx @@ -1,6 +1,8 @@ import Script from "next/script"; -import { ThemeProvider, I18nProvider, ToastProvider, ModuleNavShell, typography, themeInitScript, focusVisibleStyles } from "@nexarch/shl"; +import { ThemeProvider, I18nProvider, ToastProvider, typography, themeInitScript, focusVisibleStyles } from "@nexarch/shl"; import type { ReactNode } from "react"; +import { AccountBar } from "./AccountBar"; +import { PortalShell } from "./PortalShell"; export const metadata = { title: "NEXARCH Portal", @@ -25,7 +27,7 @@ export default function RootLayout({ children }: { children: ReactNode }) { > - {children} + {children} diff --git a/web/portal/app/page.tsx b/web/portal/app/page.tsx index 8931ce9e..afe21a43 100644 --- a/web/portal/app/page.tsx +++ b/web/portal/app/page.tsx @@ -1,3 +1,4 @@ +import { AdminPanel } from "./AdminPanel"; import { ModuleGrid } from "./ModuleGrid"; export default function Page() { @@ -5,6 +6,7 @@ export default function Page() {

NEXARCH Portal

+
); } diff --git a/web/portal/lib/adminData.test.ts b/web/portal/lib/adminData.test.ts new file mode 100644 index 00000000..96cce38d --- /dev/null +++ b/web/portal/lib/adminData.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it, vi } from "vitest"; +import { loadKennzahlenSummary, loadSystemStatus } from "./adminData"; + +describe("loadSystemStatus", () => { + it("mappt Name, Status und Zeitpunkt", async () => { + const f = vi.fn(async () => new Response(JSON.stringify([{ name: "dms", status: "up", last_checked: "2026-10-27T10:00:00Z", host: "geheim" }]), { status: 200 })) as unknown as typeof fetch; + const r = await loadSystemStatus("http://s.example/", f); + expect(r).toEqual([{ name: "dms", status: "up", lastChecked: "2026-10-27T10:00:00Z" }]); + expect(JSON.stringify(r)).not.toContain("geheim"); + }); + + it("liefert null bei Fehler oder falscher Form", async () => { + expect(await loadSystemStatus("http://s", (async () => new Response("", { status: 500 })) as unknown as typeof fetch)).toBeNull(); + expect(await loadSystemStatus("http://s", (async () => new Response("{}", { status: 200 })) as unknown as typeof fetch)).toBeNull(); + expect(await loadSystemStatus("http://s", (async () => { throw new Error("x"); }) as unknown as typeof fetch)).toBeNull(); + }); +}); + +describe("loadKennzahlenSummary", () => { + it("sendet Mandant und Rolle aus den Argumenten und gibt nur die drei Summen zurueck", async () => { + const f = vi.fn(async () => new Response(JSON.stringify({ objekt_bestand: 5, loeschvorgaenge: 2, speichervolumen_bytes: 1024, aufbewahrungsstatus: { a: 1 } }), { status: 200 })); + const r = await loadKennzahlenSummary("http://k.example", "acme", "auditor", f as unknown as typeof fetch); + expect(r).toEqual({ objektBestand: 5, loeschvorgaenge: 2, speichervolumenBytes: 1024 }); + const [url, init] = f.mock.calls[0] as unknown as [string, RequestInit]; + expect(url).toBe("http://k.example/kennzahlen/totals?tenant=acme"); + expect((init.headers as Record)["X-User-Role"]).toBe("auditor"); + }); + + it("liefert null, wenn die Quelle ablehnt", async () => { + expect(await loadKennzahlenSummary("http://k", "t", "r", (async () => new Response("", { status: 403 })) as unknown as typeof fetch)).toBeNull(); + }); +}); diff --git a/web/portal/lib/adminData.ts b/web/portal/lib/adminData.ts new file mode 100644 index 00000000..358cbd82 --- /dev/null +++ b/web/portal/lib/adminData.ts @@ -0,0 +1,53 @@ +export interface ModuleStatusRow { + name: string; + status: string; + lastChecked: string | null; +} + +export interface KennzahlenSummary { + objektBestand: number; + loeschvorgaenge: number; + speichervolumenBytes: number; +} + +const num = (v: unknown): number => (typeof v === "number" && Number.isFinite(v) ? v : 0); + +export async function loadSystemStatus(base: string, fetchImpl: typeof fetch = fetch): Promise { + try { + const res = await fetchImpl(`${base.replace(/\/$/, "")}/status/overview`, { cache: "no-store" }); + if (!res.ok) return null; + const body = (await res.json()) as unknown; + if (!Array.isArray(body)) return null; + return body.map((r: { name?: unknown; status?: unknown; last_checked?: unknown }) => ({ + name: String(r.name ?? ""), + status: String(r.status ?? "unknown"), + lastChecked: typeof r.last_checked === "string" ? r.last_checked : null, + })); + } catch { + return null; + } +} + +// Nur die drei Summenwerte verlassen das Portal; Mandant und Rolle kommen aus dem Token. +export async function loadKennzahlenSummary( + base: string, + tenant: string, + role: string, + fetchImpl: typeof fetch = fetch +): Promise { + try { + const res = await fetchImpl(`${base.replace(/\/$/, "")}/kennzahlen/totals?tenant=${encodeURIComponent(tenant)}`, { + headers: { "X-User-Role": role, Accept: "application/json" }, + cache: "no-store", + }); + if (!res.ok) return null; + const b = (await res.json()) as Record; + return { + objektBestand: num(b.objekt_bestand), + loeschvorgaenge: num(b.loeschvorgaenge), + speichervolumenBytes: num(b.speichervolumen_bytes), + }; + } catch { + return null; + } +} diff --git a/web/portal/lib/auth.ts b/web/portal/lib/auth.ts new file mode 100644 index 00000000..0d379b8e --- /dev/null +++ b/web/portal/lib/auth.ts @@ -0,0 +1,19 @@ +import type { NextRequest } from "next/server"; +import { verifyModuleToken, type VerifiedClaims } from "./verifyToken"; + +export async function authenticate(request: NextRequest): Promise { + const coreBase = process.env.PORTAL_CORE_API_URL ?? process.env.NEXT_PUBLIC_CORE_API_BASE; + if (!coreBase) return null; + const auth = request.headers.get("authorization") ?? ""; + const bearer = auth.startsWith("Bearer ") ? auth.slice(7).trim() : ""; + if (!bearer) return null; + return verifyModuleToken(bearer, { + jwksUrl: `${coreBase.replace(/\/$/, "")}/auth/module-jwks`, + issuer: process.env.PORTAL_TOKEN_ISSUER, + audience: process.env.PORTAL_TOKEN_AUDIENCE, + }); +} + +export function coreConfigured(): boolean { + return Boolean(process.env.PORTAL_CORE_API_URL ?? process.env.NEXT_PUBLIC_CORE_API_BASE); +} diff --git a/web/portal/lib/caches.ts b/web/portal/lib/caches.ts new file mode 100644 index 00000000..b37969e8 --- /dev/null +++ b/web/portal/lib/caches.ts @@ -0,0 +1,24 @@ +import type { ModuleEntry } from "./modules"; +import type { Worklist } from "./worklist"; + +export class TtlCache { + private readonly entries = new Map(); + + get(key: string, ttlMs: number): T | undefined { + const hit = this.entries.get(key); + return hit && Date.now() - hit.at < ttlMs ? hit.value : undefined; + } + + set(key: string, value: T): void { + this.entries.set(key, { at: Date.now(), value }); + } + + clear(): void { + this.entries.clear(); + } +} + +export const visibleModulesCache = new TtlCache(); +export const worklistCache = new TtlCache(); +export const statusCache = new TtlCache(); +export const kennzahlenCache = new TtlCache(); diff --git a/web/portal/lib/modules.test.ts b/web/portal/lib/modules.test.ts index 56906487..673cc8d4 100644 --- a/web/portal/lib/modules.test.ts +++ b/web/portal/lib/modules.test.ts @@ -83,3 +83,17 @@ describe("loadModuleRegistry", () => { expect(new Set(keys).size).toBe(keys.length); }); }); + +describe("applyUrlOverrides", () => { + const mods = [{ key: "dms", label: "D", description: "x", href: "http://localhost:3101", permission: "p" }]; + + it("ersetzt den Href per NEXARCH_MODULE_URL_", async () => { + const { applyUrlOverrides } = await import("./modules"); + expect(applyUrlOverrides(mods, { NEXARCH_MODULE_URL_DMS: "https://dms.example" })[0].href).toBe("https://dms.example"); + }); + + it("laesst den Href ohne Override unveraendert", async () => { + const { applyUrlOverrides } = await import("./modules"); + expect(applyUrlOverrides(mods, {})[0].href).toBe("http://localhost:3101"); + }); +}); diff --git a/web/portal/lib/modules.ts b/web/portal/lib/modules.ts index 95e21c01..167df5fa 100644 --- a/web/portal/lib/modules.ts +++ b/web/portal/lib/modules.ts @@ -1,7 +1,8 @@ -import { MODULE_REGISTRY } from "@nexarch/shl"; +import { MODULE_REGISTRY, type ModuleGroup } from "@nexarch/shl"; export interface ModuleEntry { key: string; + group?: ModuleGroup; label: string; description: string; href: string; @@ -18,3 +19,11 @@ export function loadModuleRegistry(): ModuleEntry[] { (m): m is ModuleEntry => m.key !== "portal" && m.description !== undefined && m.permission !== undefined ); } + +// NEXARCH_MODULE_URL_ ersetzt den festen Port-Href eines Moduls, ohne Rebuild. +export function applyUrlOverrides(modules: ModuleEntry[], env: Record): ModuleEntry[] { + return modules.map((m) => { + const override = env[`NEXARCH_MODULE_URL_${m.key.toUpperCase()}`]; + return override ? { ...m, href: override } : m; + }); +} diff --git a/web/portal/lib/policy.ts b/web/portal/lib/policy.ts new file mode 100644 index 00000000..772155cf --- /dev/null +++ b/web/portal/lib/policy.ts @@ -0,0 +1,41 @@ +import type { ModuleEntry } from "./modules"; + +export type Authorize = (tenant: string, role: string, permission: string) => Promise; + +// Reine, testbare Kernlogik: iteriert generisch ueber JEDE Registry, egal +// wie viele/welche Eintraege config/modules.json enthaelt (Akzeptanz- +// kriterium 3 -- ein neues Modul braucht hier keine Codeaenderung). +// Ausfall/Verweigerung von 'authorize' lassen ein Modul nie durch +// (fail-closed, Pruefung 4: kein Modul ohne Berechtigung sichtbar). +export async function filterVisibleModules( + registry: ModuleEntry[], + tenant: string, + role: string, + authorize: Authorize +): Promise { + const results = await Promise.all( + registry.map(async (mod) => { + try { + return (await authorize(tenant, role, mod.permission)) ? mod : null; + } catch { + return null; + } + }) + ); + return results.filter((m): m is ModuleEntry => m !== null); +} + +export function policyAuthorize(base: string, token: string): Authorize { + return async (tenant, role, permission) => { + const res = await fetch(`${base.replace(/\/$/, "")}/authorize`, { + method: "POST", + headers: { "X-Service-Token": token, "Content-Type": "application/json" }, + body: JSON.stringify({ tenant, role, permission }), + cache: "no-store", + }); + if (!res.ok) return false; + const body = (await res.json()) as { allowed?: boolean }; + return body.allowed === true; + }; +} + diff --git a/web/portal/lib/verifyToken.ts b/web/portal/lib/verifyToken.ts new file mode 100644 index 00000000..900aadfb --- /dev/null +++ b/web/portal/lib/verifyToken.ts @@ -0,0 +1,84 @@ +import { createPublicKey, verify, type KeyObject } from "node:crypto"; + +export interface VerifiedClaims { + sub: string; + tenant: string; + role: string; +} + +export interface VerifyOptions { + jwksUrl: string; + issuer?: string; + audience?: string; + fetchImpl?: typeof fetch; + now?: () => number; +} + +const SPKI_ED25519_PREFIX = Buffer.from("302a300506032b6570032100", "hex"); +const JWKS_TTL_MS = 60_000; + +let keyCache: { url: string; at: number; keys: Map } | null = null; + +export function resetKeyCacheForTests(): void { + keyCache = null; +} + +async function loadKeys(opts: VerifyOptions, force: boolean): Promise> { + const now = (opts.now ?? Date.now)(); + if (!force && keyCache && keyCache.url === opts.jwksUrl && now - keyCache.at < JWKS_TTL_MS) return keyCache.keys; + const res = await (opts.fetchImpl ?? fetch)(opts.jwksUrl, { cache: "no-store" }); + if (!res.ok) throw new Error(`jwks ${res.status}`); + const body = (await res.json()) as { keys?: { kid?: string; public_key?: string }[] }; + const keys = new Map(); + for (const k of body.keys ?? []) { + if (!k.kid || !k.public_key) continue; + const raw = Buffer.from(k.public_key, "base64"); + if (raw.length !== 32) continue; + keys.set(k.kid, createPublicKey({ key: Buffer.concat([SPKI_ED25519_PREFIX, raw]), format: "der", type: "spki" })); + } + keyCache = { url: opts.jwksUrl, at: now, keys }; + return keys; +} + +function parseJson(part: string): Record | null { + try { + const v = JSON.parse(Buffer.from(part, "base64url").toString("utf8")); + return typeof v === "object" && v !== null ? v : null; + } catch { + return null; + } +} + +// Gibt nur bei gueltiger Signatur, Ablaufzeit und (falls gesetzt) Aussteller/Zielmodul Claims zurueck. +export async function verifyModuleToken(token: string, opts: VerifyOptions): Promise { + const parts = token.split("."); + if (parts.length !== 3) return null; + const header = parseJson(parts[0]); + const claims = parseJson(parts[1]); + if (!header || !claims || header.alg !== "EdDSA" || typeof header.kid !== "string") return null; + + let keys: Map; + try { + keys = await loadKeys(opts, false); + if (!keys.has(header.kid)) keys = await loadKeys(opts, true); + } catch { + return null; + } + const key = keys.get(header.kid); + if (!key) return null; + + const signed = Buffer.from(`${parts[0]}.${parts[1]}`); + const sig = Buffer.from(parts[2], "base64url"); + if (!verify(null, signed, key, sig)) return null; + + const nowSec = Math.floor((opts.now ?? Date.now)() / 1000); + if (typeof claims.exp !== "number" || claims.exp <= nowSec) return null; + if (typeof claims.nbf === "number" && claims.nbf > nowSec) return null; + if (opts.issuer && claims.iss !== opts.issuer) return null; + if (opts.audience) { + const aud = Array.isArray(claims.aud) ? claims.aud : [claims.aud]; + if (!aud.includes(opts.audience)) return null; + } + if (typeof claims.sub !== "string" || typeof claims.tenant !== "string" || !claims.sub || !claims.tenant) return null; + return { sub: claims.sub, tenant: claims.tenant, role: typeof claims.role === "string" ? claims.role : "" }; +} diff --git a/web/portal/lib/worklist.test.ts b/web/portal/lib/worklist.test.ts new file mode 100644 index 00000000..62d1ca70 --- /dev/null +++ b/web/portal/lib/worklist.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it, vi } from "vitest"; +import { countApprovals, countOwnReminders, countOwnTasks, loadWorklist } from "./worklist"; + +describe("Zaehler", () => { + it("zaehlt nur eigene, nicht erledigte Personen-Aufgaben", () => { + const rows = [ + { AssigneeType: "person", AssigneeID: "u1", Status: "open" }, + { AssigneeType: "person", AssigneeID: "u1", Status: "done" }, + { AssigneeType: "person", AssigneeID: "u2", Status: "open" }, + { AssigneeType: "group", AssigneeID: "u1", Status: "open" }, + ]; + expect(countOwnTasks(rows, "u1")).toBe(1); + }); + + it("zaehlt nur eigene ueberfaellige oder diese Woche faellige Wiedervorlagen", () => { + const rows = [ + { Column: "overdue", Reminder: { CreatedBy: "u1" } }, + { Column: "this_week", Reminder: { CreatedBy: "u1" } }, + { Column: "open", Reminder: { CreatedBy: "u1" } }, + { Column: "overdue", Reminder: { CreatedBy: "u2" } }, + ]; + expect(countOwnReminders(rows, "u1")).toBe(2); + }); + + it("liefert 0 bei unerwarteter Antwortform", () => { + expect(countApprovals(null)).toBe(0); + expect(countOwnTasks({}, "u1")).toBe(0); + expect(countOwnReminders("x", "u1")).toBe(0); + }); +}); + +describe("loadWorklist", () => { + it("setzt X-User-Id und X-Tenant-Slug und gibt nur Zaehler zurueck", async () => { + const calls: { url: string; headers: Record }[] = []; + const fetchImpl = vi.fn(async (url: string, init: RequestInit) => { + calls.push({ url, headers: init.headers as Record }); + if (url.endsWith("/approvals")) return new Response(JSON.stringify([{ ID: "a" }, { ID: "b" }]), { status: 200 }); + if (url.endsWith("/tasks")) return new Response(JSON.stringify([{ AssigneeType: "person", AssigneeID: "u1", Status: "open", Title: "geheim" }]), { status: 200 }); + return new Response(JSON.stringify([]), { status: 200 }); + }) as unknown as typeof fetch; + + const w = await loadWorklist("http://wf.example/", "acme", "u1", fetchImpl); + + expect(w).toEqual({ approvals: 2, tasks: 1, remindersDue: 0 }); + expect(JSON.stringify(w)).not.toContain("geheim"); + expect(calls.every((c) => c.headers["X-User-Id"] === "u1" && c.headers["X-Tenant-Slug"] === "acme")).toBe(true); + }); + + it("markiert ausgefallene Quellen mit null, ohne die anderen zu verlieren", async () => { + const fetchImpl = vi.fn(async (url: string) => { + if (url.endsWith("/tasks")) throw new Error("down"); + if (url.endsWith("/reminders/kanban")) return new Response("", { status: 500 }); + return new Response(JSON.stringify([{ ID: "a" }]), { status: 200 }); + }) as unknown as typeof fetch; + expect(await loadWorklist("http://wf.example", "acme", "u1", fetchImpl)).toEqual({ approvals: 1, tasks: null, remindersDue: null }); + }); +}); diff --git a/web/portal/lib/worklist.ts b/web/portal/lib/worklist.ts new file mode 100644 index 00000000..d9c4ad7a --- /dev/null +++ b/web/portal/lib/worklist.ts @@ -0,0 +1,60 @@ +export interface Worklist { + approvals: number | null; + tasks: number | null; + remindersDue: number | null; +} + +interface TaskRow { + AssigneeType?: string; + AssigneeID?: string; + Status?: string; +} + +interface KanbanRow { + Column?: string; + Reminder?: { CreatedBy?: string }; +} + +export function countApprovals(rows: unknown): number { + return Array.isArray(rows) ? rows.length : 0; +} + +// /tasks liefert alle Aufgaben der Instanz; nur die eigenen, offenen werden gezaehlt. +export function countOwnTasks(rows: unknown, userId: string): number { + if (!Array.isArray(rows)) return 0; + return (rows as TaskRow[]).filter( + (t) => t.AssigneeType === "person" && t.AssigneeID === userId && t.Status !== "done" + ).length; +} + +// /reminders/kanban liefert alle Wiedervorlagen; gezaehlt werden die eigenen, ueberfaelligen oder diese Woche faelligen. +export function countOwnReminders(rows: unknown, userId: string): number { + if (!Array.isArray(rows)) return 0; + return (rows as KanbanRow[]).filter( + (k) => k.Reminder?.CreatedBy === userId && (k.Column === "overdue" || k.Column === "this_week") + ).length; +} + +export async function loadWorklist( + base: string, + tenant: string, + userId: string, + fetchImpl: typeof fetch = fetch +): Promise { + const root = base.replace(/\/$/, ""); + const headers = { "X-User-Id": userId, "X-Tenant-Slug": tenant, Accept: "application/json" }; + const get = async (path: string): Promise => { + try { + const res = await fetchImpl(`${root}${path}`, { headers, cache: "no-store" }); + return res.ok ? await res.json() : null; + } catch { + return null; + } + }; + const [approvals, tasks, reminders] = await Promise.all([get("/approvals"), get("/tasks"), get("/reminders/kanban")]); + return { + approvals: approvals === null ? null : countApprovals(approvals), + tasks: tasks === null ? null : countOwnTasks(tasks, userId), + remindersDue: reminders === null ? null : countOwnReminders(reminders, userId), + }; +} diff --git a/web/shl/__tests__/AccountBar.test.tsx b/web/shl/__tests__/AccountBar.test.tsx new file mode 100644 index 00000000..b04de9f4 --- /dev/null +++ b/web/shl/__tests__/AccountBar.test.tsx @@ -0,0 +1,36 @@ +import { describe, expect, it } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { AccountBar } from "../components/AccountBar"; +import { I18nProvider } from "../i18n/i18n"; +import { ThemeProvider } from "../theme/ThemeProvider"; + +function renderBar(props: Partial>) { + return render( + + + {}} {...props} /> + + , + ); +} + +describe("AccountBar", () => { + it("zeigt Benutzer, Rolle und Mandant", async () => { + renderBar({ loadIdentity: async () => ({ userId: "u-1", tenant: "t-1", role: "admin" }) }); + const el = await screen.findByTestId("account-identity"); + expect(el.textContent).toContain("u-1"); + expect(el.textContent).toContain("admin"); + expect(el.textContent).toContain("t-1"); + }); + + it("zeigt nur Abmelden, wenn die Identitaet nicht ladbar ist", async () => { + renderBar({ loadIdentity: async () => { throw new Error("x"); } }); + await screen.findByRole("button"); + expect(screen.queryByTestId("account-identity")).toBeNull(); + }); + + it("rendert nichts, wenn nicht sichtbar", () => { + const { container } = renderBar({ visible: false }); + expect(container.querySelector("header")).toBeNull(); + }); +}); diff --git a/web/shl/auth/session.ts b/web/shl/auth/session.ts index a17a655b..629795b8 100644 --- a/web/shl/auth/session.ts +++ b/web/shl/auth/session.ts @@ -35,6 +35,8 @@ export interface Identity { role: string; } +export type IdentityLoader = () => Promise; + export interface SessionOptions { coreBase: string; fetchImpl?: typeof fetch; diff --git a/web/shl/components/AccountBar.tsx b/web/shl/components/AccountBar.tsx new file mode 100644 index 00000000..770369e9 --- /dev/null +++ b/web/shl/components/AccountBar.tsx @@ -0,0 +1,43 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { LogoutButton } from "../auth/LogoutButton"; +import type { Identity, IdentityLoader } from "../auth/session"; +import { useI18n } from "../i18n/i18n"; + +export interface AccountBarProps { + visible: boolean; + onLogout: () => void; + loadIdentity?: IdentityLoader; +} + +export function AccountBar({ visible, onLogout, loadIdentity }: AccountBarProps) { + const { t } = useI18n(); + const [identity, setIdentity] = useState(null); + + useEffect(() => { + if (!visible || !loadIdentity) return; + let cancelled = false; + loadIdentity().then( + (id) => { + if (!cancelled) setIdentity(id); + }, + () => {}, + ); + return () => { + cancelled = true; + }; + }, [visible, loadIdentity]); + + if (!visible) return null; + return ( +
+ {identity && ( + + {identity.userId} · {t("shl.auth.role")}: {identity.role} · {t("shl.auth.tenant")}: {identity.tenant} + + )} + +
+ ); +} diff --git a/web/shl/components/ModuleNavShell.tsx b/web/shl/components/ModuleNavShell.tsx index 3578e054..f4c6ce25 100644 --- a/web/shl/components/ModuleNavShell.tsx +++ b/web/shl/components/ModuleNavShell.tsx @@ -17,8 +17,11 @@ import { useEffect, useState } from "react"; import type { ReactNode } from "react"; import { Shell } from "./Shell"; +export type ModuleGroup = "work" | "admin" | "audit"; + export interface ModuleRegistryEntry { key: string; + group?: ModuleGroup; label: string; href: string; description?: string; @@ -27,55 +30,55 @@ export interface ModuleRegistryEntry { export const MODULE_REGISTRY: ModuleRegistryEntry[] = [ { - key: "dms", label: "Dokumente", href: "http://localhost:3101", + key: "dms", group: "work", label: "Dokumente", href: "http://localhost:3101", description: "Dokumente durchsuchen und verwalten.", permission: "dms.document.read", }, { - key: "dms_upload", label: "Dokumente hochladen", href: "http://localhost:3107", + key: "dms_upload", group: "work", label: "Dokumente hochladen", href: "http://localhost:3107", description: "Dokumente und Kassenbelege hochladen, inklusive mobiler Kamera-Erfassung.", permission: "dms.upload.read", }, { - key: "mail", label: "Postfach", href: "http://localhost:3102", + key: "mail", group: "work", label: "Postfach", href: "http://localhost:3102", description: "E-Mails durchsuchen und verwalten.", permission: "mail.message.read", }, { - key: "workflow", label: "Aufgaben & Freigaben", href: "http://localhost:3103", + key: "workflow", group: "work", label: "Aufgaben & Freigaben", href: "http://localhost:3103", description: "Freigaben bearbeiten, Akten einsehen, Aufgaben verwalten.", permission: "workflow.approval.read", }, { - key: "workflow_cases", label: "Akten & Vorgänge", href: "http://localhost:3108", + key: "workflow_cases", group: "work", label: "Akten & Vorgänge", href: "http://localhost:3108", description: "Akten, Vermerke und Wiedervorlagen einsehen.", permission: "workflow.case.read", }, { - key: "routing_rules", label: "Routing-Regeln", href: "http://localhost:3109", + key: "routing_rules", group: "admin", label: "Routing-Regeln", href: "http://localhost:3109", description: "Regelbasiertes Routing anlegen und testen.", permission: "workflow.routingrule.read", }, { - key: "ai", label: "KI-Funktionen", href: "http://localhost:3104", + key: "ai", group: "work", label: "KI-Funktionen", href: "http://localhost:3104", description: "Zusammenfassungen, Extraktion und semantische Suche.", permission: "ai.summary.read", }, { - key: "ai_extraction", label: "Extraktions-Korrektur", href: "http://localhost:3110", + key: "ai_extraction", group: "work", label: "Extraktions-Korrektur", href: "http://localhost:3110", description: "Von der KI extrahierte Werte prüfen und korrigieren.", permission: "ai.extraction.read", }, { - key: "connect", label: "Externe Anbindungen", href: "http://localhost:3105", + key: "connect", group: "admin", label: "Externe Anbindungen", href: "http://localhost:3105", description: "Status externer Systemanbindungen (ERP, DATEV, CRM).", permission: "connect.status.read", }, { - key: "connect_datev", label: "DATEV-Export", href: "http://localhost:3111", + key: "connect_datev", group: "admin", label: "DATEV-Export", href: "http://localhost:3111", description: "DATEV-Exporte anstoßen und Übertragungsprotokoll einsehen.", permission: "connect.datev.read", }, { - key: "tenant_admin", label: "Mandantenverwaltung", href: "http://localhost:3106", + key: "tenant_admin", group: "admin", label: "Mandantenverwaltung", href: "http://localhost:3106", description: "Mandanten und Import-Verzeichnisse verwalten.", permission: "core.tenant.admin", }, { - key: "rbac_admin", label: "Rollen & Rechte", href: "http://localhost:3112", + key: "rbac_admin", group: "admin", label: "Rollen & Rechte", href: "http://localhost:3112", description: "Rollen und Berechtigungen verwalten.", permission: "core.tenant.admin", }, { - key: "kennzahlen_dashboard", label: "Kennzahlen", href: "http://localhost:3113", + key: "kennzahlen_dashboard", group: "audit", label: "Kennzahlen", href: "http://localhost:3113", description: "Löschkennzahlen je Tenant und Modul einsehen.", permission: "kennzahlen.read", }, { key: "portal", label: "Übersicht", href: "http://localhost:3900" }, @@ -83,6 +86,7 @@ export const MODULE_REGISTRY: ModuleRegistryEntry[] = [ export interface ModuleNavShellProps { activeKey: string; + tenantLabel?: string; children: ReactNode; } @@ -119,7 +123,7 @@ function tenantLabel(): string { return process.env.NEXT_PUBLIC_TENANT_SLUG ?? process.env.NEXT_PUBLIC_MAIL_TENANT_SLUG ?? "demo"; } -export function ModuleNavShell({ activeKey, children }: ModuleNavShellProps) { +export function ModuleNavShell({ activeKey, tenantLabel: tenantLabelProp, children }: ModuleNavShellProps) { const [hrefs, setHrefs] = useState>({}); useEffect(() => { @@ -134,7 +138,7 @@ export function ModuleNavShell({ activeKey, children }: ModuleNavShellProps) { })); return ( - + {children} ); diff --git a/web/shl/i18n/messages.ts b/web/shl/i18n/messages.ts index 98806e32..441f784f 100644 --- a/web/shl/i18n/messages.ts +++ b/web/shl/i18n/messages.ts @@ -19,6 +19,8 @@ const baseMessages: MessageBundle = { "shl.theme.toggleToLight": "Helles Erscheinungsbild", "shl.theme.toggleToDark": "Dunkles Erscheinungsbild", "shl.auth.logout": "Abmelden", + "shl.auth.role": "Rolle", + "shl.auth.tenant": "Mandant", }, en: { "shl.dialog.close": "Close", @@ -27,6 +29,8 @@ const baseMessages: MessageBundle = { "shl.theme.toggleToLight": "Switch to light theme", "shl.theme.toggleToDark": "Switch to dark theme", "shl.auth.logout": "Sign out", + "shl.auth.role": "Role", + "shl.auth.tenant": "Tenant", }, }; diff --git a/web/shl/index.ts b/web/shl/index.ts index f1e46790..95a3b004 100644 --- a/web/shl/index.ts +++ b/web/shl/index.ts @@ -15,7 +15,10 @@ export { Shell } from "./components/Shell"; export type { ShellProps, ModuleLink } from "./components/Shell"; export { ModuleNavShell, MODULE_REGISTRY, resolveModuleHref } from "./components/ModuleNavShell"; -export type { ModuleNavShellProps, ModuleRegistryEntry } from "./components/ModuleNavShell"; +export type { ModuleNavShellProps, ModuleRegistryEntry, ModuleGroup } from "./components/ModuleNavShell"; + +export { AccountBar } from "./components/AccountBar"; +export type { AccountBarProps } from "./components/AccountBar"; export { Dialog } from "./components/Dialog"; export type { DialogProps } from "./components/Dialog"; @@ -27,7 +30,7 @@ export { TextField, SelectField, CheckboxField } from "./components/FormElements export type { TextFieldProps, SelectFieldProps, CheckboxFieldProps } from "./components/FormElements"; export { createSession, redirectToLogin, describeHttpError, authMessages, AuthError } from "./auth/session"; -export type { Session, SessionOptions, Identity, AuthErrorKind } from "./auth/session"; +export type { Session, SessionOptions, Identity, IdentityLoader, AuthErrorKind } from "./auth/session"; export { LogoutButton } from "./auth/LogoutButton"; export type { LogoutButtonProps } from "./auth/LogoutButton";