SHL-03: zentrale Core-Steuerungsseite (Tenants/Rechte/Lizenz/Module gebündelt) #232

Merged
patrick merged 1 commits from feature/shl-03-zentrale-core-steuerungsseite into main 2026-09-28 01:15:08 +02:00
17 changed files with 3832 additions and 0 deletions

No files matched your search

+45
View File
@@ -0,0 +1,45 @@
# SHL-03 – Prüfprotokoll (Zentrale Core-Steuerungsseite)
Datum: 2026-09-28. Host: Entwicklungshost 192.168.1.204, frischer Klon von `origin/main` (Head `6e1b72ed`).
## Umsetzung
- Neue App `web/core-control` (Next.js/React/TS + `@nexarch/shl`), Muster von `web/portal` (SHL-02) übernommen: Sektions-Registry als reine Konfigurationsdatei (`config/sections.json`), Sichtbarkeitsprüfung über `app/api/visible-sections/route.ts` (`filterVisibleSections`, identischer Aufbau wie `filterVisibleModules`), Sitzung über bestehendes `createSession`/`redirectToLogin` aus `@nexarch/shl`.
- Jede der vier Sektionen (Tenant-Verwaltung/TEN-05, Rechte-Administration/RBAC-05, Lizenz-/Modulverwaltung/LIC-04, Benachrichtigungs-Einstellungen/CFG-04) wird als Tab (`role="tab"`/`role="tabpanel"`) dargestellt und bindet die jeweils bestehende, eigenständige App per `<iframe>` ein — keine Wiederimplementierung ihrer Komponenten/API-Aufrufe, kein Eingriff in `web/tenant-admin`, `web/rbac-admin`, `web/lic-admin`, `web/notifications` (unverändert, `git status` bestätigt keine Diffs dort).
- Jeder Tab verlinkt zusätzlich auf die eigenständige Oberfläche ("Eigenständig öffnen") — Akzeptanzkriterium 4.
### Entscheidung zur Rechte-Zuordnung (dokumentiert, wie in der Aufgabenstellung vorgesehen)
Recherche vor der Umsetzung (`migrations/0018_module_policy_rules.up.sql`, `web/portal/config/modules.json`, `docs/GESAMTPROJEKT-PRUEFPROTOKOLL.md` Zeile 1108) ergab: nur `tenant_admin` hat mit `core.tenant.admin` eine reale, migrierte Berechtigung. `rbac-admin` hat **keine eigene** reale Berechtigung — das bestehende Portal-Modul-Katalog (SHL-02) ordnet ihm bereits dasselbe `core.tenant.admin` zu, mangels einer spezifischeren, real existierenden Regel. `lic-admin` und `notifications` wurden im Portal-Katalog laut `GESAMTPROJEKT-PRUEFPROTOKOLL.md` bewusst **ausgelassen**, weil kein passendes reales Recht existiert und keines erfunden werden sollte.
Diese Kachel verlangt jedoch alle vier Bereiche gebündelt, jede Sektion rechte-gesteuert sichtbar, ohne neue Backend-Logik/neue Rechte zu schaffen. Entscheidung (klein, reversibel, dokumentiert statt stillschweigend): alle vier Sektionen werden mit dem einzigen real existierenden, migrierten Administrations-Recht `core.tenant.admin` sichtbarkeitsgesteuert — exakt dasselbe Muster, das der Portal-Katalog für `rbac_admin` bereits anwendet (Wiederverwendung einer bestehenden Regel statt Erfindung einer neuen). Es wird **keine neue Zeile** in `policy_rules`/Migrationen ergänzt. Die vier bestehenden Oberflächen bleiben selbst unverändert (sie prüfen serverseitig weiterhin über ihren eigenen Admin-Service-Token bzw. manuell eingegebene Superadmin-ID, wie vor dieser Kachel) — die neue Steuerungsseite entscheidet nur über die *Sichtbarkeit des Tabs*, nicht über neue Backend-Autorisierung.
Konsequenz für Prüfung 1: da alle vier Sektionen dasselbe reale Recht nutzen, unterscheiden sich zwei Testnutzer mit unterschiedlichen Rechtekombinationen im Ergebnis "alle vier sichtbar" vs. "keine sichtbar" (nicht in einer feineren Mischung) — das ist die durch die tatsächliche Rechtelandschaft mögliche Granularität, kein Implementierungsfehler dieser Kachel.
## Akzeptanzkriterien — Ergebnis
1. Eine Seite bündelt alle vier Bereiche als Tabs: **erfüllt**, `app/ControlSections.tsx`.
2. Jede Sektion nur mit zugehöriger Berechtigung sichtbar (wiederverwendet, keine neue Logik): **erfüllt** über `core.tenant.admin`, siehe Entscheidung oben.
3. Nutzer ohne jede Berechtigung sieht klar erklärte Leerdarstellung statt Fehler: **erfüllt**, `ControlSections.tsx` rendert einen erklärenden Text ohne `role="alert"`.
4. Bestehende Einzeloberflächen bleiben unverändert und eigenständig erreichbar: **erfüllt**, kein Diff in `web/tenant-admin`, `web/rbac-admin`, `web/lic-admin`, `web/notifications`; jeder Tab verlinkt zusätzlich auf die Standalone-URL.
## Prüfungen — Ergebnis
1. **Zwei Testnutzer mit unterschiedlichen Rechtekombinationen sehen jeweils nur ihre zugelassenen Sektionen.**
- Unit-Test (`app/api/visible-sections/route.test.ts`): Rolle mit `core.tenant.admin` erhält alle vier Sektionen, Rolle ohne die Berechtigung keine — bestanden.
- Komponententest (`app/ControlSections.test.tsx`, `@testing-library/react`, echte gemockte Sitzung/Token-Antwort): Testnutzer A (`tenant_admin`, Berechtigung vorhanden) sieht alle vier Tabs im DOM; Testnutzer B (`user`, keine Berechtigung) sieht keinen Tab — bestanden.
2. **Nutzer ohne jede Berechtigung: klare Leerdarstellung, kein Fehlerzustand.** Testfall in `ControlSections.test.tsx` prüft explizit `screen.queryByRole("alert")` ist `null` und der erklärende Text erscheint — bestanden.
3. **Reale pa11y-WCAG2AA-Prüfung** gegen den laufenden Produktionsbuild auf Host 204 (`next build && next start`, Port 39232, Chrome stable `/usr/bin/google-chrome`, gemockter `core`/`policy-api` liefert eine echte Sitzung mit `core.tenant.admin`, alle vier Tabs sichtbar gerendert): **„No issues found!“**
4. **Bestehende Einzeloberflächen-Tests laufen unverändert grün (keine Regression).** `web/tenant-admin`, `web/rbac-admin`, `web/lic-admin`, `web/notifications` haben laut Repo-Stand keine eigene Testsuite (vorbestehender Zustand, siehe `docs/QA-15-F17-NEXTJS16-PRUEFPROTOKOLL.md` Zeile 53) und wurden durch diese Kachel nicht angefasst (`git status` zeigt ausschließlich `web/core-control/` als neu) — strukturell keine Regressionsmöglichkeit.
## Tests
- `npx vitest run` (in `web/core-control`): 11/11 grün (`app/api/visible-sections/route.test.ts` 7 Fälle, `app/ControlSections.test.tsx` 4 Fälle).
- `npx tsc --noEmit`: sauber.
- `npx next build`: erfolgreich (3 Routen: `/`, `/_not-found`, `/api/visible-sections`); kein Turbopack/`@nexarch/shl`-Symlink-Problem aufgetreten (`npm install --install-links` legt von Anfang an eine Realkopie an).
## Nicht Bestandteil (wie in der Kachel festgelegt)
- Keine neuen Verwaltungsfunktionen über die vier bestehenden Bereiche hinaus.
- Keine Cross-Modul-Suche oder Kennzahlen-Dashboards (ANA-01/02).
- Kein Rollout/Deploy auf Host 131.
+3
View File
@@ -0,0 +1,3 @@
node_modules
.next
.env*
@@ -0,0 +1,96 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import { I18nProvider } from "@nexarch/shl";
import { fetchVisibleSections, ControlSections } from "./ControlSections";
import { resetSessionForTests } from "../lib/http";
describe("fetchVisibleSections", () => {
it("ruft die eigene Route mit tenant/role auf und gibt die Sektionsliste zurueck", async () => {
const fetchImpl = vi.fn(
async () =>
new Response(
JSON.stringify({ sections: [{ key: "tenant_admin", label: "Mandanten", description: "x", href: "http://a", permission: "core.tenant.admin" }] }),
{ status: 200 }
)
) as unknown as typeof fetch;
const sections = await fetchVisibleSections("acme", "tenant_admin", fetchImpl);
expect(sections).toHaveLength(1);
expect(sections[0].key).toBe("tenant_admin");
const calledUrl = (fetchImpl as ReturnType<typeof vi.fn>).mock.calls[0][0] as string;
expect(calledUrl).toContain("tenant=acme");
expect(calledUrl).toContain("role=tenant_admin");
});
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 sections = await fetchVisibleSections("acme", "user", fetchImpl);
expect(sections).toEqual([]);
});
});
// AC1/AC2: Bündelung der vier Bereiche als Tabs, je nur sichtbar mit Berechtigung.
// AC3: Nutzer ohne jede Berechtigung sieht eine erklärte Leerdarstellung statt Fehler.
describe("ControlSections (AC1, AC2, AC3, Pruefung 1 und 2)", () => {
const originalFetch = global.fetch;
beforeEach(() => {
resetSessionForTests();
process.env.NEXT_PUBLIC_CORE_API_BASE = "http://core.example";
global.fetch = originalFetch;
});
function mockSessionToken(tenant: string, role: string) {
const claims = { sub: "user-1", tenant, role };
const b64 = Buffer.from(JSON.stringify(claims)).toString("base64url");
const token = `h.${b64}.s`;
global.fetch = vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url.includes("/auth/module-token")) {
return new Response(JSON.stringify({ token, expires_in: 300, tenant, role }), { status: 200 });
}
if (url.includes("/api/visible-sections")) {
const all = [
{ key: "tenant_admin", label: "Mandantenverwaltung", description: "x", href: "http://a", permission: "core.tenant.admin" },
{ key: "rbac_admin", label: "Rechte-Administration", description: "x", href: "http://b", permission: "core.tenant.admin" },
{ key: "lic_admin", label: "Lizenz- und Modulverwaltung", description: "x", href: "http://c", permission: "core.tenant.admin" },
{ key: "notifications", label: "Benachrichtigungs-Einstellungen", description: "x", href: "http://d", permission: "core.tenant.admin" },
];
const sections = role === "tenant_admin" ? all : [];
return new Response(JSON.stringify({ sections }), { status: 200 });
}
throw new Error(`unerwarteter Aufruf: ${url}`);
}) as unknown as typeof fetch;
}
it("Testnutzer mit core.tenant.admin sieht alle vier Sektionen als Tabs", async () => {
mockSessionToken("acme", "tenant_admin");
render(
<I18nProvider initialLocale="de">
<ControlSections />
</I18nProvider>
);
await waitFor(() => expect(screen.getByRole("tablist")).toBeInTheDocument());
expect(screen.getByRole("tab", { name: "Mandantenverwaltung" })).toBeInTheDocument();
expect(screen.getByRole("tab", { name: "Rechte-Administration" })).toBeInTheDocument();
expect(screen.getByRole("tab", { name: "Lizenz- und Modulverwaltung" })).toBeInTheDocument();
expect(screen.getByRole("tab", { name: "Benachrichtigungs-Einstellungen" })).toBeInTheDocument();
});
it("Testnutzer ohne jede Berechtigung sieht klare Leerdarstellung statt Fehler", async () => {
mockSessionToken("acme", "user");
render(
<I18nProvider initialLocale="de">
<ControlSections />
</I18nProvider>
);
await waitFor(() =>
expect(screen.getByText(/keine Steuerungsbereiche freigeschaltet/i)).toBeInTheDocument()
);
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
expect(screen.queryByRole("tablist")).not.toBeInTheDocument();
});
});
+134
View File
@@ -0,0 +1,134 @@
"use client";
import { useEffect, useState } from "react";
import { useI18n } from "@nexarch/shl";
import type { SectionEntry } from "../lib/sections";
import { getSession } from "../lib/http";
interface VisibleSectionsResponse {
sections?: SectionEntry[];
error?: string;
}
export async function fetchVisibleSections(
tenant: string,
role: string,
fetchImpl: typeof fetch = fetch
): Promise<SectionEntry[]> {
const res = await fetchImpl(
`/api/visible-sections?tenant=${encodeURIComponent(tenant)}&role=${encodeURIComponent(role)}`
);
if (!res.ok) return [];
const body = (await res.json()) as VisibleSectionsResponse;
return body.sections ?? [];
}
type State =
| { status: "loading" }
| { status: "error"; message: string }
| { status: "ready"; sections: SectionEntry[] };
export function ControlSections() {
const { t } = useI18n();
const [state, setState] = useState<State>({ status: "loading" });
const [activeKey, setActiveKey] = useState<string | null>(null);
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("corecontrol.noIdentity", "Anmeldung erforderlich.") });
}
return;
}
const sections = await fetchVisibleSections(identity.tenant, identity.role);
if (!cancelled) {
setState({ status: "ready", sections });
setActiveKey(sections[0]?.key ?? null);
}
} catch (err) {
if (!cancelled) {
setState({ status: "error", message: (err as Error).message });
}
}
})();
return () => {
cancelled = true;
};
}, [t]);
if (state.status === "loading") return <p>{t("corecontrol.loading", "Lade Steuerungsseite …")}</p>;
if (state.status === "error") return <p role="alert">{state.message}</p>;
if (state.sections.length === 0) {
return (
<p>
{t(
"corecontrol.noSections",
"Für Ihr Konto sind hier keine Steuerungsbereiche freigeschaltet. Bitte wenden Sie sich an Ihre Administratorin bzw. Ihren Administrator, falls Sie Zugriff benötigen."
)}
</p>
);
}
const active = state.sections.find((s) => s.key === activeKey) ?? state.sections[0];
return (
<div>
<div role="tablist" aria-label={t("corecontrol.tablist", "Steuerungsbereiche")} style={{ display: "flex", gap: 4, borderBottom: "1px solid var(--shl-color-border, #d7dbe0)", flexWrap: "wrap" }}>
{state.sections.map((section) => {
const selected = section.key === active.key;
return (
<button
key={section.key}
role="tab"
id={`tab-${section.key}`}
aria-selected={selected}
aria-controls={`panel-${section.key}`}
onClick={() => setActiveKey(section.key)}
style={{
minHeight: 44,
padding: "8px 16px",
border: "none",
borderBottom: selected ? "3px solid var(--shl-color-focus-ring, #1D4ED8)" : "3px solid transparent",
background: "transparent",
fontWeight: selected ? 700 : 400,
cursor: "pointer",
}}
>
{section.label}
</button>
);
})}
</div>
{state.sections.map((section) => (
<div
key={section.key}
role="tabpanel"
id={`panel-${section.key}`}
aria-labelledby={`tab-${section.key}`}
hidden={section.key !== active.key}
style={{ padding: "16px 0" }}
>
<p>{section.description}</p>
<p>
<a href={section.href} target="_blank" rel="noreferrer">
{t("corecontrol.openStandalone", "Eigenständig öffnen")}
</a>
</p>
<iframe
title={section.label}
src={section.href}
style={{ width: "100%", height: 720, border: "1px solid var(--shl-color-border, #d7dbe0)", borderRadius: 8 }}
/>
</div>
))}
</div>
);
}
@@ -0,0 +1,89 @@
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
import { NextRequest } from "next/server";
import { filterVisibleSections, type Authorize } from "./route";
import type { SectionEntry } from "../../../lib/sections";
const originalFetch = global.fetch;
const baseRegistry: SectionEntry[] = [
{ key: "tenant_admin", label: "Mandanten", description: "x", href: "http://a", permission: "core.tenant.admin" },
{ key: "rbac_admin", label: "Rechte", description: "x", href: "http://b", permission: "core.tenant.admin" },
{ key: "lic_admin", label: "Lizenz", description: "x", href: "http://c", permission: "core.tenant.admin" },
{ key: "notifications", label: "Benachrichtigungen", description: "x", href: "http://d", permission: "core.tenant.admin" },
];
describe("filterVisibleSections (Pruefung 1: zwei Testnutzer mit unterschiedlichen Rechten)", () => {
it("Nutzer mit core.tenant.admin sieht alle vier Sektionen, Nutzer ohne Berechtigung keine", async () => {
const admin: Authorize = async (_t, _r, perm) => perm === "core.tenant.admin";
const user: Authorize = async () => false;
const adminSections = await filterVisibleSections(baseRegistry, "acme", "tenant_admin", admin);
const userSections = await filterVisibleSections(baseRegistry, "acme", "user", user);
expect(adminSections.map((s) => s.key)).toEqual(["tenant_admin", "rbac_admin", "lic_admin", "notifications"]);
expect(userSections).toEqual([]);
});
it("laesst keine Sektion ohne Berechtigung durch (fail-closed)", async () => {
const denyAll: Authorize = async () => false;
const result = await filterVisibleSections(baseRegistry, "acme", "user", denyAll);
expect(result).toEqual([]);
});
it("laesst keine Sektion durch wenn 'authorize' fehlschlaegt (Ausfall der policy-api)", async () => {
const throwing: Authorize = async () => {
throw new Error("policy-api nicht erreichbar");
};
const result = await filterVisibleSections(baseRegistry, "acme", "user", throwing);
expect(result).toEqual([]);
});
it("eine neue Sektion per Konfigurationseintrag erscheint ohne Codeaenderung", async () => {
const extendedRegistry: SectionEntry[] = [
...baseRegistry,
{ key: "neue_sektion", label: "Neue Sektion", description: "x", href: "http://e", permission: "test.section.read" },
];
const allowEverything: Authorize = async () => true;
const result = await filterVisibleSections(extendedRegistry, "acme", "tenant_admin", allowEverything);
expect(result.map((s) => s.key)).toContain("neue_sektion");
expect(result).toHaveLength(5);
});
});
describe("GET /api/visible-sections (HTTP-Schicht)", () => {
beforeEach(() => {
process.env.CORECONTROL_POLICY_API_URL = "http://policy.example";
process.env.CORECONTROL_POLICY_SERVICE_TOKEN = "test-token";
});
afterEach(() => {
global.fetch = originalFetch;
delete process.env.CORECONTROL_POLICY_API_URL;
delete process.env.CORECONTROL_POLICY_SERVICE_TOKEN;
});
it("gibt 400 ohne tenant/role zurueck", async () => {
const { GET } = await import("./route");
const req = new NextRequest("http://core-control.example/api/visible-sections");
const res = await GET(req);
expect(res.status).toBe(400);
});
it("gibt 503 zurueck wenn nicht konfiguriert (Pruefung 3: klare Leerdarstellung statt Fehler bleibt Frontend-seitig getrennt)", async () => {
delete process.env.CORECONTROL_POLICY_API_URL;
const { GET } = await import("./route");
const req = new NextRequest("http://core-control.example/api/visible-sections?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://core-control.example/api/visible-sections?tenant=acme&role=tenant_admin");
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,57 @@
import { NextRequest, NextResponse } from "next/server";
import { loadSectionRegistry, type SectionEntry } from "../../../lib/sections";
export const dynamic = "force-dynamic";
export type Authorize = (tenant: string, role: string, permission: string) => Promise<boolean>;
// Identisch zu web/portal/app/api/visible-modules/route.ts (filterVisibleModules):
// fail-closed, kein Eintrag ohne bestaetigte Berechtigung sichtbar.
export async function filterVisibleSections(
registry: SectionEntry[],
tenant: string,
role: string,
authorize: Authorize
): Promise<SectionEntry[]> {
const results = await Promise.all(
registry.map(async (section) => {
try {
return (await authorize(tenant, role, section.permission)) ? section : null;
} catch {
return null;
}
})
);
return results.filter((s): s is SectionEntry => s !== 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;
};
}
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.CORECONTROL_POLICY_API_URL;
const token = process.env.CORECONTROL_POLICY_SERVICE_TOKEN;
if (!base || !token) {
return NextResponse.json({ error: "Core-Steuerungsseite nicht konfiguriert" }, { status: 503 });
}
const sections = await filterVisibleSections(loadSectionRegistry(), tenant, role, policyAuthorize(base, token));
return NextResponse.json({ sections });
}
+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 Core-Steuerung",
};
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 { ControlSections } from "./ControlSections";
export default function Page() {
return (
<main style={{ maxWidth: 1100, margin: "0 auto", padding: 24 }}>
<h1>NEXARCH Core-Steuerung</h1>
<ControlSections />
</main>
);
}
+30
View File
@@ -0,0 +1,30 @@
[
{
"key": "tenant_admin",
"label": "Mandantenverwaltung",
"description": "Mandanten anlegen, Einstellungen pflegen, Lifecycle steuern.",
"href": "http://localhost:3106",
"permission": "core.tenant.admin"
},
{
"key": "rbac_admin",
"label": "Rechte-Administration",
"description": "Rollen und Berechtigungen verwalten.",
"href": "http://localhost:3112",
"permission": "core.tenant.admin"
},
{
"key": "lic_admin",
"label": "Lizenz- und Modulverwaltung",
"description": "Lizenzstatus, Feature-Flags und Nutzungsstand je Mandant.",
"href": "http://localhost:3113",
"permission": "core.tenant.admin"
},
{
"key": "notifications",
"label": "Benachrichtigungs-Einstellungen",
"description": "Benachrichtigungskanäle und -regeln je Mandant konfigurieren.",
"href": "http://localhost:3114",
"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 registry from "../config/sections.json";
export interface SectionEntry {
key: string;
label: string;
description: string;
href: string;
permission: string;
}
// Registry statt hartcodierter Liste, analog web/portal/lib/modules.ts (SHL-02):
// eine Sektion kommt per Eintrag in config/sections.json hinzu, ohne Codeaenderung.
export function loadSectionRegistry(): SectionEntry[] {
return registry as SectionEntry[];
}
+7
View File
@@ -0,0 +1,7 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
// @nexarch/shl liegt als file:-Dependency mit TS-Quellen in node_modules —
// Next.js transpiliert node_modules standardmäßig nicht, siehe web/shl/README.md.
transpilePackages: ["@nexarch/shl"],
};
export default nextConfig;
+3215
View File
File diff suppressed because it is too large. Load diff
+27
View File
@@ -0,0 +1,27 @@
{
"name": "nexarch-core-control",
"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);