SHL-03: zentrale Core-Steuerungsseite (Tenants/Rechte/Lizenz/Module gebündelt) #232
No files matched your search
@@ -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.
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
@@ -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 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[];
|
||||
}
|
||||
@@ -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;
|
||||
Generated
+3215
File diff suppressed because it is too large.
Load diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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