Files
certvia/src/components/policy-registers.tsx
T
msolarczekandClaude Opus 4.8 eab16c861d Richtlinien-Update: 316 Anforderungen/45 Controls + Schutzbedarf-/TISAX-Schalter
Delta-Update des VDA-ISA-2027-Vorlagenpakets eingepflegt:

- Aktualisiertes Seed-Paket (ersetzt bisherigen Stand): 316 Anforderungen
  (122 MUSS · 132 SOLL · 43 HOCH · 19 SEHR HOCH) über 45 Controls; VA-01/VA-05
  jetzt enthalten (13 Verfahren vollständig). Beschädigte RACI-Tokens (VA-05/08/09/
  10/12/13) repariert. Importer: Umsetzungstext aus den .md-IMPL-Ankern extrahiert
  (mapping.json führt ihn nicht mehr), neue Obligation-Typen HOCH/SEHR HOCH.
- Neue Schutzbedarf-Flags (variables.schema): FLAG_HIGH_PROTECTION,
  FLAG_VERY_HIGH_PROTECTION, FLAG_ELEVATED_PROTECTION (abgeleitet). Render-Helper
  applyProtection: HIGH stets an, VERY_HIGH aus Global/Override, ELEVATED = HIGH||VH
  (nie manuell) — angewandt in Lese-, Bearbeiten-, Handbuch- und Coverage-Rendering.
- TISAX-Level-Schalter (AL2/AL3) zentral auf der Bibliothek (setGlobalTisaxLevel);
  AL2 = MUSS/SOLL/HOCH, AL3 = zusätzlich SEHR HOCH. Override je Richtlinie im
  Bearbeitungsmodus (setProtectionOverride, Feld protection_override); effektiver
  Wert = Dokument-Override sonst global.
- KPIs zeigen 316 Anforderungen mit Aufschlüsselung; Coverage/Badges für HOCH/SEHR
  HOCH; Control-Titel-Fallback.

Verifiziert: Import 316/45; Rendering rückstandsfrei über AL2/AL3 × Flag-Kombis;
Override R04→AL3 zeigt SEHR-HOCH-Inhalt, R02 (global AL2) nicht; global bleibt AL2.

Architektur-Hinweis: applyProtection kapselt das Level→Flags-Mapping, sodass die
globale Ebene später ohne Umbau zur TISAX-AL2/AL3-Auswahl wird (bereits so gebaut).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 11:23:07 +02:00

