Merge branch 'feature/shl-02-zentrales-portal-navigation-ueber-alle-module' of https://gitea.perlbach24.de/scripte/nexarch
This commit is contained in:
commit
2fbc5e387c
18 files changed
+3860
No files matched your search
@@ -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).
|
||||
@@ -0,0 +1,3 @@
|
||||
node_modules
|
||||
.next
|
||||
.env*
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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(".");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
transpilePackages: ["@nexarch/shl"],
|
||||
};
|
||||
export default nextConfig;
|
||||
Generated
+3319
File diff suppressed because it is too large.
Load diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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"],
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,5 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { afterEach } from "vitest";
|
||||
import { cleanup } from "@testing-library/react";
|
||||
|
||||
afterEach(cleanup);
|
||||
Reference in new issue
Block a user