From 36d1cf5e7f80c5de54e4c8a1e64164d572af9f28 Mon Sep 17 00:00:00 2001 From: sysops Date: Sun, 30 Aug 2026 08:49:24 +0200 Subject: [PATCH] =?UTF-8?q?RET-06:=20aufbewahrungsfristen-konfigurationsob?= =?UTF-8?q?erfl=C3=A4che?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - web/retention-admin: eigenstaendige Next.js/React/TypeScript-App (kein Backend-Annex), auf web/shl (SHL-01) aufbauend - classes: Aufbewahrungsklasse anlegen/aendern/deaktivieren (AC1) - preview: Vorschauliste 30 Tage, nutzt denselben Endpunkt wie der periodische Job (AC2) - lib/api.ts: ForbiddenError bei 403, getrennt behandelt, keine eigene Autorisierungslogik im Frontend (RBAC-Entscheidung liegt bei RET-08/RBAC-06) - expliziter 403-Nachweis (nicht nur der 200-Fall): lib/api.test.ts, ClassesPage/PreviewPage zeigen 'Zugriff verweigert' statt leerer Seite - lokal getestet: tsc clean, next build clean, vitest 3/3 gruen Pruefungen siehe archive/docs/RET-06-PRUEFPROTOKOLL.md --- archive/docs/RET-06-PRUEFPROTOKOLL.md | 58 ++++++++++ web/retention-admin/app/classes/page.tsx | 136 +++++++++++++++++++++++ web/retention-admin/app/layout.tsx | 30 +++++ web/retention-admin/app/page.tsx | 5 + web/retention-admin/app/preview/page.tsx | 58 ++++++++++ web/retention-admin/lib/api.test.ts | 51 +++++++++ web/retention-admin/lib/api.ts | 81 ++++++++++++++ web/retention-admin/next.config.mjs | 5 + web/retention-admin/package.json | 26 +++++ web/retention-admin/tsconfig.json | 21 ++++ web/retention-admin/vitest.config.ts | 7 ++ 11 files changed, 478 insertions(+) create mode 100644 archive/docs/RET-06-PRUEFPROTOKOLL.md create mode 100644 web/retention-admin/app/classes/page.tsx create mode 100644 web/retention-admin/app/layout.tsx create mode 100644 web/retention-admin/app/page.tsx create mode 100644 web/retention-admin/app/preview/page.tsx create mode 100644 web/retention-admin/lib/api.test.ts create mode 100644 web/retention-admin/lib/api.ts create mode 100644 web/retention-admin/next.config.mjs create mode 100644 web/retention-admin/package.json create mode 100644 web/retention-admin/tsconfig.json create mode 100644 web/retention-admin/vitest.config.ts diff --git a/archive/docs/RET-06-PRUEFPROTOKOLL.md b/archive/docs/RET-06-PRUEFPROTOKOLL.md new file mode 100644 index 0000000..f6b3aae --- /dev/null +++ b/archive/docs/RET-06-PRUEFPROTOKOLL.md @@ -0,0 +1,58 @@ +# RET-06 – Prüfprotokoll: Aufbewahrungsfristen-Konfigurationsoberfläche + +Voraussetzung RET-02, RET-06-API, RET-08 (RBAC-Migration) – alle +bereits Fertig. RET-08 hat das Header-Provisorium in RET-06-API bereits +durch einen echten Aufruf von Core RBAC-06 ersetzt – dieses Ticket +testet daher von Anfang an gegen echte RBAC-Autorisierung, nicht gegen +ein Provisorium (siehe RET-08-Prüfprotokoll). + +## Umsetzung + +- `web/retention-admin` – eigenständige Next.js/React/TypeScript-App + (kein `/admin`-Annex im Go-Backend), analog zu `web/notifications` + (CFG-04) und `web/lic-admin` (LIC-04), aufbauend auf `web/shl` + (SHL-01, gemeinsames Design-System). +- `lib/api.ts` – dünner Client für RET-06-API. Reicht die vom Nutzer + beanspruchte Rolle über `X-User-Role` durch (RET-08 prüft sie gegen + RBAC-06), trifft selbst keine Autorisierungsentscheidung. Wirft + `ForbiddenError` bei HTTP 403, getrennt von generischen `ApiError`s. +- `app/classes/page.tsx` – Akzeptanzkriterium 1: Aufbewahrungsklasse + anlegen/ändern (ein Formular, Backend-UPSERT) und deaktivieren. + Akzeptanzkriterium 3: `ForbiddenError` führt zu einer expliziten + "Zugriff verweigert"-Anzeige, nicht zu einer leeren Tabelle. +- `app/preview/page.tsx` – Akzeptanzkriterium 2: Vorschauliste über + `/retention-classes/preview`, dieselbe Funktion wie der periodische + Job (RET-02/RET-06-API), keine eigene Berechnung im Frontend. + +## Prüfungen + +| # | Prüfung | Ergebnis | +|---|---|---| +| 1 | Änderung einer Frist wirkt nur auf künftige Berechnungen, nicht rückwirkend | **bestanden (Backend-seitig bereits durch RET-02/RET-06-API bewiesen)** – Frontend ruft ausschließlich den bestehenden UPSERT-Endpunkt auf, keine eigene Berechnungslogik im Frontend, die dies unterlaufen könnte | +| 2 | Nicht berechtigte Rolle erhält keinen Zugriff auf die Konfiguration | **bestanden – expliziter 403-Nachweis, nicht nur der 200-Fall:** `lib/api.test.ts`, `describe("api client - 403-Nachweis (keine berechtigte Rolle)")` – zwei Tests (`fetchClassRules`, `configureClassRule`) mit real gemockter 403-HTTP-Antwort, beide werfen `ForbiddenError`; `ClassesPage`/`PreviewPage` fangen `ForbiddenError` ab und zeigen `role="alert"` "Zugriff verweigert" statt einer leeren/stillen Seite. Der 403-Vertrag selbst (RET-06-API antwortet real mit 403 bei fehlender RBAC-06-Berechtigung) ist bereits in RET-08 end-zu-ende gegen den laufenden Dienst auf 131 bewiesen (`curl` ohne Policy-Rule → 403) – dieses Ticket prüft, dass das Frontend diesen real existierenden Vertrag korrekt behandelt, nicht das Backend erneut | +| 3 | Vorschauliste stimmt mit dem Ergebnis des periodischen Jobs überein | **bestanden (Backend-seitig bereits durch RET-06-API bewiesen)** – `PreviewPage` ruft exakt denselben `/retention-classes/preview`-Endpunkt auf, der intern `retentionengine.ListExpiringObjects` verwendet (identische Funktion wie der periodische Job), keine zweite Implementierung im Frontend | + +## Build/Test-Ergebnis (lokal, node v22.16.0/npm 10.9.2 – bereits installiert) + +``` +npx tsc --noEmit -> clean (eigener Code; siehe Hinweis) +npx next build -> Compiled successfully, 3 Routen (/, /classes, /preview) +npx vitest run -> 3/3 Tests bestanden +``` + +**Hinweis:** `web/shl` (SHL-01, bereits Fertig) hatte kein eigenes +`node_modules` im Checkout – ohne `npm install` dort lieferte `tsc` +kaskadierende "Cannot find module 'react'"-Fehler in shl-eigenen +Dateien, nicht durch RET-06 verursacht. Für den lokalen Testlauf wurde +`npm install` in `web/shl` ausgeführt (kein Code-Umbau, nur Abhängigkeiten +installiert); `web/shl/package-lock.json` wurde dadurch neu erzeugt, +aber bewusst NICHT mitcommittet (gehört zu SHL-01, nicht zu diesem +Ticket – kein Umbau angrenzender Bereiche). + +## Gesamtergebnis + +**Bestanden.** Alle drei Akzeptanzkriterien real erfüllt, insbesondere +der explizit geforderte negative 403-Fall (nicht nur der Erfolgsfall) +sowie die eigenständige Next.js-App-Struktur (kein Backend-Annex). +Frontend testet von Anfang an gegen die durch RET-08 real hergestellte +RBAC-06-Autorisierung, nicht gegen ein Provisorium. diff --git a/web/retention-admin/app/classes/page.tsx b/web/retention-admin/app/classes/page.tsx new file mode 100644 index 0000000..3d99d52 --- /dev/null +++ b/web/retention-admin/app/classes/page.tsx @@ -0,0 +1,136 @@ +"use client"; + +import { useEffect, useState, type ChangeEvent, type FormEvent } from "react"; +import { Table, TextField, useToast, type TableColumn } from "@nexarch/shl"; +import { + ApiError, + ClassRule, + ForbiddenError, + configureClassRule, + deactivateClassRule, + fetchClassRules, +} from "../../lib/api"; + +export default function ClassesPage() { + const { push } = useToast(); + const [rules, setRules] = useState(null); + const [forbidden, setForbidden] = useState(false); + const [loadError, setLoadError] = useState(null); + const [newClass, setNewClass] = useState(""); + const [newDuration, setNewDuration] = useState(""); + const [saving, setSaving] = useState(false); + + function load() { + fetchClassRules() + .then((r) => { + setRules(r); + setForbidden(false); + }) + .catch((err) => { + if (err instanceof ForbiddenError) { + setForbidden(true); + } else { + setLoadError(err instanceof Error ? err.message : "Aufbewahrungsklassen konnten nicht geladen werden."); + } + }); + } + + useEffect(load, []); + + // Akzeptanzkriterium 3: nicht berechtigte Rolle sieht explizit den + // Zugriff-verweigert-Zustand, keine leere Tabelle ohne Erklaerung. + if (forbidden) { + return ( +
+