412 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import Link from "next/link";
import { getFormatter } 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 { Pill } from "@/components/mockup-ui";
import { isExpired, isExpiring } from "@/lib/supplier";
import { applyProtection, buildContext, renderPolicyHtml, resolveLink } from "@/lib/policy-render";
import {
addClassificationClass,
addCryptoEntry,
addHandlingAspect,
deleteCryptoEntry,
updateCryptoEntry,
updateDamageDimension,
updateEwLevel,
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[];
}
/** Inhalt der verwalteten Register (§7b) und des Anwender-Handbuchs (§9.7) — auf eigener Seite. */
export async function PolicyRegisterView({
doc,
data,
canWrite,
}: {
doc: PolicyDocument;
data: RegisterData;
canWrite: boolean;
}) {
return (
<div>
{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>
);
}
/* ─────────────── 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="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 — inline editierbar */}
<section>
<p className="mb-2 font-heading text-sm font-semibold">Risikoklassen & Akzeptanz {canWrite && <span className="text-[11px] font-normal text-muted-foreground">· zum Bearbeiten auf eine Zeile klicken</span>}</p>
<div className="rounded-xl border">
<div className="grid grid-cols-[8rem_7rem_1fr] gap-x-3 border-b bg-[var(--elevated)] px-3 py-2 text-[10.5px] tracking-[.04em] text-muted-foreground uppercase">
<span>Klasse</span><span>Bis Risikowert</span><span>Akzeptanzinstanz</span>
</div>
{sorted.map((c) => (
canWrite ? (
<details key={c.id} className="border-b last:border-0">
<summary className="grid cursor-pointer grid-cols-[8rem_7rem_1fr] items-center gap-x-3 px-3 py-2.5 text-sm select-none hover:bg-muted/40 [&::-webkit-details-marker]:hidden">
<span><Pill tone={c.tone as "ok" | "warn" | "orange" | "risk"}>{c.name}</Pill></span>
<span className="font-medium"> {c.maxScore}</span>
<span className="flex items-center justify-between gap-2 text-muted-foreground">{c.acceptance}<Pencil className="size-3.5 shrink-0 opacity-60" /></span>
</summary>
<form action={updateRiskClass.bind(null, c.id)} className="grid gap-2 border-t bg-[var(--surface-soft)] p-3 sm:grid-cols-[8rem_7rem_1fr_auto]">
<Input name="name" required defaultValue={c.name} placeholder="Klasse" className="h-8" />
<Input name="maxScore" type="number" required defaultValue={c.maxScore} placeholder="≤ Wert" className="h-8" />
<Input name="acceptance" defaultValue={c.acceptance} placeholder="Akzeptanzinstanz" className="h-8" />
<Button type="submit" variant="secondary" size="sm">Speichern</Button>
</form>
</details>
) : (
<div key={c.id} className="grid grid-cols-[8rem_7rem_1fr] items-center gap-x-3 border-b px-3 py-2.5 text-sm last:border-0">
<span><Pill tone={c.tone as "ok" | "warn" | "orange" | "risk"}>{c.name}</Pill></span>
<span className="font-medium"> {c.maxScore}</span>
<span className="text-muted-foreground">{c.acceptance}</span>
</div>
)
))}
</div>
</section>
{/* Eintrittswahrscheinlichkeit — inline editierbar */}
<section>
<p className="mb-2 font-heading text-sm font-semibold">Eintrittswahrscheinlichkeit</p>
<div className="rounded-xl border">
{[...ew].sort((a, b) => a.level - b.level).map((l) => (
canWrite ? (
<details key={l.id} className="border-b last:border-0">
<summary className="flex cursor-pointer items-center justify-between gap-2 px-3 py-2 text-[12.5px] select-none hover:bg-muted/40 [&::-webkit-details-marker]:hidden">
<span><b>{l.level} · {l.label}</b> <span className="text-muted-foreground"> {l.definition}</span></span>
<Pencil className="size-3.5 shrink-0 opacity-60" />
</summary>
<form action={updateEwLevel.bind(null, l.id)} className="grid gap-2 border-t bg-[var(--surface-soft)] p-3 sm:grid-cols-[10rem_1fr_auto]">
<Input name="label" required defaultValue={l.label} placeholder="Bezeichnung" className="h-8" />
<Input name="definition" defaultValue={l.definition} placeholder="Definition" className="h-8" />
<Button type="submit" variant="secondary" size="sm">Speichern</Button>
</form>
</details>
) : (
<div key={l.id} className="border-b px-3 py-2 text-[12.5px] last:border-0"><b>{l.level} · {l.label}</b> <span className="text-muted-foreground"> {l.definition}</span></div>
)
))}
</div>
</section>
{/* Schadenskategorien — inline editierbar */}
<section>
<p className="mb-2 font-heading text-sm font-semibold">Schadenskategorien</p>
<div className="rounded-xl border">
<div className="hidden grid-cols-[12rem_1fr_1fr_1fr_1fr] gap-x-3 border-b bg-[var(--elevated)] px-3 py-2 text-[10.5px] tracking-[.04em] text-muted-foreground uppercase sm:grid">
<span>Dimension</span><span>1 Niedrig</span><span>2 Normal</span><span>3 Hoch</span><span>4 Sehr hoch</span>
</div>
{damage.map((d) => {
const lv = d.levels as Record<string, string>;
return canWrite ? (
<details key={d.id} className="border-b last:border-0">
<summary className="grid cursor-pointer grid-cols-1 items-center gap-x-3 px-3 py-2.5 text-[12px] select-none hover:bg-muted/40 sm:grid-cols-[12rem_1fr_1fr_1fr_1fr] [&::-webkit-details-marker]:hidden">
<span className="flex items-center justify-between gap-2 font-medium">{d.name}<Pencil className="size-3.5 shrink-0 opacity-60 sm:hidden" /></span>
<span className="text-muted-foreground">{lv["1"]}</span><span className="text-muted-foreground">{lv["2"]}</span><span className="text-muted-foreground">{lv["3"]}</span><span className="text-muted-foreground">{lv["4"]}</span>
</summary>
<form action={updateDamageDimension.bind(null, d.id)} className="grid gap-2 border-t bg-[var(--surface-soft)] p-3 sm:grid-cols-2">
<Input name="name" required defaultValue={d.name} placeholder="Dimension" className="h-8 sm:col-span-2" />
<Input name="l1" defaultValue={lv["1"]} placeholder="1 Niedrig" className="h-8" />
<Input name="l2" defaultValue={lv["2"]} placeholder="2 Normal" className="h-8" />
<Input name="l3" defaultValue={lv["3"]} placeholder="3 Hoch" className="h-8" />
<Input name="l4" defaultValue={lv["4"]} placeholder="4 Sehr hoch" className="h-8" />
<Button type="submit" variant="secondary" size="sm" className="sm:col-span-2">Speichern</Button>
</form>
</details>
) : (
<div key={d.id} className="grid grid-cols-1 gap-x-3 border-b px-3 py-2.5 text-[12px] last:border-0 sm:grid-cols-[12rem_1fr_1fr_1fr_1fr]">
<span className="font-medium">{d.name}</span>
<span className="text-muted-foreground">{lv["1"]}</span><span className="text-muted-foreground">{lv["2"]}</span><span className="text-muted-foreground">{lv["3"]}</span><span className="text-muted-foreground">{lv["4"]}</span>
</div>
);
})}
</div>
</section>
</div>
);
}
/* ─────────────── Anwender-Handbuch ─────────────── */
function Handbook({ topics, variables }: { topics: HandbookTopic[]; variables: PolicyVariable[] }) {
const ctx = applyProtection(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>
);
}