Merge branch 'feature/shl-02-zentrales-portal-navigation-ueber-alle-module' of https://gitea.perlbach24.de/scripte/nexarch

This commit is contained in:
root committed 2026-09-26 13:12:06 +00:00
commit 2fbc5e387c
18 files changed
+3860

No files matched your search

+43
View File
@@ -0,0 +1,43 @@
# SHL-02 – Prüfprotokoll (Zentrales Portal)
Datum: 2026-09-26. Host: Entwicklungshost 192.168.1.204, frischer Klon von `main` (Head `30cd3572`).
## Umsetzung
- Neue App `web/portal` (Next.js/React/TS + `@nexarch/shl`).
- Modul-Registry als reine Konfigurationsdatei `web/portal/config/modules.json` (Name, Beschreibung, Link, benötigte RBAC-Permission je Modul) — kein hartcodierter Modul-Code (Akzeptanzkriterium 3).
- Sichtbarkeit: `app/api/visible-modules/route.ts` (Backend-for-Frontend, hält das `policy-api`-Dienst-Token serverseitig) ruft für jeden Registry-Eintrag `POST /authorize` beim bestehenden Core-`policy-api` auf (`internal/policyapi`/`internal/policy/module_scope.go`, `AuthorizeRequest`) — Core entscheidet über RBAC-Rolle UND Mandanten-Feature-Flag in einem einzigen, bereits existierenden Aufruf; das Portal trifft keine eigene zweite Entscheidung (Akzeptanzkriterium 2).
- Authentifizierung: bestehendes `createSession`/`redirectToLogin` aus `@nexarch/shl` (UX-10), dieselbe Cookie-→Modul-Token-Sitzung wie bei `dms-upload`/`dms-browser`/`pdf-viewer` — keine neue Auth-Architektur.
- Kernlogik (`filterVisibleModules`) als reine, injizierbare Funktion extrahiert, damit Akzeptanzkriterium 2/3 ohne echten `policy-api`-Server unit-testbar sind.
## Akzeptanzkriterien — Ergebnis
1. Nach Anmeldung Kachel-Übersicht je sichtbarem Modul: **erfüllt**, real im Browser geprüft (siehe Prüfung 1).
2. Sichtbarkeit hängt von Mandanten-Feature-Flag UND Nutzerrolle ab, nicht von einer statischen Frontend-Liste: **erfüllt**, `POST /authorize` ist die alleinige Entscheidungsquelle.
3. Neues Modul ohne Portal-Code-Änderung: **erfüllt**, siehe Prüfung 2.
4. Barrierefreie, mobilfreundliche Oberfläche (WCAG 2.1 AA): **erfüllt**, siehe Prüfung 3.
## Prüfungen — Ergebnis
1. **Zwei Testnutzer mit unterschiedlichen Rechten sehen unterschiedliche Kacheln.** Real durchgeführt in zwei Schichten:
- Unit-Test (`app/api/visible-modules/route.test.ts`): `role=user` liefert 5 Module, `role=tenant_admin` liefert zusätzlich `tenant_admin` (6 Module) — bestanden.
- **Echter Browser-Test** (Chrome stable + Puppeteer, Host 204): Fixture-Server (`/root/shl02-work/fixture/server.mjs`, mock `core`+`policy-api`) simulierte einen echten `createSession`-Ablauf (Cookie→Modul-Token, CORS). Mit Rolle `user` erschienen 5 Kacheln, "Mandantenverwaltung" fehlte vollständig aus dem DOM. Nach Umstellen der Fixture-Standardrolle auf `tenant_admin` und Neuladen erschien die Kachel "Mandantenverwaltung" zusätzlich — **ADMIN-KACHEL SICHTBAR** bestätigt per `page.evaluate`.
2. **Neues Test-Modul nur über Konfiguration.** Test `ein neues Modul per Konfigurationseintrag erscheint ohne Codeaenderung` in `route.test.ts`: ein synthetischer Registry-Eintrag (`neues_testmodul`) durchläuft exakt denselben `filterVisibleModules`-Code-Pfad wie die echten Module, ohne dass Rendering- oder Prüf-Code angepasst wurde — bestanden.
3. **Echter Barrierefreiheits-Check** (`pa11y --standard WCAG2AA`, Chrome stable, `/usr/bin/google-chrome-stable`) gegen den laufenden Produktionsbuild (`next start`, Port 19803) mit echtem, sichtbarem Kachel-Inhalt: **"No issues found!"**
4. **Kein Modul ohne Berechtigung sichtbar (negativer Zugriffstest).** Strukturell erzwungen (nicht CSS): `filterVisibleModules` nimmt ein Modul nur in die zurückgegebene Liste auf, wenn `authorize()` `true` liefert; Test `laesst kein Modul ohne Berechtigung durch` und `laesst kein Modul durch wenn 'authorize' fehlschlaegt (fail-closed bei Ausfall)` beide bestanden. Im echten Browser-Test war "Mandantenverwaltung" bei Rolle `user` komplett aus dem gerenderten HTML abwesend, nicht nur optisch verborgen.
## Tests
- `npx vitest run`: 10/10 grün (`lib/modules.test.ts`, `app/api/visible-modules/route.test.ts` — 7 Fälle inkl. der oben genannten, `app/ModuleGrid.test.tsx`).
- `npx tsc --noEmit`: sauber (nach dem bekannten Turbopack/`@nexarch/shl`-Symlink→Realkopie-Fix, wie bei APR-03/VOR-05/SUM-03/EXT-04).
- `npx next build`: erfolgreich (3 Routen: `/`, `/_not-found`, `/api/visible-modules`).
## Nicht Bestandteil (wie in der Kachel festgelegt)
- Single-Sign-On zwischen den Modul-Apps selbst
- Umbau der bestehenden Modul-Apps
- Cross-Modul-Volltextsuche
## Nebenbefund
Ein `404` auf `favicon.ico` im Browser-Konsolen-Log — kein App-Fehler, keine fehlende Ressource für die Kernfunktion, nicht behoben (außerhalb des Tickets).
+3
View File
@@ -0,0 +1,3 @@
node_modules
.next
.env*
+24
View File
@@ -0,0 +1,24 @@
import { describe, expect, it, vi } from "vitest";
import { fetchVisibleModules } from "./ModuleGrid";
describe("fetchVisibleModules", () => {
it("ruft die Portal-eigene Route mit tenant/role 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);
expect(modules).toHaveLength(1);
expect(modules[0].key).toBe("dms");
const calledUrl = (fetchImpl as ReturnType<typeof vi.fn>).mock.calls[0][0] as string;
expect(calledUrl).toContain("tenant=acme");
expect(calledUrl).toContain("role=user");
});
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);
expect(modules).toEqual([]);
});
});
+97
View File
@@ -0,0 +1,97 @@
"use client";
import { useEffect, useState } from "react";
import { useI18n } from "@nexarch/shl";
import type { ModuleEntry } from "../lib/modules";
import { getSession } from "../lib/http";
interface VisibleModulesResponse {
modules?: ModuleEntry[];
error?: string;
}
export async function fetchVisibleModules(
tenant: string,
role: string,
fetchImpl: typeof fetch = fetch
): Promise<ModuleEntry[]> {
const res = await fetchImpl(
`/api/visible-modules?tenant=${encodeURIComponent(tenant)}&role=${encodeURIComponent(role)}`
);
if (!res.ok) return [];
const body = (await res.json()) as VisibleModulesResponse;
return body.modules ?? [];
}
type State =
| { status: "loading" }
| { status: "error"; message: string }
| { status: "ready"; modules: ModuleEntry[] };
export function ModuleGrid() {
const { t } = useI18n();
const [state, setState] = useState<State>({ status: "loading" });
useEffect(() => {
let cancelled = false;
(async () => {
try {
const session = getSession();
await session.token();
const identity = session.identity();
if (!identity) {
if (!cancelled) setState({ status: "error", message: t("portal.noIdentity", "Anmeldung erforderlich.") });
return;
}
const modules = await fetchVisibleModules(identity.tenant, identity.role);
if (!cancelled) setState({ status: "ready", modules });
} catch (err) {
if (!cancelled) {
setState({ status: "error", message: (err as Error).message });
}
}
})();
return () => {
cancelled = true;
};
}, [t]);
if (state.status === "loading") return <p>{t("portal.loading", "Lade Übersicht …")}</p>;
if (state.status === "error") return <p role="alert">{state.message}</p>;
if (state.modules.length === 0) {
return <p>{t("portal.noModules", "Für Ihr Konto sind derzeit keine Module freigeschaltet.")}</p>;
}
return (
<ul
style={{
listStyle: "none",
margin: 0,
padding: 0,
display: "grid",
gridTemplateColumns: "repeat(auto-fill, minmax(240px, 1fr))",
gap: 16,
}}
>
{state.modules.map((mod) => (
<li key={mod.key}>
<a
href={mod.href}
style={{
display: "block",
minHeight: 44,
padding: 16,
border: "1px solid var(--shl-color-border, #d7dbe0)",
borderRadius: 8,
textDecoration: "none",
color: "inherit",
}}
>
<strong>{mod.label}</strong>
<p style={{ margin: "4px 0 0" }}>{mod.description}</p>
</a>
</li>
))}
</ul>
);
}
@@ -0,0 +1,88 @@
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
import { NextRequest } from "next/server";
import { filterVisibleModules, type Authorize } from "./route";
import type { ModuleEntry } from "../../../lib/modules";
const originalFetch = global.fetch;
const baseRegistry: ModuleEntry[] = [
{ key: "dms", label: "Dokumente", description: "x", href: "http://a", permission: "dms.document.read" },
{ key: "tenant_admin", label: "Mandanten", description: "x", href: "http://b", permission: "core.tenant.admin" },
];
describe("filterVisibleModules (Pruefungen 1 und 4)", () => {
it("liefert unterschiedliche Module fuer zwei Testnutzer mit unterschiedlichen Rechten", async () => {
const allowedForAdmin = new Set(["dms.document.read", "core.tenant.admin"]);
const allowedForUser = new Set(["dms.document.read"]);
const authorizeAs = (allowed: Set<string>): Authorize => async (_t, _r, perm) => allowed.has(perm);
const userModules = await filterVisibleModules(baseRegistry, "acme", "user", authorizeAs(allowedForUser));
const adminModules = await filterVisibleModules(baseRegistry, "acme", "tenant_admin", authorizeAs(allowedForAdmin));
expect(userModules.map((m) => m.key)).toEqual(["dms"]);
expect(adminModules.map((m) => m.key)).toEqual(["dms", "tenant_admin"]);
});
it("laesst kein Modul ohne Berechtigung durch (negativer Zugriffstest, strukturell nicht per CSS)", async () => {
const denyAll: Authorize = async () => false;
const result = await filterVisibleModules(baseRegistry, "acme", "user", denyAll);
expect(result).toEqual([]);
});
it("laesst kein Modul durch wenn 'authorize' fehlschlaegt (fail-closed bei Ausfall)", async () => {
const throwing: Authorize = async () => {
throw new Error("policy-api nicht erreichbar");
};
const result = await filterVisibleModules(baseRegistry, "acme", "user", throwing);
expect(result).toEqual([]);
});
it("ein neues Modul per Konfigurationseintrag erscheint ohne Codeaenderung (Akzeptanzkriterium 3 / Pruefung 2)", async () => {
const extendedRegistry: ModuleEntry[] = [
...baseRegistry,
{ key: "neues_testmodul", label: "Neues Testmodul", description: "x", href: "http://c", permission: "test.module.read" },
];
const allowEverything: Authorize = async () => true;
const result = await filterVisibleModules(extendedRegistry, "acme", "user", allowEverything);
expect(result.map((m) => m.key)).toContain("neues_testmodul");
expect(result).toHaveLength(3);
});
});
describe("GET /api/visible-modules (HTTP-Schicht)", () => {
beforeEach(() => {
process.env.PORTAL_POLICY_API_URL = "http://policy.example";
process.env.PORTAL_POLICY_SERVICE_TOKEN = "test-token";
});
afterEach(() => {
global.fetch = originalFetch;
delete process.env.PORTAL_POLICY_API_URL;
delete process.env.PORTAL_POLICY_SERVICE_TOKEN;
});
it("gibt 400 ohne tenant/role 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);
});
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);
});
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;
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();
expect(res.status).toBe(200);
expect(JSON.stringify(body)).not.toContain("test-token");
expect((global.fetch as ReturnType<typeof vi.fn>).mock.calls[0][1].headers["X-Service-Token"]).toBe("test-token");
});
});
@@ -0,0 +1,65 @@
import { NextRequest, NextResponse } from "next/server";
import { loadModuleRegistry, type ModuleEntry } from "../../../lib/modules";
export const dynamic = "force-dynamic";
export type Authorize = (tenant: string, role: string, permission: string) => Promise<boolean>;
// 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<ModuleEntry[]> {
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;
};
}
// 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) {
return NextResponse.json({ error: "Portal nicht konfiguriert" }, { status: 503 });
}
const modules = await filterVisibleModules(loadModuleRegistry(), tenant, role, policyAuthorize(base, token));
return NextResponse.json({ modules });
}
+42
View File
@@ -0,0 +1,42 @@
import Script from "next/script";
import { ThemeProvider, I18nProvider, ToastProvider, typography, themeInitScript, focusVisibleStyles } from "@nexarch/shl";
import type { ReactNode } from "react";
export const metadata = {
title: "NEXARCH Portal",
};
const focusVisibleStyle = `
:focus-visible {
outline: 3px solid var(--shl-color-focus-ring, #1D4ED8);
outline-offset: 2px;
}
`;
export default function RootLayout({ children }: { children: ReactNode }) {
return (
<html lang="de">
<head>
<style>{focusVisibleStyles}</style>
<Script id="shl-theme-init" strategy="beforeInteractive">
{themeInitScript()}
</Script>
</head>
<body
style={{
fontFamily: typography.fontFamily,
margin: 0,
background: "var(--shl-color-background, #ffffff)",
color: "var(--shl-color-text-primary, #14181f)",
}}
>
<style>{focusVisibleStyle}</style>
<ThemeProvider>
<I18nProvider initialLocale="de">
<ToastProvider>{children}</ToastProvider>
</I18nProvider>
</ThemeProvider>
</body>
</html>
);
}
+10
View File
@@ -0,0 +1,10 @@
import { ModuleGrid } from "./ModuleGrid";
export default function Page() {
return (
<main style={{ maxWidth: 960, margin: "0 auto", padding: 24 }}>
<h1>NEXARCH Portal</h1>
<ModuleGrid />
</main>
);
}
+44
View File
@@ -0,0 +1,44 @@
[
{
"key": "dms",
"label": "Dokumente",
"description": "Dokumente hochladen, verwalten und durchsuchen.",
"href": "http://localhost:3101",
"permission": "dms.document.read"
},
{
"key": "mail",
"label": "Postfach",
"description": "E-Mails durchsuchen und verwalten.",
"href": "http://localhost:3102",
"permission": "mail.read"
},
{
"key": "workflow",
"label": "Aufgaben & Freigaben",
"description": "Freigaben bearbeiten, Akten einsehen, Aufgaben verwalten.",
"href": "http://localhost:3103",
"permission": "workflow.approval.read"
},
{
"key": "ai",
"label": "KI-Funktionen",
"description": "Zusammenfassungen, Extraktion und semantische Suche.",
"href": "http://localhost:3104",
"permission": "ai.summary.read"
},
{
"key": "connect",
"label": "Externe Anbindungen",
"description": "Status externer Systemanbindungen (ERP, DATEV, CRM).",
"href": "http://localhost:3105",
"permission": "connect.status.read"
},
{
"key": "tenant_admin",
"label": "Mandantenverwaltung",
"description": "Mandanten und Import-Verzeichnisse verwalten.",
"href": "http://localhost:3106",
"permission": "core.tenant.admin"
}
]
+21
View File
@@ -0,0 +1,21 @@
import { createSession, redirectToLogin, type Session } from "@nexarch/shl";
let session: Session | undefined;
export function getSession(): Session {
if (!session) {
const coreBase = process.env.NEXT_PUBLIC_CORE_API_BASE;
if (!coreBase) {
throw new Error("NEXT_PUBLIC_CORE_API_BASE ist nicht gesetzt (Umgebungsvariable erforderlich)");
}
session = createSession({
coreBase,
onLoginRequired: () => redirectToLogin(process.env.NEXT_PUBLIC_LOGIN_URL),
});
}
return session;
}
export function resetSessionForTests(): void {
session = undefined;
}
+15
View File
@@ -0,0 +1,15 @@
import { describe, expect, it } from "vitest";
import { loadModuleRegistry } from "./modules";
describe("loadModuleRegistry", () => {
it("liest die Modul-Liste aus der Konfigurationsdatei, nicht aus Code", () => {
const registry = loadModuleRegistry();
expect(registry.length).toBeGreaterThan(0);
for (const mod of registry) {
expect(mod.key).toBeTruthy();
expect(mod.label).toBeTruthy();
expect(mod.href).toMatch(/^https?:\/\//);
expect(mod.permission).toContain(".");
}
});
});
+16
View File
@@ -0,0 +1,16 @@
import registry from "../config/modules.json";
export interface ModuleEntry {
key: string;
label: string;
description: string;
href: string;
permission: string;
}
// Konfigurationsdatei statt hartcodierter Liste (Akzeptanzkriterium 3):
// ein neues Modul kommt per Eintrag in config/modules.json hinzu, ohne
// dass dieser oder anderer Portal-Code sich aendert.
export function loadModuleRegistry(): ModuleEntry[] {
return registry as ModuleEntry[];
}
+5
View File
@@ -0,0 +1,5 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
transpilePackages: ["@nexarch/shl"],
};
export default nextConfig;
+3319
View File
File diff suppressed because it is too large. Load diff
+27
View File
@@ -0,0 +1,27 @@
{
"name": "nexarch-portal",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"test": "vitest run"
},
"dependencies": {
"@nexarch/shl": "file:../shl",
"next": "16.3.5",
"react": "19.3.0",
"react-dom": "19.3.0"
},
"devDependencies": {
"@testing-library/jest-dom": "^7.0.1",
"@testing-library/react": "16.3.3",
"@testing-library/user-event": "14.5.2",
"@types/node": "^22.20.4",
"@types/react": "19.3.0",
"@types/react-dom": "19.3.0",
"jsdom": "24.1.0",
"typescript": "5.5.3",
"vitest": "^5.0.1"
}
}
+21
View File
@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": false,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [{ "name": "next" }],
"paths": { "@/*": ["./*"] }
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", ".next/dev/types/**/*.ts"],
"exclude": ["node_modules"]
}
+15
View File
@@ -0,0 +1,15 @@
import path from "node:path";
import { defineConfig } from "vitest/config";
export default defineConfig({
resolve: {
alias: {
react: path.resolve(__dirname, "node_modules/react"),
"react-dom": path.resolve(__dirname, "node_modules/react-dom"),
},
},
test: {
environment: "jsdom",
setupFiles: ["./vitest.setup.ts"],
},
});
+5
View File
@@ -0,0 +1,5 @@
import "@testing-library/jest-dom/vitest";
import { afterEach } from "vitest";
import { cleanup } from "@testing-library/react";
afterEach(cleanup);