Zugriff verweigert: Ihre Rolle ist nicht zur Fristenverwaltung berechtigt.

+
+ ); + } + if (loadError) { + return ( +
+

{loadError}

+
+ ); + } + + async function onCreateOrChange(e: FormEvent) { + e.preventDefault(); + setSaving(true); + try { + // Akzeptanzkriterium 1: gleicher Endpunkt legt an ODER aendert + // (UPSERT im Backend, retentionengine.ConfigureClassRule). + await configureClassRule(newClass, newDuration); + push(`Aufbewahrungsklasse "${newClass}" gespeichert.`, "success"); + setNewClass(""); + setNewDuration(""); + load(); + } catch (err) { + if (err instanceof ForbiddenError) { + setForbidden(true); + } else { + push(err instanceof ApiError ? err.message : "Speichern fehlgeschlagen.", "danger"); + } + } finally { + setSaving(false); + } + } + + async function onDeactivate(retentionClass: string) { + try { + await deactivateClassRule(retentionClass); + push(`Aufbewahrungsklasse "${retentionClass}" deaktiviert.`, "success"); + load(); + } catch (err) { + if (err instanceof ForbiddenError) { + setForbidden(true); + } else { + push(err instanceof ApiError ? err.message : "Deaktivieren fehlgeschlagen.", "danger"); + } + } + } + + const columns: TableColumn[] = [ + { key: "class", header: "Aufbewahrungsklasse", render: (r) => r.RetentionClass }, + { key: "duration", header: "Frist", render: (r) => r.Duration }, + { key: "active", header: "Status", render: (r) => (r.Active ? "aktiv" : "deaktiviert") }, + { + key: "actions", + header: "Aktion", + render: (r) => + r.Active ? ( + + ) : null, + }, + ]; + + return ( +
+

Aufbewahrungsfristen-Konfiguration

+ +
+ ) => setNewClass(e.target.value)} + required + /> + ) => setNewDuration(e.target.value)} + required + /> + + + + {rules && ( + r.RetentionClass} caption="Konfigurierte Aufbewahrungsklassen" /> + )} + + ); +} diff --git a/web/retention-admin/app/layout.tsx b/web/retention-admin/app/layout.tsx new file mode 100644 index 0000000..cb3c93b --- /dev/null +++ b/web/retention-admin/app/layout.tsx @@ -0,0 +1,30 @@ +import { ThemeProvider, I18nProvider, ToastProvider, typography } from "@nexarch/shl"; + +export const metadata = { + title: "NEXARCH Aufbewahrungsfristen", +}; + +export default function RootLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( + + + + + {children} + + + + + ); +} diff --git a/web/retention-admin/app/page.tsx b/web/retention-admin/app/page.tsx new file mode 100644 index 0000000..f4446c1 --- /dev/null +++ b/web/retention-admin/app/page.tsx @@ -0,0 +1,5 @@ +import { redirect } from "next/navigation"; + +export default function IndexPage() { + redirect("/classes"); +} diff --git a/web/retention-admin/app/preview/page.tsx b/web/retention-admin/app/preview/page.tsx new file mode 100644 index 0000000..0ccbae6 --- /dev/null +++ b/web/retention-admin/app/preview/page.tsx @@ -0,0 +1,58 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { Table, type TableColumn } from "@nexarch/shl"; +import { ExpiringObject, ForbiddenError, fetchExpiringPreview } from "../../lib/api"; + +// Akzeptanzkriterium 2: Vorschauliste bald ablaufender Objekte (naechste 30 +// Tage) - ruft dieselbe RET-06-API /retention-classes/preview auf, die +// intern dieselbe Funktion wie der periodische Job nutzt (RET-02), keine +// eigene Berechnung im Frontend. +export default function PreviewPage() { + const [objects, setObjects] = useState(null); + const [forbidden, setForbidden] = useState(false); + const [loadError, setLoadError] = useState(null); + + useEffect(() => { + fetchExpiringPreview() + .then(setObjects) + .catch((err) => { + if (err instanceof ForbiddenError) { + setForbidden(true); + } else { + setLoadError(err instanceof Error ? err.message : "Vorschauliste konnte nicht geladen werden."); + } + }); + }, []); + + if (forbidden) { + return ( +
+

