Richtlinien Phase 2a: verwaltete Register-Tabellen + Anwender-Handbuch (§7b, §9.7)
Vier neue, in die Bibliothek integrierte Dokumente (öffnen als interaktive Popups):
- Krypto-Register (VA-07): editierbare Tabelle der eingesetzten Verschlüsselung
mit Ablaufüberwachung (läuft-ab/abgelaufen-Markierung), Baseline-Bezug
(BL-CRY-…), voller CRUD (add/edit/delete).
- Risiko-Bewertungsmatrix (R03/VA-09): zentrale Pflegestelle mit FB-80-04-Defaults
— 4×4-Heatmap (Schadensausmaß × EW), Risikoklassen + Akzeptanzinstanz
(Niedrig≤3 / Mittel≤6 / Hoch≤9 / Kritisch≥12, editierbar), EW-Skala,
7 Schadenskategorien. (Rückkopplung in die 5×5-Heatmap der Risikoanalyse folgt.)
- Klassifizierungs-Handhabungsmatrix (R02/VA-08): AA-80-20-Defaults, 4 Schutzklassen
× 14 Aspekte mit eskalierenden Regeln; Zellen inline editierbar, Aspekt/Klasse
hinzufügbar.
- Anwender-Handbuch (§9.7): kuratierte Themen (Passwörter/MFA, Zugriffsrechte,
Klassifizierung, mobiles Arbeiten, E-Mail, Vorfälle) in verständlicher Sprache;
Werte via {{VARIABLE}}-Templating → automatisch synchron zur Baseline; Deep-Links
in die Quellabschnitte (z. B. R08 §4.1.2).
Zusätzlich: Richtlinien/Verfahren, die eine verwaltete Tabelle einbetten
(R09/VA-07, R03/VA-09, R02/VA-08), zeigen im Lesemodus einen „Vollständige Tabelle
öffnen"-Callout auf das jeweilige Register (§7b).
Datenmodell: CryptoEntry, ClassificationClass/HandlingAspect/HandlingRule,
RiskMatrixClass/RiskEwLevel/RiskDamageDimension, HandbookTopic (+ RLS).
Server-Actions mit RBAC (policy:write) + Audit-Log. Seed aus FB-80-04/AA-80-20.
Verifiziert im Browser: alle vier Register gerendert, Ablauf-/Farb-/Deep-Link-Logik,
Krypto-CRUD (add + delete) end-to-end.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -2,10 +2,11 @@ import Link from "next/link";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { requireSession } from "@/server/auth";
|
||||
import { dbForTenant } from "@/server/db";
|
||||
import { requirePermission } from "@/server/rbac";
|
||||
import { hasPermission, requirePermission } from "@/server/rbac";
|
||||
import { PageHead, Pill, Tag, KpiCard } from "@/components/mockup-ui";
|
||||
import { FilterTabs } from "@/components/filter-tabs";
|
||||
import { PolicyReadModal } from "@/components/policy-modals";
|
||||
import { PolicyRegisterModal } from "@/components/policy-registers";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -15,7 +16,7 @@ import {
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
|
||||
const DOC_TYPES = ["LEITLINIE", "RICHTLINIE", "VERFAHREN", "REGISTER"] as const;
|
||||
const DOC_TYPES = ["LEITLINIE", "RICHTLINIE", "VERFAHREN", "REGISTER", "HANDBUCH"] as const;
|
||||
const TYPE_LABEL: Record<string, string> = {
|
||||
LEITLINIE: "Leitlinie",
|
||||
RICHTLINIE: "Richtlinie",
|
||||
@@ -62,18 +63,46 @@ export default async function PoliciesPage({
|
||||
r.vaCodes.forEach((v) => e.vas.add(v));
|
||||
}
|
||||
|
||||
/* ── Modal (Lesemodus) ── */
|
||||
const modalDoc = params.doc ? docs.find((d) => d.code === params.doc) : null;
|
||||
const modalData = modalDoc
|
||||
? {
|
||||
doc: modalDoc,
|
||||
variables: await db.policyVariable.findMany({ orderBy: { orderIdx: "asc" } }),
|
||||
controls: [...(byPolicy.get(modalDoc.code)?.controls ?? [])].sort(),
|
||||
relatedVas: [...(byPolicy.get(modalDoc.code)?.vas ?? [])].sort(),
|
||||
}
|
||||
: null;
|
||||
|
||||
const isCoverage = params.view === "coverage";
|
||||
const canWrite = hasPermission(session, "policy:write");
|
||||
const backHref = isCoverage ? "/policies?view=coverage" : "/policies";
|
||||
|
||||
/* ── Modal: Lesemodus (Markdown) oder interaktives Register (§7b) ── */
|
||||
const modalDoc = params.doc ? docs.find((d) => d.code === params.doc) : null;
|
||||
const REGISTER_CODES = new Set(["CRYPTO", "RISKMATRIX", "CLASSIFICATION", "HANDBUCH"]);
|
||||
let modalNode: React.ReactNode = null;
|
||||
if (modalDoc && REGISTER_CODES.has(modalDoc.code)) {
|
||||
const registerData =
|
||||
modalDoc.code === "CRYPTO"
|
||||
? { crypto: await db.cryptoEntry.findMany({ orderBy: { orderIdx: "asc" } }) }
|
||||
: modalDoc.code === "CLASSIFICATION"
|
||||
? {
|
||||
classes: await db.classificationClass.findMany({ orderBy: { orderIdx: "asc" } }),
|
||||
aspects: await db.handlingAspect.findMany({ orderBy: { orderIdx: "asc" } }),
|
||||
rules: await db.handlingRule.findMany(),
|
||||
}
|
||||
: modalDoc.code === "RISKMATRIX"
|
||||
? {
|
||||
riskClasses: await db.riskMatrixClass.findMany({ orderBy: { orderIdx: "asc" } }),
|
||||
ewLevels: await db.riskEwLevel.findMany({ orderBy: { level: "asc" } }),
|
||||
damage: await db.riskDamageDimension.findMany({ orderBy: { orderIdx: "asc" } }),
|
||||
}
|
||||
: { handbook: await db.handbookTopic.findMany({ orderBy: { orderIdx: "asc" } }), variables: await db.policyVariable.findMany({ orderBy: { orderIdx: "asc" } }) };
|
||||
modalNode = <PolicyRegisterModal doc={modalDoc} data={registerData} canWrite={canWrite} backHref={backHref} />;
|
||||
} else if (modalDoc) {
|
||||
modalNode = (
|
||||
<PolicyReadModal
|
||||
data={{
|
||||
doc: modalDoc,
|
||||
variables: await db.policyVariable.findMany({ orderBy: { orderIdx: "asc" } }),
|
||||
controls: [...(byPolicy.get(modalDoc.code)?.controls ?? [])].sort(),
|
||||
relatedVas: [...(byPolicy.get(modalDoc.code)?.vas ?? [])].sort(),
|
||||
}}
|
||||
backHref={backHref}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const head = (
|
||||
<PageHead
|
||||
crumb={t("crumb")}
|
||||
@@ -146,7 +175,7 @@ export default async function PoliciesPage({
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
{modalData && <PolicyReadModal data={modalData} backHref="/policies?view=coverage" />}
|
||||
{modalNode}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -227,7 +256,7 @@ export default async function PoliciesPage({
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{modalData && <PolicyReadModal data={modalData} backHref="/policies" />}
|
||||
{modalNode}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -27,6 +27,16 @@ const STATUS_LABEL: Record<string, string> = {
|
||||
ARCHIVIERT: "Archiviert",
|
||||
};
|
||||
|
||||
// Dokumente, die eine verwaltete Register-Tabelle einbetten (§7b) → „Vollständige Tabelle öffnen"
|
||||
const EMBEDDED_REGISTER: Record<string, { code: string; label: string }> = {
|
||||
R09: { code: "CRYPTO", label: "Verschlüsselungsmechanismen-Register" },
|
||||
"VA-07": { code: "CRYPTO", label: "Verschlüsselungsmechanismen-Register" },
|
||||
R03: { code: "RISKMATRIX", label: "Risiko-Bewertungsmatrix" },
|
||||
"VA-09": { code: "RISKMATRIX", label: "Risiko-Bewertungsmatrix" },
|
||||
R02: { code: "CLASSIFICATION", label: "Klassifizierung & Handhabungsmatrix" },
|
||||
"VA-08": { code: "CLASSIFICATION", label: "Klassifizierung & Handhabungsmatrix" },
|
||||
};
|
||||
|
||||
export interface PolicyReadData {
|
||||
doc: PolicyDocument;
|
||||
variables: PolicyVariable[];
|
||||
@@ -102,6 +112,19 @@ export async function PolicyReadModal({ data, backHref }: { data: PolicyReadData
|
||||
</details>
|
||||
)}
|
||||
|
||||
{/* Eingebettete verwaltete Tabelle (§7b) */}
|
||||
{EMBEDDED_REGISTER[doc.code] && (
|
||||
<Link
|
||||
href={`/policies?doc=${EMBEDDED_REGISTER[doc.code].code}`}
|
||||
className="mb-4 flex items-center justify-between gap-2 rounded-xl border border-[var(--band-brd)] bg-[var(--band)] px-4 py-2.5 text-[12.5px] text-[var(--band-text)] hover:opacity-90"
|
||||
>
|
||||
<span>
|
||||
<b>Verwaltete Tabelle:</b> {EMBEDDED_REGISTER[doc.code].label}
|
||||
</span>
|
||||
<span className="font-semibold">{t("openFullTable")} →</span>
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{/* Gerendertes Dokument */}
|
||||
<article className="policy-prose" dangerouslySetInnerHTML={{ __html: bodyHtml }} />
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,401 @@
|
||||
import Link from "next/link";
|
||||
import { getFormatter, getTranslations } from "next-intl/server";
|
||||
import { Pencil, Plus, Trash2 } from "lucide-react";
|
||||
import type {
|
||||
ClassificationClass,
|
||||
CryptoEntry,
|
||||
HandbookTopic,
|
||||
HandlingAspect,
|
||||
HandlingRule,
|
||||
PolicyDocument,
|
||||
PolicyVariable,
|
||||
RiskDamageDimension,
|
||||
RiskEwLevel,
|
||||
RiskMatrixClass,
|
||||
} from "@prisma/client";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Modal } from "@/components/modal";
|
||||
import { Pill, Tag } from "@/components/mockup-ui";
|
||||
import { isExpired, isExpiring } from "@/lib/supplier";
|
||||
import { buildContext, renderPolicyHtml, resolveLink } from "@/lib/policy-render";
|
||||
import {
|
||||
addClassificationClass,
|
||||
addCryptoEntry,
|
||||
addHandlingAspect,
|
||||
deleteCryptoEntry,
|
||||
updateCryptoEntry,
|
||||
updateHandlingRule,
|
||||
updateRiskClass,
|
||||
} from "@/server/actions/policies";
|
||||
|
||||
const TONE_HEX: Record<string, string> = { ok: "#2ea86b", warn: "#e3b427", orange: "#e2802e", risk: "#d63c5e" };
|
||||
|
||||
export interface RegisterData {
|
||||
crypto?: CryptoEntry[];
|
||||
classes?: ClassificationClass[];
|
||||
aspects?: HandlingAspect[];
|
||||
rules?: HandlingRule[];
|
||||
riskClasses?: RiskMatrixClass[];
|
||||
ewLevels?: RiskEwLevel[];
|
||||
damage?: RiskDamageDimension[];
|
||||
handbook?: HandbookTopic[];
|
||||
variables?: PolicyVariable[];
|
||||
}
|
||||
|
||||
/** Popup für die verwalteten Register (§7b) und das Anwender-Handbuch (§9.7). */
|
||||
export async function PolicyRegisterModal({
|
||||
doc,
|
||||
data,
|
||||
canWrite,
|
||||
backHref,
|
||||
}: {
|
||||
doc: PolicyDocument;
|
||||
data: RegisterData;
|
||||
canWrite: boolean;
|
||||
backHref: string;
|
||||
}) {
|
||||
const t = await getTranslations("policies");
|
||||
return (
|
||||
<Modal
|
||||
title={`${doc.code} · ${doc.title}`}
|
||||
sub={doc.type === "HANDBUCH" ? "Handbuch" : "Register"}
|
||||
headerExtra={<Tag>{doc.type === "HANDBUCH" ? "Handbuch" : "Register"}</Tag>}
|
||||
closeHref={backHref}
|
||||
closeLabel={t("close")}
|
||||
footer={<Button nativeButton={false} render={<Link href={backHref} />}>{t("close")}</Button>}
|
||||
>
|
||||
<div className="max-h-[74vh] overflow-y-auto p-5">
|
||||
{doc.code === "CRYPTO" && <CryptoRegister entries={data.crypto ?? []} canWrite={canWrite} />}
|
||||
{doc.code === "CLASSIFICATION" && (
|
||||
<ClassificationMatrix classes={data.classes ?? []} aspects={data.aspects ?? []} rules={data.rules ?? []} canWrite={canWrite} />
|
||||
)}
|
||||
{doc.code === "RISKMATRIX" && (
|
||||
<RiskMatrix classes={data.riskClasses ?? []} ew={data.ewLevels ?? []} damage={data.damage ?? []} canWrite={canWrite} />
|
||||
)}
|
||||
{doc.code === "HANDBUCH" && <Handbook topics={data.handbook ?? []} variables={data.variables ?? []} />}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
/* ─────────────── Krypto-Register ─────────────── */
|
||||
|
||||
async function CryptoRegister({ entries, canWrite }: { entries: CryptoEntry[]; canWrite: boolean }) {
|
||||
const fmt = await getFormatter();
|
||||
const date = (d: Date | null) => (d ? fmt.dateTime(d, { dateStyle: "medium" }) : "—");
|
||||
const expiring = entries.filter((e) => isExpiring(e.ablaufdatum) || isExpired(e.ablaufdatum)).length;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-3 flex items-center justify-between gap-2">
|
||||
<p className="text-[12.5px] text-muted-foreground">
|
||||
Eingesetzte Verschlüsselung mit Ablaufüberwachung.{" "}
|
||||
{expiring > 0 && <span className="font-semibold text-[var(--warn)]">{expiring} laufen bald ab / abgelaufen.</span>}
|
||||
</p>
|
||||
{canWrite && (
|
||||
<details className="relative">
|
||||
<summary className="bg-grad-soft inline-flex h-8 cursor-pointer list-none items-center gap-1.5 rounded-lg px-2.5 font-heading text-sm font-semibold text-white select-none hover:opacity-90 [&::-webkit-details-marker]:hidden">
|
||||
<Plus className="size-4" /> Eintrag
|
||||
</summary>
|
||||
<form action={addCryptoEntry} className="shadow-card absolute right-0 z-10 mt-2 w-80 space-y-2 rounded-xl border bg-card p-3 text-sm">
|
||||
<Input name="dienst" required placeholder="Dienst" />
|
||||
<Input name="schluessel" required placeholder="Schlüssel" />
|
||||
<Input name="algorithmus" placeholder="Algorithmus / Länge" />
|
||||
<Input name="ablaufdatum" type="date" />
|
||||
<Input name="verantwortlich" placeholder="Verantwortlich" />
|
||||
<Input name="speicherort" placeholder="Speicherort" />
|
||||
<Input name="baselineRef" placeholder="Baseline (BL-CRY-…)" />
|
||||
<Button type="submit" variant="secondary" size="sm">Hinzufügen</Button>
|
||||
</form>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
<div className="overflow-x-auto rounded-xl border">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b bg-[var(--elevated)] text-[10.5px] tracking-[.04em] text-muted-foreground uppercase">
|
||||
<th className="p-2.5 text-left">Dienst</th>
|
||||
<th className="p-2.5 text-left">Schlüssel</th>
|
||||
<th className="p-2.5 text-left">Algorithmus</th>
|
||||
<th className="p-2.5 text-left">Ablauf</th>
|
||||
<th className="p-2.5 text-left">Verantwortlich</th>
|
||||
<th className="p-2.5 text-left">Baseline</th>
|
||||
{canWrite && <th className="p-2.5" />}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{entries.length === 0 && (
|
||||
<tr><td colSpan={7} className="p-6 text-center text-muted-foreground">Noch keine Einträge.</td></tr>
|
||||
)}
|
||||
{entries.map((e) => {
|
||||
const exp = isExpired(e.ablaufdatum);
|
||||
const soon = !exp && isExpiring(e.ablaufdatum);
|
||||
return (
|
||||
<tr key={e.id} className="border-b last:border-0">
|
||||
<td className="p-2.5 font-medium">{e.dienst}</td>
|
||||
<td className="p-2.5 text-muted-foreground">{e.schluessel}</td>
|
||||
<td className="p-2.5 text-muted-foreground">{e.algorithmus ?? "—"}</td>
|
||||
<td className="p-2.5">
|
||||
<span className={exp ? "font-semibold text-[var(--risk)]" : soon ? "font-semibold text-[var(--warn)]" : "text-muted-foreground"}>
|
||||
{date(e.ablaufdatum)}
|
||||
</span>
|
||||
{exp && <Pill tone="risk">abgelaufen</Pill>}
|
||||
{soon && <Pill tone="warn">läuft ab</Pill>}
|
||||
</td>
|
||||
<td className="p-2.5 text-muted-foreground">{e.verantwortlich}</td>
|
||||
<td className="p-2.5">{e.baselineRef ? <Pill tone="violet">{e.baselineRef}</Pill> : <span className="text-muted-foreground">—</span>}</td>
|
||||
{canWrite && (
|
||||
<td className="p-2.5">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<details className="relative">
|
||||
<summary className="grid size-7 cursor-pointer list-none place-items-center rounded-md text-muted-foreground hover:bg-muted [&::-webkit-details-marker]:hidden"><Pencil className="size-3.5" /></summary>
|
||||
<form action={updateCryptoEntry.bind(null, e.id)} className="shadow-card absolute right-0 z-10 mt-2 w-72 space-y-2 rounded-xl border bg-card p-3 text-sm">
|
||||
<Input name="dienst" required defaultValue={e.dienst} />
|
||||
<Input name="schluessel" required defaultValue={e.schluessel} />
|
||||
<Input name="algorithmus" defaultValue={e.algorithmus ?? ""} placeholder="Algorithmus" />
|
||||
<Input name="ablaufdatum" type="date" defaultValue={e.ablaufdatum ? e.ablaufdatum.toISOString().slice(0, 10) : ""} />
|
||||
<Input name="verantwortlich" defaultValue={e.verantwortlich} />
|
||||
<Input name="speicherort" defaultValue={e.speicherort ?? ""} placeholder="Speicherort" />
|
||||
<Input name="baselineRef" defaultValue={e.baselineRef ?? ""} placeholder="BL-CRY-…" />
|
||||
<Button type="submit" variant="secondary" size="sm">Speichern</Button>
|
||||
</form>
|
||||
</details>
|
||||
<form action={deleteCryptoEntry.bind(null, e.id)}>
|
||||
<button type="submit" title="Löschen" className="grid size-7 place-items-center rounded-md text-muted-foreground hover:text-destructive"><Trash2 className="size-3.5" /></button>
|
||||
</form>
|
||||
</div>
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ─────────────── Klassifizierungs-Handhabungsmatrix ─────────────── */
|
||||
|
||||
function ClassificationMatrix({
|
||||
classes, aspects, rules, canWrite,
|
||||
}: { classes: ClassificationClass[]; aspects: HandlingAspect[]; rules: HandlingRule[]; canWrite: boolean }) {
|
||||
const ruleOf = (classId: string, aspectId: string) => rules.find((r) => r.classId === classId && r.aspectId === aspectId)?.text ?? "";
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
|
||||
<p className="text-[12.5px] text-muted-foreground">Handhabungsregeln je Schutzklasse. Assets erben die Regeln aus ihrer Klasse.</p>
|
||||
{canWrite && (
|
||||
<div className="flex gap-2">
|
||||
<details className="relative">
|
||||
<summary className="inline-flex h-8 cursor-pointer list-none items-center gap-1.5 rounded-lg border px-2.5 text-[12.5px] font-semibold text-muted-foreground hover:text-foreground [&::-webkit-details-marker]:hidden"><Plus className="size-3.5" /> Aspekt</summary>
|
||||
<form action={addHandlingAspect} className="shadow-card absolute right-0 z-10 mt-2 w-72 space-y-2 rounded-xl border bg-card p-3 text-sm">
|
||||
<Input name="name" required placeholder="Aspekt (Zeile)" />
|
||||
<Input name="category" placeholder="Kategorie" />
|
||||
<Button type="submit" variant="secondary" size="sm">Hinzufügen</Button>
|
||||
</form>
|
||||
</details>
|
||||
<details className="relative">
|
||||
<summary className="inline-flex h-8 cursor-pointer list-none items-center gap-1.5 rounded-lg border px-2.5 text-[12.5px] font-semibold text-muted-foreground hover:text-foreground [&::-webkit-details-marker]:hidden"><Plus className="size-3.5" /> Schutzklasse</summary>
|
||||
<form action={addClassificationClass} className="shadow-card absolute right-0 z-10 mt-2 w-72 space-y-2 rounded-xl border bg-card p-3 text-sm">
|
||||
<Input name="name" required placeholder="Schutzklasse (Spalte)" />
|
||||
<Input name="description" placeholder="Beschreibung" />
|
||||
<Button type="submit" variant="secondary" size="sm">Hinzufügen</Button>
|
||||
</form>
|
||||
</details>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="overflow-x-auto rounded-xl border">
|
||||
<table className="w-full text-[12.5px]">
|
||||
<thead>
|
||||
<tr className="border-b bg-[var(--elevated)]">
|
||||
<th className="p-2.5 text-left text-[10.5px] tracking-[.04em] text-muted-foreground uppercase">Aspekt</th>
|
||||
{classes.map((c) => (
|
||||
<th key={c.id} className="min-w-40 p-2.5 text-left font-heading font-bold" title={c.description ?? ""}>{c.name}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{aspects.map((a) => (
|
||||
<tr key={a.id} className="border-b last:border-0 align-top">
|
||||
<td className="p-2.5 font-medium">{a.name}</td>
|
||||
{classes.map((c) => (
|
||||
<td key={c.id} className="p-1.5">
|
||||
{canWrite ? (
|
||||
<details className="group">
|
||||
<summary className="cursor-pointer list-none rounded-md px-2 py-1.5 hover:bg-muted/50 [&::-webkit-details-marker]:hidden">{ruleOf(c.id, a.id) || <span className="text-muted-foreground">—</span>}</summary>
|
||||
<form action={updateHandlingRule.bind(null, c.id, a.id)} className="mt-1 flex gap-1">
|
||||
<Input name="text" defaultValue={ruleOf(c.id, a.id)} className="h-8" />
|
||||
<Button type="submit" variant="secondary" size="sm">OK</Button>
|
||||
</form>
|
||||
</details>
|
||||
) : (
|
||||
<span className="px-2">{ruleOf(c.id, a.id) || "—"}</span>
|
||||
)}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ─────────────── Risiko-Bewertungsmatrix ─────────────── */
|
||||
|
||||
function RiskMatrix({
|
||||
classes, ew, damage, canWrite,
|
||||
}: { classes: RiskMatrixClass[]; ew: RiskEwLevel[]; damage: RiskDamageDimension[]; canWrite: boolean }) {
|
||||
const sorted = [...classes].sort((a, b) => a.maxScore - b.maxScore);
|
||||
const classFor = (score: number) => sorted.find((c) => score <= c.maxScore) ?? sorted[sorted.length - 1];
|
||||
const rows = [4, 3, 2, 1]; // Schadensausmaß (oben = 4)
|
||||
const cols = [1, 2, 3, 4]; // Eintrittswahrscheinlichkeit
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
{/* 4×4 Heatmap */}
|
||||
<section>
|
||||
<p className="mb-2 font-heading text-sm font-semibold">Bewertungsmatrix (Schadensausmaß × Eintrittswahrscheinlichkeit)</p>
|
||||
<div className="inline-block overflow-hidden rounded-xl border">
|
||||
<table className="text-center text-[12.5px]">
|
||||
<tbody>
|
||||
{rows.map((s) => (
|
||||
<tr key={s}>
|
||||
<th className="bg-[var(--elevated)] px-3 py-2 text-muted-foreground">S {s}</th>
|
||||
{cols.map((e) => {
|
||||
const score = s * e;
|
||||
const cls = classFor(score);
|
||||
return (
|
||||
<td key={e} className="px-4 py-2 font-bold text-white" style={{ background: TONE_HEX[cls?.tone] ?? "#555" }} title={`${cls?.name} (${score})`}>
|
||||
{score}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
))}
|
||||
<tr>
|
||||
<th className="bg-[var(--elevated)] px-3 py-2" />
|
||||
{cols.map((e) => (
|
||||
<th key={e} className="bg-[var(--elevated)] px-4 py-2 text-muted-foreground">EW {e}</th>
|
||||
))}
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Risikoklassen + Akzeptanzinstanz */}
|
||||
<section>
|
||||
<p className="mb-2 font-heading text-sm font-semibold">Risikoklassen & Akzeptanz</p>
|
||||
<div className="overflow-hidden rounded-xl border">
|
||||
<table className="w-full text-sm">
|
||||
<thead><tr className="border-b bg-[var(--elevated)] text-[10.5px] tracking-[.04em] text-muted-foreground uppercase">
|
||||
<th className="p-2.5 text-left">Klasse</th><th className="p-2.5 text-left">Bis Risikowert</th><th className="p-2.5 text-left">Akzeptanzinstanz</th>{canWrite && <th className="p-2.5" />}
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{sorted.map((c) => (
|
||||
<tr key={c.id} className="border-b last:border-0">
|
||||
<td className="p-2.5"><Pill tone={(c.tone as "ok" | "warn" | "orange" | "risk")}>{c.name}</Pill></td>
|
||||
<td className="p-2.5 font-medium">≤ {c.maxScore}</td>
|
||||
<td className="p-2.5 text-muted-foreground">{c.acceptance}</td>
|
||||
{canWrite && (
|
||||
<td className="p-2.5 text-right">
|
||||
<details className="relative">
|
||||
<summary className="grid size-7 cursor-pointer list-none place-items-center rounded-md text-muted-foreground hover:bg-muted [&::-webkit-details-marker]:hidden"><Pencil className="size-3.5" /></summary>
|
||||
<form action={updateRiskClass.bind(null, c.id)} className="shadow-card absolute right-0 z-10 mt-2 w-72 space-y-2 rounded-xl border bg-card p-3 text-sm">
|
||||
<Input name="name" required defaultValue={c.name} />
|
||||
<Input name="maxScore" type="number" required defaultValue={c.maxScore} placeholder="Bis Risikowert" />
|
||||
<Input name="acceptance" defaultValue={c.acceptance} placeholder="Akzeptanzinstanz" />
|
||||
<Button type="submit" variant="secondary" size="sm">Speichern</Button>
|
||||
</form>
|
||||
</details>
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Eintrittswahrscheinlichkeit */}
|
||||
<section>
|
||||
<p className="mb-2 font-heading text-sm font-semibold">Eintrittswahrscheinlichkeit</p>
|
||||
<ul className="space-y-1 text-[12.5px]">
|
||||
{ew.sort((a, b) => a.level - b.level).map((l) => (
|
||||
<li key={l.id}><b>{l.level} · {l.label}</b> <span className="text-muted-foreground">— {l.definition}</span></li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
{/* Schadenskategorien */}
|
||||
<section>
|
||||
<p className="mb-2 font-heading text-sm font-semibold">Schadenskategorien</p>
|
||||
<div className="overflow-x-auto rounded-xl border">
|
||||
<table className="w-full text-[12px]">
|
||||
<thead><tr className="border-b bg-[var(--elevated)] text-[10.5px] tracking-[.04em] text-muted-foreground uppercase">
|
||||
<th className="p-2.5 text-left">Dimension</th><th className="p-2.5 text-left">1 Niedrig</th><th className="p-2.5 text-left">2 Normal</th><th className="p-2.5 text-left">3 Hoch</th><th className="p-2.5 text-left">4 Sehr hoch</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{damage.map((d) => {
|
||||
const lv = d.levels as Record<string, string>;
|
||||
return (
|
||||
<tr key={d.id} className="border-b last:border-0 align-top">
|
||||
<td className="p-2.5 font-medium">{d.name}</td>
|
||||
<td className="p-2.5 text-muted-foreground">{lv["1"]}</td>
|
||||
<td className="p-2.5 text-muted-foreground">{lv["2"]}</td>
|
||||
<td className="p-2.5 text-muted-foreground">{lv["3"]}</td>
|
||||
<td className="p-2.5 text-muted-foreground">{lv["4"]}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ─────────────── Anwender-Handbuch ─────────────── */
|
||||
|
||||
function Handbook({ topics, variables }: { topics: HandbookTopic[]; variables: PolicyVariable[] }) {
|
||||
const ctx = buildContext(variables);
|
||||
const cats = [...new Set(topics.map((t) => t.category))];
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<p className="text-[12.5px] text-muted-foreground">
|
||||
Kompakte Regeln für Mitarbeitende. Werte (z. B. Passwortlänge) stammen aus der zentralen Baseline und bleiben automatisch synchron.
|
||||
</p>
|
||||
{cats.map((cat) => (
|
||||
<section key={cat}>
|
||||
<p className="mb-2 font-heading text-sm font-semibold text-[var(--band-text)]">{cat}</p>
|
||||
<div className="space-y-3">
|
||||
{topics.filter((t) => t.category === cat).map((topic) => (
|
||||
<div key={topic.id} className="rounded-xl border border-l-[3px] border-l-[var(--primary)] bg-[var(--surface-soft)] p-4">
|
||||
<p className="font-heading text-[14px] font-bold">{topic.title}</p>
|
||||
<div className="policy-prose mt-1" dangerouslySetInnerHTML={{ __html: renderPolicyHtml(topic.bodyMd, ctx, { readMode: false }) }} />
|
||||
{topic.sourceRefs.length > 0 && (
|
||||
<div className="mt-2 flex flex-wrap items-center gap-2 text-[12px] text-muted-foreground">
|
||||
Details:
|
||||
{topic.sourceRefs.map((ref) => {
|
||||
const { href, label } = resolveLink(ref);
|
||||
return (
|
||||
<Link key={ref} href={href} className="font-semibold text-[var(--info)] hover:underline">{label} →</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { z } from "zod";
|
||||
import { requireSession } from "@/server/auth";
|
||||
import { dbForTenant } from "@/server/db";
|
||||
import { requirePermission } from "@/server/rbac";
|
||||
import { writeAuditLog } from "@/server/audit";
|
||||
|
||||
const optDate = (v: FormDataEntryValue | null) => (v && String(v) ? new Date(String(v)) : null);
|
||||
const str = (v: FormDataEntryValue | null) => (v ? String(v).trim() : "");
|
||||
|
||||
async function guard() {
|
||||
const session = await requireSession();
|
||||
requirePermission(session, "policy:write");
|
||||
return { session, db: dbForTenant(session.user.tenantId) };
|
||||
}
|
||||
|
||||
/* ── Krypto-Register (VA-07) ── */
|
||||
|
||||
export async function addCryptoEntry(formData: FormData) {
|
||||
const { session, db } = await guard();
|
||||
const last = await db.cryptoEntry.aggregate({ _max: { orderIdx: true } });
|
||||
await db.cryptoEntry.create({
|
||||
data: {
|
||||
tenantId: session.user.tenantId,
|
||||
dienst: z.string().trim().min(1).parse(formData.get("dienst")),
|
||||
schluessel: z.string().trim().min(1).parse(formData.get("schluessel")),
|
||||
algorithmus: str(formData.get("algorithmus")) || null,
|
||||
ablaufdatum: optDate(formData.get("ablaufdatum")),
|
||||
verantwortlich: str(formData.get("verantwortlich")) || "—",
|
||||
speicherort: str(formData.get("speicherort")) || null,
|
||||
baselineRef: str(formData.get("baselineRef")) || null,
|
||||
orderIdx: (last._max.orderIdx ?? 0) + 1,
|
||||
},
|
||||
});
|
||||
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "create", entity: "crypto_entry" });
|
||||
revalidatePath("/policies");
|
||||
}
|
||||
|
||||
export async function updateCryptoEntry(id: string, formData: FormData) {
|
||||
const { session, db } = await guard();
|
||||
await db.cryptoEntry.update({
|
||||
where: { id },
|
||||
data: {
|
||||
dienst: z.string().trim().min(1).parse(formData.get("dienst")),
|
||||
schluessel: z.string().trim().min(1).parse(formData.get("schluessel")),
|
||||
algorithmus: str(formData.get("algorithmus")) || null,
|
||||
ablaufdatum: optDate(formData.get("ablaufdatum")),
|
||||
verantwortlich: str(formData.get("verantwortlich")) || "—",
|
||||
speicherort: str(formData.get("speicherort")) || null,
|
||||
baselineRef: str(formData.get("baselineRef")) || null,
|
||||
},
|
||||
});
|
||||
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "update", entity: "crypto_entry", entityId: id });
|
||||
revalidatePath("/policies");
|
||||
}
|
||||
|
||||
export async function deleteCryptoEntry(id: string) {
|
||||
const { session, db } = await guard();
|
||||
await db.cryptoEntry.delete({ where: { id } });
|
||||
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "delete", entity: "crypto_entry", entityId: id });
|
||||
revalidatePath("/policies");
|
||||
}
|
||||
|
||||
/* ── Klassifizierungs-Handhabungsmatrix (R02 / VA-08) ── */
|
||||
|
||||
export async function updateHandlingRule(classId: string, aspectId: string, formData: FormData) {
|
||||
const { session, db } = await guard();
|
||||
const text = str(formData.get("text"));
|
||||
await db.handlingRule.upsert({
|
||||
where: { classId_aspectId: { classId, aspectId } },
|
||||
update: { text },
|
||||
create: { tenantId: session.user.tenantId, classId, aspectId, text },
|
||||
});
|
||||
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "update", entity: "handling_rule" });
|
||||
revalidatePath("/policies");
|
||||
}
|
||||
|
||||
export async function addHandlingAspect(formData: FormData) {
|
||||
const { session, db } = await guard();
|
||||
const last = await db.handlingAspect.aggregate({ _max: { orderIdx: true } });
|
||||
await db.handlingAspect.create({
|
||||
data: {
|
||||
tenantId: session.user.tenantId,
|
||||
name: z.string().trim().min(1).parse(formData.get("name")),
|
||||
category: str(formData.get("category")) || null,
|
||||
orderIdx: (last._max.orderIdx ?? 0) + 1,
|
||||
},
|
||||
});
|
||||
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "create", entity: "handling_aspect" });
|
||||
revalidatePath("/policies");
|
||||
}
|
||||
|
||||
export async function addClassificationClass(formData: FormData) {
|
||||
const { session, db } = await guard();
|
||||
const last = await db.classificationClass.aggregate({ _max: { orderIdx: true } });
|
||||
await db.classificationClass.create({
|
||||
data: {
|
||||
tenantId: session.user.tenantId,
|
||||
name: z.string().trim().min(1).parse(formData.get("name")),
|
||||
description: str(formData.get("description")) || null,
|
||||
orderIdx: (last._max.orderIdx ?? 0) + 1,
|
||||
},
|
||||
});
|
||||
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "create", entity: "classification_class" });
|
||||
revalidatePath("/policies");
|
||||
}
|
||||
|
||||
/* ── Risiko-Bewertungsmatrix (R03 / VA-09) ── */
|
||||
|
||||
export async function updateRiskClass(id: string, formData: FormData) {
|
||||
const { session, db } = await guard();
|
||||
await db.riskMatrixClass.update({
|
||||
where: { id },
|
||||
data: {
|
||||
name: z.string().trim().min(1).parse(formData.get("name")),
|
||||
maxScore: z.coerce.number().int().min(1).max(999).parse(formData.get("maxScore")),
|
||||
acceptance: str(formData.get("acceptance")),
|
||||
},
|
||||
});
|
||||
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "update", entity: "risk_matrix_class", entityId: id });
|
||||
revalidatePath("/policies");
|
||||
}
|
||||
@@ -52,6 +52,14 @@ const TENANT_MODELS = new Set<string>([
|
||||
"PolicyVariable",
|
||||
"PolicyBaselineParam",
|
||||
"PolicyEvidence",
|
||||
"CryptoEntry",
|
||||
"ClassificationClass",
|
||||
"HandlingAspect",
|
||||
"HandlingRule",
|
||||
"RiskMatrixClass",
|
||||
"RiskEwLevel",
|
||||
"RiskDamageDimension",
|
||||
"HandbookTopic",
|
||||
]);
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user