Richtlinien-Fixes: zentrale Variablen, Schutzbedarf zentral, Coverage nach Level
- Zentrale Variablen (Organisation + Rollen + Schutzbedarf-Flags, lib/policy-variables.ts) sind im Richtlinien-Editor ausgeblendet und werden nur noch in den Einstellungen gepflegt; updatePolicyVariable/updateScopedVariables blocken zentrale Keys zusätzlich serverseitig. - Schutzbedarf/TISAX wird ausschließlich zentral gesteuert (Superadmin): Override je Richtlinie entfernt (setProtectionOverride + UI weg), globaler TISAX-Schalter im Policy-Modul entfernt (setGlobalTisaxLevel weg) → nur noch Read-only-Anzeige im Editor und in der Bibliothek. Rendering nutzt stets den globalen Level. - Coverage-/Referenzmatrix zeigt nur Anforderungen des aktiven Assessment-Levels (Bedingung erfüllt): bei AL2 keine „sehr hoch"-Controls (browser-verifiziert: 297 statt 316 Anforderungen, 0 SEHR HOCH); AL3 zeigt alle. tsc + lint + build grün; Edit-Seite: Schutzbedarf read-only, zentrale Variablen gesperrt. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -9,9 +9,10 @@ import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Pill } from "@/components/mockup-ui";
|
||||
import { applyProtection, buildContext, effectiveLevel, renderPolicyHtml, splitPolicyDoc } from "@/lib/policy-render";
|
||||
import { isCentralVariable } from "@/lib/policy-variables";
|
||||
import { POLICY_STATUS_LABEL, POLICY_STATUS_TONE, POLICY_TYPE_LABEL } from "@/components/policy-modals";
|
||||
import { PolicyExpertEditor } from "@/components/policy-expert-editor";
|
||||
import { approvePolicy, rejectPolicy, setProtectionOverride, submitForApproval, updatePolicyTemplate, updateScopedVariables } from "@/server/actions/policies";
|
||||
import { approvePolicy, rejectPolicy, submitForApproval, updatePolicyTemplate, updateScopedVariables } from "@/server/actions/policies";
|
||||
|
||||
const TEMPLATE_TYPES = new Set(["LEITLINIE", "RICHTLINIE", "VERFAHREN"]);
|
||||
|
||||
@@ -59,12 +60,14 @@ export default async function PolicyEditPage({
|
||||
const userName = (id: string | null) => users.find((u) => u.id === id)?.name ?? id ?? "—";
|
||||
|
||||
const usedKeys = docVariableKeys(doc.rawMarkdown, baseline);
|
||||
const docVars = variables.filter((v) => usedKeys.has(v.key) && v.kind !== "boolean");
|
||||
// Zentrale Variablen (Organisation/Rollen) sind nur in den Einstellungen pflegbar → hier ausblenden.
|
||||
const docVars = variables.filter((v) => usedKeys.has(v.key) && v.kind !== "boolean" && !isCentralVariable(v));
|
||||
const keysCsv = docVars.map((v) => v.key).join(",");
|
||||
|
||||
const ctx = applyProtection(buildContext(variables), doc.protectionOverride);
|
||||
// Schutzbedarf wird zentral gesteuert (Superadmin) — kein Override je Richtlinie mehr.
|
||||
const ctx = applyProtection(buildContext(variables));
|
||||
const globalVeryHigh = variables.find((v) => v.key === "FLAG_VERY_HIGH_PROTECTION")?.value === "true";
|
||||
const effLevel = effectiveLevel(globalVeryHigh, doc.protectionOverride);
|
||||
const effLevel = effectiveLevel(globalVeryHigh);
|
||||
const { infoMd, bodyMd } = splitPolicyDoc(doc.rawMarkdown);
|
||||
const stripBaseline = doc.code !== "BASELINE";
|
||||
const bodyHtml = renderPolicyHtml(bodyMd, ctx, { readMode: true, stripBaseline });
|
||||
@@ -182,25 +185,18 @@ export default async function PolicyEditPage({
|
||||
<p className="mt-3 text-[11px] text-muted-foreground">{t("approvalNote")}</p>
|
||||
</div>
|
||||
|
||||
{/* Schutzbedarf / TISAX-Level-Override je Richtlinie (Delta-Update) */}
|
||||
<form action={setProtectionOverride.bind(null, decoded)} className="shadow-card rounded-2xl border bg-card p-4">
|
||||
{/* Schutzbedarf / TISAX-Level — zentral gesteuert (nur Anzeige) */}
|
||||
<div className="shadow-card rounded-2xl border bg-card p-4">
|
||||
<p className="font-heading text-sm font-semibold">{t("protectionLevel")}</p>
|
||||
<p className="mt-1 mb-2 text-[11.5px] text-muted-foreground">
|
||||
{t("effectiveLevel")}: <b>{effLevel}</b> · {t("globalLevel")}: {globalVeryHigh ? "AL3" : "AL2"}
|
||||
</p>
|
||||
<select name="override" defaultValue={doc.protectionOverride ?? ""} className="h-8 w-full rounded-md border border-input bg-transparent px-2 text-[12.5px]">
|
||||
<option value="">{t("levelGlobal")} ({globalVeryHigh ? "AL3" : "AL2"})</option>
|
||||
<option value="AL2">AL2 — {t("levelAl2")}</option>
|
||||
<option value="AL3">AL3 — {t("levelAl3")}</option>
|
||||
</select>
|
||||
<Button type="submit" size="sm" className="mt-3 w-full justify-center">{tc("save")}</Button>
|
||||
</form>
|
||||
<p className="mt-1 text-[12.5px]">{t("effectiveLevel")}: <b>{effLevel}</b></p>
|
||||
<p className="mt-2 text-[11px] text-muted-foreground">Der Schutzbedarf wird zentral vom Plattform-Betreiber gesteuert und ist hier nicht änderbar.</p>
|
||||
</div>
|
||||
|
||||
{/* Dokument-Variablen (§8 Variablen-Scoping) */}
|
||||
{/* Dokument-Variablen (§8 Variablen-Scoping) — zentrale Variablen ausgenommen */}
|
||||
<form action={updateScopedVariables.bind(null, decoded)} className="shadow-card rounded-2xl border bg-card p-4">
|
||||
<input type="hidden" name="keys" value={keysCsv} />
|
||||
<p className="font-heading text-sm font-semibold">{t("docVariables")}</p>
|
||||
<p className="mt-1 mb-3 text-[11.5px] text-muted-foreground">{t("docVariablesHint")}</p>
|
||||
<p className="mt-1 mb-3 text-[11.5px] text-muted-foreground">Zentrale Variablen (Unternehmensdaten, Rollen) werden ausschließlich in den Einstellungen gepflegt.</p>
|
||||
<div className="space-y-2.5">
|
||||
{docVars.length === 0 && <p className="text-[12.5px] text-muted-foreground">{tc("none")}</p>}
|
||||
{docVars.map((v) => (
|
||||
|
||||
@@ -8,7 +8,6 @@ import { FilterTabs } from "@/components/filter-tabs";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { controlTitle, compareControl } from "@/lib/control-titles";
|
||||
import { applyProtection, buildContext, renderPolicyMarkdown } from "@/lib/policy-render";
|
||||
import { setGlobalTisaxLevel } from "@/server/actions/policies";
|
||||
import type { PolicyRequirement } from "@prisma/client";
|
||||
import {
|
||||
Table,
|
||||
@@ -55,22 +54,13 @@ function ObligationBadge({ type }: { type: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
/** Zentrale Schutzbedarf-/TISAX-Level-Einstellung (AL2/AL3) — mandantenweit. */
|
||||
/** Schutzbedarf-/TISAX-Level — zentral vom Betreiber gesteuert (nur Anzeige). */
|
||||
function TisaxLevelCard({ level }: { level: "AL2" | "AL3" }) {
|
||||
const opt = (lvl: "AL2" | "AL3", label: string) => (
|
||||
<form action={setGlobalTisaxLevel}>
|
||||
<input type="hidden" name="level" value={lvl} />
|
||||
<button type="submit" className={`rounded-md px-2.5 py-1 text-[12px] font-bold ${level === lvl ? "bg-[var(--sidebar-accent)] text-[var(--primary)]" : "text-muted-foreground hover:text-foreground"}`}>{label}</button>
|
||||
</form>
|
||||
);
|
||||
return (
|
||||
<div className="shadow-card rounded-xl border bg-card p-4">
|
||||
<p className="text-[12px] text-muted-foreground">Schutzbedarf / TISAX-Level</p>
|
||||
<div className="mt-1.5 inline-flex rounded-lg border p-1">
|
||||
{opt("AL2", "AL2")}
|
||||
{opt("AL3", "AL3")}
|
||||
</div>
|
||||
<p className="mt-2 text-[11px] text-muted-foreground">{level === "AL3" ? "MUSS · SOLL · HOCH · SEHR HOCH" : "MUSS · SOLL · HOCH"}</p>
|
||||
<p className="mt-1.5 font-heading text-lg font-bold">{level}</p>
|
||||
<p className="mt-1 text-[11px] text-muted-foreground">{level === "AL3" ? "MUSS · SOLL · HOCH · SEHR HOCH" : "MUSS · SOLL · HOCH"} · zentral gesteuert</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -133,6 +123,9 @@ export default async function PoliciesPage({
|
||||
]);
|
||||
const titleOf = (code: string) => docs.find((d) => d.code === code)?.title ?? code;
|
||||
const ctx = applyProtection(buildContext(variables));
|
||||
// Coverage nach Assessment-Level: nur Anforderungen, deren Bedingung im effektiven
|
||||
// Level erfüllt ist (AL2 blendet „sehr hoch" = FLAG_VERY_HIGH_PROTECTION aus).
|
||||
const activeRequirements = requirements.filter((r) => !r.condition || ctx[r.condition] === true);
|
||||
const clean = (s: string, n = 160) =>
|
||||
renderPolicyMarkdown(s, ctx, { readMode: true, stripBaseline: true })
|
||||
.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
|
||||
@@ -170,14 +163,14 @@ export default async function PoliciesPage({
|
||||
/* ── Referenz-/Coverage-Matrix (§9.3, nach Mockup) ── */
|
||||
if (isCoverage) {
|
||||
const dir = params.dir === "doc" ? "doc" : "ctrl";
|
||||
const controlCount = new Set(requirements.map((r) => r.control)).size;
|
||||
const controlCount = new Set(activeRequirements.map((r) => r.control)).size;
|
||||
const vasOf = (ids: string[]) => [...new Set(ids)].sort();
|
||||
|
||||
return (
|
||||
<main className="flex-1 p-6">
|
||||
{head}
|
||||
<div className="mt-3 flex flex-wrap items-center justify-between gap-3">
|
||||
<p className="text-[12.5px] text-muted-foreground">{t("coverageHint", { controls: controlCount, reqs: requirements.length })}</p>
|
||||
<p className="text-[12.5px] text-muted-foreground">{t("coverageHint", { controls: controlCount, reqs: activeRequirements.length })}</p>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="inline-flex rounded-lg border bg-card p-1 text-[12px] font-semibold">
|
||||
<Link href="/policies?view=coverage" className={dir === "ctrl" ? "rounded-md bg-[var(--sidebar-accent)] px-3 py-1.5 text-[var(--primary)]" : "px-3 py-1.5 text-muted-foreground hover:text-foreground"}>{t("byControl")}</Link>
|
||||
@@ -199,8 +192,8 @@ export default async function PoliciesPage({
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{[...new Set(requirements.map((r) => r.control))].sort(compareControl).map((control) => {
|
||||
const list = requirements.filter((r) => r.control === control);
|
||||
{[...new Set(activeRequirements.map((r) => r.control))].sort(compareControl).map((control) => {
|
||||
const list = activeRequirements.filter((r) => r.control === control);
|
||||
const vas = vasOf(list.flatMap((r) => r.vaCodes));
|
||||
return (
|
||||
<tr key={control} className="border-b align-top last:border-0">
|
||||
@@ -241,8 +234,8 @@ export default async function PoliciesPage({
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{[...new Set(requirements.map((r) => r.policyCode))].map((policy) => (
|
||||
<PolicyGroup key={policy} policy={policy} title={titleOf(policy)} rows={requirements.filter((r) => r.policyCode === policy)} clean={clean} />
|
||||
{[...new Set(activeRequirements.map((r) => r.policyCode))].map((policy) => (
|
||||
<PolicyGroup key={policy} policy={policy} title={titleOf(policy)} rows={activeRequirements.filter((r) => r.policyCode === policy)} clean={clean} />
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@@ -48,7 +48,7 @@ export interface PolicyReadData {
|
||||
export async function PolicyReadView({ data }: { data: PolicyReadData }) {
|
||||
const t = await getTranslations("policies");
|
||||
const { doc, variables, controls, relatedVas } = data;
|
||||
const ctx = applyProtection(buildContext(variables), doc.protectionOverride);
|
||||
const ctx = applyProtection(buildContext(variables));
|
||||
const { infoMd, bodyMd } = splitPolicyDoc(doc.rawMarkdown);
|
||||
|
||||
const stripBaseline = doc.code !== "BASELINE";
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* Abgrenzung „zentraler" Richtlinien-Variablen (nur in den Einstellungen pflegbar,
|
||||
* im Richtlinien-Editor gesperrt): Organisation (Unternehmensname/Scope, …),
|
||||
* verantwortliche Rollen (ROLE_*) und die Schutzbedarf-Flags. Alles andere
|
||||
* (dokument-/paketspezifische Variablen, eigene) bleibt im Editor pflegbar.
|
||||
*
|
||||
* Reine Funktion ohne Server-/Render-Abhängigkeiten (auch in Actions nutzbar).
|
||||
*/
|
||||
export const CENTRAL_VARIABLE_GROUPS = new Set(["Organisation", "Rollen"]);
|
||||
export const CENTRAL_VARIABLE_KEYS = new Set(["FLAG_HIGH_PROTECTION", "FLAG_VERY_HIGH_PROTECTION"]);
|
||||
|
||||
export function isCentralVariable(v: { key: string; groupName?: string | null }): boolean {
|
||||
if (v.groupName && CENTRAL_VARIABLE_GROUPS.has(v.groupName)) return true;
|
||||
return CENTRAL_VARIABLE_KEYS.has(v.key);
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { redirect } from "next/navigation";
|
||||
import { z } from "zod";
|
||||
import { moduleGuard } from "@/server/action-guard";
|
||||
import { type Permission } from "@/server/rbac";
|
||||
import { isCentralVariable } from "@/lib/policy-variables";
|
||||
import { writeAuditLog } from "@/server/audit";
|
||||
|
||||
const optDate = (v: FormDataEntryValue | null) => (v && String(v) ? new Date(String(v)) : null);
|
||||
@@ -179,6 +180,8 @@ export async function updatePolicyTemplate(code: string, formData: FormData) {
|
||||
/** Eine dokument-bezogene Variable ändern (eine Pflegestelle, §7/§8) — propagiert in alle Dokumente. */
|
||||
export async function updatePolicyVariable(key: string, formData: FormData) {
|
||||
const { session, db } = await guard();
|
||||
const variable = await db.policyVariable.findFirst({ where: { key }, select: { key: true, groupName: true } });
|
||||
if (variable && isCentralVariable(variable)) throw new Error("Zentrale Variablen werden ausschließlich in den Einstellungen gepflegt.");
|
||||
const value = String(formData.get("value") ?? "");
|
||||
await db.policyVariable.updateMany({ where: { key }, data: { value } });
|
||||
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "update", entity: "policy_variable", after: { key, value } });
|
||||
@@ -193,8 +196,11 @@ export async function updatePolicyVariable(key: string, formData: FormData) {
|
||||
export async function updateScopedVariables(code: string, formData: FormData) {
|
||||
const { session, db } = await guard();
|
||||
const keys = String(formData.get("keys") ?? "").split(",").map((k) => k.trim()).filter(Boolean);
|
||||
// Zentrale Variablen (Organisation/Rollen) niemals aus dem Richtlinien-Editor überschreiben.
|
||||
const rows = keys.length ? await db.policyVariable.findMany({ where: { key: { in: keys } }, select: { key: true, groupName: true } }) : [];
|
||||
const centralKeys = new Set(rows.filter(isCentralVariable).map((r) => r.key));
|
||||
for (const key of keys) {
|
||||
if (!formData.has(`var_${key}`)) continue;
|
||||
if (centralKeys.has(key) || !formData.has(`var_${key}`)) continue;
|
||||
const value = String(formData.get(`var_${key}`) ?? "");
|
||||
await db.policyVariable.updateMany({ where: { key }, data: { value } });
|
||||
}
|
||||
@@ -203,31 +209,6 @@ export async function updateScopedVariables(code: string, formData: FormData) {
|
||||
redirect(`/policies/${encodeURIComponent(code)}/edit`);
|
||||
}
|
||||
|
||||
/* ── Schutzbedarf / TISAX-Level (Delta-Update) ── */
|
||||
|
||||
/** Globaler TISAX-Level: AL2 (very-high aus) / AL3 (very-high an); HIGH bleibt stets an. */
|
||||
export async function setGlobalTisaxLevel(formData: FormData) {
|
||||
const { session, db } = await guard();
|
||||
const level = String(formData.get("level")) === "AL3" ? "AL3" : "AL2";
|
||||
await db.policyVariable.updateMany({ where: { key: "FLAG_VERY_HIGH_PROTECTION" }, data: { value: level === "AL3" ? "true" : "false" } });
|
||||
await db.policyVariable.updateMany({ where: { key: "FLAG_HIGH_PROTECTION" }, data: { value: "true" } });
|
||||
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "update", entity: "tisax_level", after: { level } });
|
||||
revalidatePath("/policies", "layout");
|
||||
}
|
||||
|
||||
/** Schutzbedarf-Override je Richtlinie: "" = global, "AL2", "AL3". */
|
||||
export async function setProtectionOverride(code: string, formData: FormData) {
|
||||
const { session, db } = await guard();
|
||||
const raw = String(formData.get("override") ?? "");
|
||||
const override = raw === "AL2" || raw === "AL3" ? raw : null;
|
||||
const doc = await db.policyDocument.findFirst({ where: { code } });
|
||||
if (!doc) throw new Error("Dokument nicht gefunden");
|
||||
await db.policyDocument.update({ where: { id: doc.id }, data: { protectionOverride: override } });
|
||||
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "update", entity: "policy_document", entityId: doc.id, after: { protectionOverride: override } });
|
||||
revalidatePath("/policies", "layout");
|
||||
redirect(`/policies/${encodeURIComponent(code)}/edit`);
|
||||
}
|
||||
|
||||
/* ── Freigabe-Workflow (Vier-Augen, §8) ── */
|
||||
|
||||
export async function submitForApproval(code: string) {
|
||||
|
||||
Reference in New Issue
Block a user