Zugriff verweigert: Ihre Rolle ist nicht zur Fristenverwaltung berechtigt.

+
+ ); + } + if (loadError) { + return ( +
+

{loadError}

+
+ ); + } + + const columns: TableColumn[] = [ + { key: "type", header: "Objekttyp", render: (o) => o.ObjectType }, + { key: "ref", header: "Referenz", render: (o) => o.ObjectReference }, + { key: "class", header: "Aufbewahrungsklasse", render: (o) => o.RetentionClass }, + { key: "due", header: "Fällig am", render: (o) => o.DueDate }, + ]; + + return ( +
+

In den nächsten 30 Tagen ablaufende Objekte

+ {objects && ( +
o.RetentionObjectID} caption="Bald ablaufende Objekte" /> + )} + + ); +} diff --git a/web/retention-admin/lib/api.test.ts b/web/retention-admin/lib/api.test.ts new file mode 100644 index 0000000..faf307c --- /dev/null +++ b/web/retention-admin/lib/api.test.ts @@ -0,0 +1,51 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { ForbiddenError, fetchClassRules, configureClassRule } from "./api"; + +const ORIGINAL_ENV = process.env; + +beforeEach(() => { + process.env = { ...ORIGINAL_ENV, NEXT_PUBLIC_RETENTION_API_URL: "http://backend.test" }; +}); + +afterEach(() => { + process.env = ORIGINAL_ENV; + vi.restoreAllMocks(); +}); + +// Akzeptanzkriterium 3 ("nur berechtigten Rollen zugaenglich"): der +// Negativfall MUSS explizit gepruft werden, nicht nur der Erfolgsfall - +// Frontend wirft ForbiddenError, wenn RET-06-API (real gegen RBAC-06, +// siehe RET-08) mit 403 antwortet. +describe("api client - 403-Nachweis (keine berechtigte Rolle)", () => { + it("fetchClassRules wirft ForbiddenError bei 403-Antwort", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue(new Response(null, { status: 403 })) + ); + + await expect(fetchClassRules()).rejects.toBeInstanceOf(ForbiddenError); + }); + + it("configureClassRule wirft ForbiddenError bei 403-Antwort", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue(new Response(null, { status: 403 })) + ); + + await expect(configureClassRule("klasse-x", "1 year")).rejects.toBeInstanceOf(ForbiddenError); + }); +}); + +describe("api client - erlaubte Rolle", () => { + it("fetchClassRules liefert die Liste bei 200-Antwort", async () => { + const rules = [{ RetentionClass: "klasse-a", Duration: "5 years", Active: true }]; + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue( + new Response(JSON.stringify(rules), { status: 200, headers: { "Content-Type": "application/json" } }) + ) + ); + + await expect(fetchClassRules()).resolves.toEqual(rules); + }); +}); diff --git a/web/retention-admin/lib/api.ts b/web/retention-admin/lib/api.ts new file mode 100644 index 0000000..5087cc0 --- /dev/null +++ b/web/retention-admin/lib/api.ts @@ -0,0 +1,81 @@ +// Duenner Client fuer RET-06-API (archive/internal/retentionapi) — enthaelt +// keine eigene Fristen-/RBAC-Logik, nur Datenabruf und -weitergabe. Der +// RBAC-Check selbst passiert serverseitig ueber RET-08 (echter RBAC-06-Aufruf), +// dieser Client meldet nur, ob der Zugriff erlaubt war (403) oder nicht. + +export class ForbiddenError extends Error {} +export class ApiError extends Error {} + +function apiBase(): string { + const base = process.env.NEXT_PUBLIC_RETENTION_API_URL; + if (!base) { + throw new Error("NEXT_PUBLIC_RETENTION_API_URL ist nicht gesetzt (Umgebungsvariable erforderlich)"); + } + return base; +} + +// userRole wird bis zu einer zentralen Session-/Identitaets-Loesung im +// Frontend als Umgebungsvariable/Query mitgegeben und als X-User-Role-Header +// an RET-06-API durchgereicht, das ihn seinerseits gegen RBAC-06 prueft +// (RET-08) - das Frontend trifft selbst KEINE Autorisierungsentscheidung. +function userRoleHeader(): Record { + const role = process.env.NEXT_PUBLIC_USER_ROLE; + return role ? { "X-User-Role": role } : {}; +} + +async function req(path: string, init?: RequestInit): Promise { + const res = await fetch(`${apiBase()}${path}`, { + ...init, + headers: { + ...userRoleHeader(), + ...(init?.body ? { "Content-Type": "application/json" } : {}), + ...init?.headers, + }, + }); + if (res.status === 403) { + throw new ForbiddenError("Zugriff verweigert: Ihre Rolle ist nicht zur Fristenverwaltung berechtigt."); + } + if (!res.ok) { + const body = await res.json().catch(() => ({})); + throw new ApiError(body.error ?? `Anfrage fehlgeschlagen (${res.status})`); + } + if (res.status === 200 && res.headers.get("content-length") === "0") { + return undefined as T; + } + return res.json().catch(() => undefined as T); +} + +export interface ClassRule { + RetentionClass: string; + Duration: string; + Active: boolean; +} + +export interface ExpiringObject { + RetentionObjectID: string; + ObjectType: string; + ObjectReference: string; + RetentionClass: string; + DueDate: string; +} + +export function fetchClassRules(): Promise { + return req("/retention-classes"); +} + +export function configureClassRule(retentionClass: string, duration: string): Promise { + return req("/retention-classes", { + method: "POST", + body: JSON.stringify({ retention_class: retentionClass, duration }), + }); +} + +export function deactivateClassRule(retentionClass: string): Promise { + return req(`/retention-classes/${encodeURIComponent(retentionClass)}/deactivate`, { + method: "POST", + }); +} + +export function fetchExpiringPreview(): Promise { + return req("/retention-classes/preview"); +} diff --git a/web/retention-admin/next.config.mjs b/web/retention-admin/next.config.mjs new file mode 100644 index 0000000..aa96725 --- /dev/null +++ b/web/retention-admin/next.config.mjs @@ -0,0 +1,5 @@ +/** @type {import('next').NextConfig} */ +const nextConfig = { + transpilePackages: ["@nexarch/shl"], +}; +export default nextConfig; diff --git a/web/retention-admin/package.json b/web/retention-admin/package.json new file mode 100644 index 0000000..c11b4d6 --- /dev/null +++ b/web/retention-admin/package.json @@ -0,0 +1,26 @@ +{ + "name": "nexarch-retention-admin", + "private": true, + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start", + "lint": "next lint", + "test": "vitest run" + }, + "dependencies": { + "@nexarch/shl": "file:../shl", + "next": "14.2.35", + "react": "18.3.1", + "react-dom": "18.3.1" + }, + "devDependencies": { + "@testing-library/react": "16.0.0", + "@types/node": "20.14.9", + "@types/react": "18.3.3", + "@types/react-dom": "18.3.0", + "jsdom": "24.1.0", + "typescript": "5.5.3", + "vitest": "2.0.5" + } +} diff --git a/web/retention-admin/tsconfig.json b/web/retention-admin/tsconfig.json new file mode 100644 index 0000000..8b1f373 --- /dev/null +++ b/web/retention-admin/tsconfig.json @@ -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": "preserve", + "incremental": true, + "plugins": [{ "name": "next" }], + "paths": { "@/*": ["./*"] } + }, + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], + "exclude": ["node_modules"] +} diff --git a/web/retention-admin/vitest.config.ts b/web/retention-admin/vitest.config.ts new file mode 100644 index 0000000..9f6250a --- /dev/null +++ b/web/retention-admin/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + environment: "jsdom", + }, +});