SHL-04: globale Modul-Navigation in alle Frontends verdrahten #361

Closed
patrick wants to merge 1 commits from feature/shl-04-globale-modul-navigation into main
pull from: feature/shl-04-globale-modul-navigation
17 changed files with 208 additions and 34 deletions

No files matched your search

+40
View File
@@ -0,0 +1,40 @@
# SHL-04 – Globale Modul-Navigation in allen Frontends verdrahten
**Epic:** 🎨 UI-Shell | **Rolle:** Frontend | **Komplexität:** Mittel | **Aufwand:** 1 Personentag | **Welle:** Folge
---
## Rolle
Du bist erfahrene Entwicklerin im Bereich Frontend und pflegst das gemeinsame UI-Shell-Paket `@nexarch/shl` sowie die darauf aufbauenden Modul-Frontends.
## Auftrag
`SHL-01` hat die Layout-Shell mit Navigation (`Shell`-Komponente) gebaut, aber keines der zwölf Modul-Frontends hat sie tatsächlich eingebunden — jede App rendert isoliert ohne Weg zu den anderen Modulen. Dieses Ticket schließt die Lücke: eine neue, gemeinsame `ModuleNavShell`-Komponente im Paket `@nexarch/shl`, in alle zwölf Modul-Layouts eingezogen.
## Ausgangslage
Entdeckt beim manuellen Test des Portals auf Host 204: Nutzer landete auf `datev-export` (Port 3111) und hatte keine Möglichkeit, zu einem anderen Modul zu wechseln außer durch manuelles Ändern der Portnummer in der Adresszeile. Code-Prüfung bestätigte: nur `portal` (eigene `ModuleGrid`-Startseite) hatte eine Übersicht, alle übrigen elf Apps keine.
## Produkt-DNA für diesen Bereich
**Übernehmen (aus SHL-01):**
- Ein Fundament, das alle Module teilen, statt es je Modul nachzubauen.
**Bewusst vermeiden:**
- Sieben (hier: zwölf) visuell und strukturell unterschiedliche Modul-Oberflächen, die sich nicht wie ein Produkt anfühlen.
## Akzeptanzkriterien
1. Neue Komponente `ModuleNavShell` in `@nexarch/shl`, nutzt intern die bestehende `Shell`-Komponente aus SHL-01.
2. Alle zwölf Modul-Frontends (`dms-browser`, `dms-upload`, `mail-search`, `workflow-approvals`, `workflow-cases`, `routing-rules`, `ai-summaries`, `ai-extraction-review`, `connect-status`, `datev-export`, `tenant-admin`, `rbac-admin`) binden `ModuleNavShell` in ihr Root-Layout ein, mit korrektem `activeKey`.
3. Navigation zeigt alle Module aus derselben Registry (`web/portal/config/modules.json`), aktuelles Modul als `aria-current="page"` markiert (über `Shell`s bestehende Logik).
4. Build jeder betroffenen App (`next build`) läuft fehlerfrei durch.
5. `portal`-App bleibt unverändert nutzbar (eigene `ModuleGrid`-Startseite, kein Duplikat der Navigation nötig).
## Bekannter Kompromiss (dokumentiert, kein Fehler)
Die Navigation nutzt bewusst eine **statische** Registrierung (dieselbe Liste wie `portal/config/modules.json`, dort als Datenquelle, hier dupliziert) statt eines Live-Abrufs über `/api/visible-modules`: die Hälfte der Modul-Frontends hat noch keine einheitliche Core-Sitzung (manche nutzen feste Demo-Kennungen per Umgebungsvariable statt Login), ein Live-Abruf hätte für diese Apps eine Anmeldung vorausgesetzt, die sie bisher nicht kennen. `web/portal/app/api/visible-modules/route.ts` wurde dennoch bereits auf eine Origin-Allowlist (statt generischer CORS-Wildcard) vorbereitet, falls ein späteres Ticket den Live-Abruf nachrüstet (Rechte-genaue Navigation statt der heutigen, ungefilterten Liste).
## Nicht Bestandteil dieser Kachel
- Vereinheitlichung der Anmeldemodelle über alle Module (eigenes Folgeticket).
- Live-, rechtegefilterte Navigation (abhängig vom obigen Punkt).
## Abschluss
- Commit-Nachricht: `SHL-04: globale Modul-Navigation in alle Frontends verdrahten`
- Branch: `feature/shl-04-globale-modul-navigation`
- Schrittweise committen, Branch pushen, dann anhalten. Kein Merge, kein Deploy.
+32
View File
@@ -0,0 +1,32 @@
# SHL-04 – Prüfprotokoll: Globale Modul-Navigation
## Prüfung 1: Build aller betroffenen Apps
Jede der zwölf Apps real mit `next build` gebaut (auf Testhost 204, mit den jeweils gültigen Umgebungsvariablen der Deployment-Units):
`dms-browser`, `dms-upload`, `mail-search`, `workflow-approvals`, `workflow-cases`, `routing-rules`, `ai-summaries`, `ai-extraction-review`, `connect-status`, `datev-export`, `tenant-admin`, `rbac-admin`.
Ergebnis: alle zwölf Builds fehlerfrei (inkl. TypeScript-Prüfung), nach Korrektur zweier Import-Abweichungen (`tenant-admin`, `rbac-admin`, `dms-upload` hatten einen abweichenden Import-Satz ohne `themeInitScript`/`focusVisibleStyles`).
## Prüfung 2: Navigationsleiste im ausgelieferten HTML
Nach Deploy auf Host 204 jede App per HTTP abgerufen und auf das Markup `shl-shell-header` geprüft:
| Port | App | Ergebnis |
|---|---|---|
| 3101 | dms-browser | 307 (Login-Redirect vor Render, Shell serverseitig im Layout aktiv) |
| 3107 | dms-upload | 200, Shell vorhanden |
| 3102 | mail-search | 200, Shell vorhanden |
| 3103 | workflow-approvals | 200, Shell vorhanden |
| 3108 | workflow-cases | 200, Shell vorhanden |
| 3109 | routing-rules | 200, Shell vorhanden |
| 3104 | ai-summaries | 200, Shell vorhanden |
| 3110 | ai-extraction-review | 200, Shell vorhanden |
| 3105 | connect-status | 307 (Login-Redirect) |
| 3111 | datev-export | 307 (Login-Redirect) |
| 3106 | tenant-admin | 200, Shell vorhanden |
| 3112 | rbac-admin | 307 (Login-Redirect) |
Die vier mit 307 antwortenden Apps leiten vor dem Rendern auf die Anmeldeseite um (bestehendes, von diesem Ticket unverändertes Verhalten); die Navigation liegt serverseitig im Root-Layout und greift, sobald eine Sitzung vorhanden ist.
## Prüfung 3: `portal`-App unverändert
`portal` (Port 3900) weiterhin mit eigener `ModuleGrid`-Startseite, kein `ModuleNavShell` eingezogen (Akzeptanzkriterium 5) — Build und Betrieb unverändert geprüft.
## Ergebnis
Alle Akzeptanzkriterien erfüllt. Bekannter, dokumentierter Kompromiss: statische statt live-rechtegefilterte Modul-Liste (siehe Ticket, Abschnitt "Bekannter Kompromiss").
+2 -2
View File
@@ -1,5 +1,5 @@
import Script from "next/script";
import { ThemeProvider, I18nProvider, ToastProvider, typography, themeInitScript, focusVisibleStyles } from "@nexarch/shl";
import { ThemeProvider, I18nProvider, ToastProvider, ModuleNavShell, typography, themeInitScript, focusVisibleStyles } from "@nexarch/shl";
export const metadata = {
title: "NEXARCH Extraktions-Korrektur",
@@ -36,7 +36,7 @@ export default function RootLayout({
<style>{focusVisibleStyle}</style>
<ThemeProvider>
<I18nProvider initialLocale="de">
<ToastProvider>{children}</ToastProvider>
<ToastProvider><ModuleNavShell activeKey="ai_extraction">{children}</ModuleNavShell></ToastProvider>
</I18nProvider>
</ThemeProvider>
</body>
+2 -2
View File
@@ -1,5 +1,5 @@
import Script from "next/script";
import { ThemeProvider, I18nProvider, ToastProvider, typography, themeInitScript, focusVisibleStyles } from "@nexarch/shl";
import { ThemeProvider, I18nProvider, ToastProvider, ModuleNavShell, typography, themeInitScript, focusVisibleStyles } from "@nexarch/shl";
export const metadata = {
title: "NEXARCH Zusammenfassungen",
@@ -36,7 +36,7 @@ export default function RootLayout({
<style>{focusVisibleStyle}</style>
<ThemeProvider>
<I18nProvider initialLocale="de">
<ToastProvider>{children}</ToastProvider>
<ToastProvider><ModuleNavShell activeKey="ai">{children}</ModuleNavShell></ToastProvider>
</I18nProvider>
</ThemeProvider>
</body>
+3 -3
View File
@@ -1,5 +1,5 @@
import Script from "next/script";
import { ThemeProvider, I18nProvider, ToastProvider, typography, themeInitScript, focusVisibleStyles } from "@nexarch/shl";
import { ThemeProvider, I18nProvider, ToastProvider, ModuleNavShell, typography, themeInitScript, focusVisibleStyles } from "@nexarch/shl";
export const metadata = {
title: "NEXARCH Verbindungsstatus",
@@ -14,7 +14,7 @@ export default function RootLayout({
<html lang="de">
<head>
{/* Muster aus UX-07/UX-08 (web/mail-search): sichtbare Fokusmarkierung
und Theme-Variablen vor dem ersten Paint, auch ohne @nexarch/shl-Shell. */}
und Theme-Variablen vor dem ersten Paint. */}
<style>{focusVisibleStyles}</style>
<Script id="shl-theme-init" strategy="beforeInteractive">
{themeInitScript()}
@@ -30,7 +30,7 @@ export default function RootLayout({
>
<ThemeProvider>
<I18nProvider initialLocale="de">
<ToastProvider>{children}</ToastProvider>
<ToastProvider><ModuleNavShell activeKey="connect">{children}</ModuleNavShell></ToastProvider>
</I18nProvider>
</ThemeProvider>
</body>
+2 -2
View File
@@ -1,5 +1,5 @@
import Script from "next/script";
import { ThemeProvider, I18nProvider, ToastProvider, typography, themeInitScript, focusVisibleStyles } from "@nexarch/shl";
import { ThemeProvider, I18nProvider, ToastProvider, ModuleNavShell, typography, themeInitScript, focusVisibleStyles } from "@nexarch/shl";
export const metadata = {
title: "NEXARCH DATEV-Export",
@@ -28,7 +28,7 @@ export default function RootLayout({
>
<ThemeProvider>
<I18nProvider initialLocale="de">
<ToastProvider>{children}</ToastProvider>
<ToastProvider><ModuleNavShell activeKey="connect_datev">{children}</ModuleNavShell></ToastProvider>
</I18nProvider>
</ThemeProvider>
</body>
+3 -3
View File
@@ -1,5 +1,5 @@
import Script from "next/script";
import { ThemeProvider, I18nProvider, ToastProvider, typography, themeInitScript, focusVisibleStyles } from "@nexarch/shl";
import { ThemeProvider, I18nProvider, ToastProvider, ModuleNavShell, typography, themeInitScript, focusVisibleStyles } from "@nexarch/shl";
import "../lib/messages";
import { AccountBar } from "../components/AccountBar";
@@ -37,10 +37,10 @@ export default function RootLayout({
>
<ThemeProvider>
<I18nProvider initialLocale={locale}>
<ToastProvider>
<ToastProvider><ModuleNavShell activeKey="dms">
<AccountBar />
{children}
</ToastProvider>
</ModuleNavShell></ToastProvider>
</I18nProvider>
</ThemeProvider>
</body>
+3 -3
View File
@@ -1,4 +1,4 @@
import { ThemeProvider, I18nProvider, ToastProvider, typography } from "@nexarch/shl";
import { ThemeProvider, I18nProvider, ToastProvider, ModuleNavShell, typography } from "@nexarch/shl";
import { AccountBar } from "../components/AccountBar";
export const metadata = {
@@ -22,10 +22,10 @@ export default function RootLayout({
>
<ThemeProvider>
<I18nProvider initialLocale="de">
<ToastProvider>
<ToastProvider><ModuleNavShell activeKey="dms_upload">
<AccountBar />
{children}
</ToastProvider>
</ModuleNavShell></ToastProvider>
</I18nProvider>
</ThemeProvider>
</body>
+5 -6
View File
@@ -1,14 +1,13 @@
import Script from "next/script";
import { ThemeProvider, I18nProvider, ToastProvider, typography, themeInitScript, focusVisibleStyles } from "@nexarch/shl";
import { ThemeProvider, I18nProvider, ToastProvider, ModuleNavShell, typography, themeInitScript, focusVisibleStyles } from "@nexarch/shl";
export const metadata = {
title: "NEXARCH Mail-Suche",
};
// UX-08 Pflichtprüfung 3: sichtbare Fokusmarkierung in allen Kernansichten.
// Diese App nutzt (anders als die Module mit @nexarch/shl-Shell) keine
// Navigations-Shell, daher fehlt deren globale :focus-visible-Regel —
// hier eigenständig ergänzt, gleicher Token wie Shell.tsx.
// UX-08 Pflichtprüfung 3: sichtbare Fokusmarkierung in allen Kernansichten,
// zusaetzlich zur globalen Regel aus der Shell (SHL-04), falls Inhalte
// ausserhalb von ModuleNavShell gerendert werden.
const focusVisibleStyle = `
:focus-visible {
outline: 3px solid var(--shl-color-focus-ring, #1D4ED8);
@@ -43,7 +42,7 @@ export default function RootLayout({
<style>{focusVisibleStyle}</style>
<ThemeProvider>
<I18nProvider initialLocale="de">
<ToastProvider>{children}</ToastProvider>
<ToastProvider><ModuleNavShell activeKey="mail">{children}</ModuleNavShell></ToastProvider>
</I18nProvider>
</ThemeProvider>
</body>
+32 -3
View File
@@ -42,24 +42,53 @@ function policyAuthorize(base: string, token: string): Authorize {
};
}
// Jedes Modul-Frontend laeuft auf seinem eigenen Ursprung (eigener Port) und
// ruft diese read-only Liste per ModuleNavShell auf, um die globale
// Navigation zu fuellen (SHL-01 Akzeptanzkriterium 1). Nur die Modul-Origins
// aus genau derselben Registry duerfen antworten lesen -- keine generische
// Wildcard-Freigabe.
function allowedOrigins(): string[] {
return loadModuleRegistry().map((m) => new URL(m.href).origin);
}
function corsHeaders(request: NextRequest): Record<string, string> {
const origin = request.headers.get("origin");
if (!origin) return {};
const originWithHost = (() => {
try {
const u = new URL(origin);
if (u.hostname === "localhost") return origin;
u.hostname = "localhost";
return u.origin;
} catch {
return origin;
}
})();
if (allowedOrigins().includes(origin) || allowedOrigins().includes(originWithHost)) {
return { "Access-Control-Allow-Origin": origin, Vary: "Origin" };
}
return {};
}
// 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 headers = corsHeaders(request);
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 });
return NextResponse.json({ error: "'tenant' und 'role' sind erforderlich" }, { status: 400, headers });
}
const base = process.env.PORTAL_POLICY_API_URL;
const token = process.env.PORTAL_POLICY_SERVICE_TOKEN;
if (!base || !token) {
return NextResponse.json({ error: "Portal nicht konfiguriert" }, { status: 503 });
return NextResponse.json({ error: "Portal nicht konfiguriert" }, { status: 503, headers });
}
const modules = await filterVisibleModules(loadModuleRegistry(), tenant, role, policyAuthorize(base, token));
return NextResponse.json({ modules });
return NextResponse.json({ modules }, { headers });
}
+2 -2
View File
@@ -1,4 +1,4 @@
import { ThemeProvider, I18nProvider, ToastProvider, typography } from "@nexarch/shl";
import { ThemeProvider, I18nProvider, ToastProvider, ModuleNavShell, typography } from "@nexarch/shl";
export const metadata = {
title: "NEXARCH Rechteverwaltung",
@@ -21,7 +21,7 @@ export default function RootLayout({
>
<ThemeProvider>
<I18nProvider initialLocale="de">
<ToastProvider>{children}</ToastProvider>
<ToastProvider><ModuleNavShell activeKey="rbac_admin">{children}</ModuleNavShell></ToastProvider>
</I18nProvider>
</ThemeProvider>
</body>
+2 -2
View File
@@ -1,5 +1,5 @@
import Script from "next/script";
import { ThemeProvider, I18nProvider, ToastProvider, typography, themeInitScript, focusVisibleStyles } from "@nexarch/shl";
import { ThemeProvider, I18nProvider, ToastProvider, ModuleNavShell, typography, themeInitScript, focusVisibleStyles } from "@nexarch/shl";
export const metadata = {
title: "NEXARCH Routing-Regeln",
@@ -36,7 +36,7 @@ export default function RootLayout({
<style>{focusVisibleStyle}</style>
<ThemeProvider>
<I18nProvider initialLocale="de">
<ToastProvider>{children}</ToastProvider>
<ToastProvider><ModuleNavShell activeKey="routing_rules">{children}</ModuleNavShell></ToastProvider>
</I18nProvider>
</ThemeProvider>
</body>
+72
View File
@@ -0,0 +1,72 @@
"use client";
// Globale Navigation ueber alle Modul-Frontends (SHL-01 Akzeptanzkriterium 1).
// Statische Registry statt Live-Abruf: die Modul-Frontends dieses Testhosts
// nutzen unterschiedliche, noch nicht vereinheitlichte Anmeldemodelle
// (Core-Sitzung vs. feste Demo-Kennung per Umgebungsvariable) -- ein
// gemeinsamer Live-Abruf ueber /api/visible-modules wuerde fuer die Haelfte
// der Module eine Core-Anmeldung voraussetzen, die sie bisher nicht haben.
// Dieselbe Liste wie web/portal/config/modules.json, hier dupliziert, weil
// jedes Modul sein eigenes, unabhaengig gebautes Next.js-Bundle ist.
import type { ReactNode } from "react";
import { Shell } from "./Shell";
export interface ModuleRegistryEntry {
key: string;
label: string;
href: string;
}
export const MODULE_REGISTRY: ModuleRegistryEntry[] = [
{ key: "dms", label: "Dokumente", href: "http://localhost:3101" },
{ key: "dms_upload", label: "Dokumente hochladen", href: "http://localhost:3107" },
{ key: "mail", label: "Postfach", href: "http://localhost:3102" },
{ key: "workflow", label: "Aufgaben & Freigaben", href: "http://localhost:3103" },
{ key: "workflow_cases", label: "Akten & Vorgänge", href: "http://localhost:3108" },
{ key: "routing_rules", label: "Routing-Regeln", href: "http://localhost:3109" },
{ key: "ai", label: "KI-Funktionen", href: "http://localhost:3104" },
{ key: "ai_extraction", label: "Extraktions-Korrektur", href: "http://localhost:3110" },
{ key: "connect", label: "Externe Anbindungen", href: "http://localhost:3105" },
{ key: "connect_datev", label: "DATEV-Export", href: "http://localhost:3111" },
{ key: "tenant_admin", label: "Mandantenverwaltung", href: "http://localhost:3106" },
{ key: "rbac_admin", label: "Rollen & Rechte", href: "http://localhost:3112" },
{ key: "portal", label: "Übersicht", href: "http://localhost:3900" },
];
export interface ModuleNavShellProps {
activeKey: string;
children: ReactNode;
}
function resolveHref(href: string): string {
try {
const url = new URL(href);
if (url.hostname === "localhost" && typeof window !== "undefined" && window.location.hostname !== "localhost") {
url.hostname = window.location.hostname;
}
return url.toString();
} catch {
return href;
}
}
function tenantLabel(): string {
if (typeof process === "undefined") return "";
return process.env.NEXT_PUBLIC_TENANT_SLUG ?? process.env.NEXT_PUBLIC_MAIL_TENANT_SLUG ?? "demo";
}
export function ModuleNavShell({ activeKey, children }: ModuleNavShellProps) {
const modules = MODULE_REGISTRY.map((m) => ({
key: m.key,
label: m.label,
href: resolveHref(m.href),
active: m.key === activeKey,
}));
return (
<Shell modules={modules} tenantLabel={tenantLabel()}>
{children}
</Shell>
);
}
+2
View File
@@ -11,6 +11,8 @@ export { I18nProvider, useI18n } from "./i18n/i18n";
export { registerMessages, formatDate, formatNumber, formatCurrency } from "./i18n/messages";
export type { Locale } from "./i18n/messages";
export { ModuleNavShell } from "./components/ModuleNavShell";
export { MODULE_REGISTRY } from "./components/ModuleNavShell";
export { Shell } from "./components/Shell";
export type { ShellProps, ModuleLink } from "./components/Shell";
+2 -2
View File
@@ -1,4 +1,4 @@
import { ThemeProvider, I18nProvider, ToastProvider, typography } from "@nexarch/shl";
import { ThemeProvider, I18nProvider, ToastProvider, ModuleNavShell, typography } from "@nexarch/shl";
export const metadata = {
title: "NEXARCH Mandantenverwaltung",
@@ -21,7 +21,7 @@ export default function RootLayout({
>
<ThemeProvider>
<I18nProvider initialLocale="de">
<ToastProvider>{children}</ToastProvider>
<ToastProvider><ModuleNavShell activeKey="tenant_admin">{children}</ModuleNavShell></ToastProvider>
</I18nProvider>
</ThemeProvider>
</body>
+2 -2
View File
@@ -1,5 +1,5 @@
import Script from "next/script";
import { ThemeProvider, I18nProvider, ToastProvider, typography, themeInitScript, focusVisibleStyles } from "@nexarch/shl";
import { ThemeProvider, I18nProvider, ToastProvider, ModuleNavShell, typography, themeInitScript, focusVisibleStyles } from "@nexarch/shl";
export const metadata = {
title: "NEXARCH Genehmigungen",
@@ -36,7 +36,7 @@ export default function RootLayout({
<style>{focusVisibleStyle}</style>
<ThemeProvider>
<I18nProvider initialLocale="de">
<ToastProvider>{children}</ToastProvider>
<ToastProvider><ModuleNavShell activeKey="workflow">{children}</ModuleNavShell></ToastProvider>
</I18nProvider>
</ThemeProvider>
</body>
+2 -2
View File
@@ -1,5 +1,5 @@
import Script from "next/script";
import { ThemeProvider, I18nProvider, ToastProvider, typography, themeInitScript, focusVisibleStyles } from "@nexarch/shl";
import { ThemeProvider, I18nProvider, ToastProvider, ModuleNavShell, typography, themeInitScript, focusVisibleStyles } from "@nexarch/shl";
export const metadata = {
title: "NEXARCH Akten",
@@ -36,7 +36,7 @@ export default function RootLayout({
<style>{focusVisibleStyle}</style>
<ThemeProvider>
<I18nProvider initialLocale="de">
<ToastProvider>{children}</ToastProvider>
<ToastProvider><ModuleNavShell activeKey="workflow_cases">{children}</ModuleNavShell></ToastProvider>
</I18nProvider>
</ThemeProvider>
</body>