diff --git a/src/app/(app)/policies/[code]/edit/page.tsx b/src/app/(app)/policies/[code]/edit/page.tsx index 1a36337..fbf247b 100644 --- a/src/app/(app)/policies/[code]/edit/page.tsx +++ b/src/app/(app)/policies/[code]/edit/page.tsx @@ -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({

{t("approvalNote")}

- {/* Schutzbedarf / TISAX-Level-Override je Richtlinie (Delta-Update) */} -
+ {/* Schutzbedarf / TISAX-Level — zentral gesteuert (nur Anzeige) */} +

{t("protectionLevel")}

-

- {t("effectiveLevel")}: {effLevel} · {t("globalLevel")}: {globalVeryHigh ? "AL3" : "AL2"} -

- - - +

{t("effectiveLevel")}: {effLevel}

+

Der Schutzbedarf wird zentral vom Plattform-Betreiber gesteuert und ist hier nicht änderbar.

+
- {/* Dokument-Variablen (§8 Variablen-Scoping) */} + {/* Dokument-Variablen (§8 Variablen-Scoping) — zentrale Variablen ausgenommen */}

{t("docVariables")}

-

{t("docVariablesHint")}

+

Zentrale Variablen (Unternehmensdaten, Rollen) werden ausschließlich in den Einstellungen gepflegt.

{docVars.length === 0 &&

{tc("none")}

} {docVars.map((v) => ( diff --git a/src/app/(app)/policies/page.tsx b/src/app/(app)/policies/page.tsx index 0dff77c..40ebb61 100644 --- a/src/app/(app)/policies/page.tsx +++ b/src/app/(app)/policies/page.tsx @@ -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) => ( - - - - - ); return (

Schutzbedarf / TISAX-Level

-
- {opt("AL2", "AL2")} - {opt("AL3", "AL3")} -
-

{level === "AL3" ? "MUSS · SOLL · HOCH · SEHR HOCH" : "MUSS · SOLL · HOCH"}

+

{level}

+

{level === "AL3" ? "MUSS · SOLL · HOCH · SEHR HOCH" : "MUSS · SOLL · HOCH"} · zentral gesteuert

); } @@ -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 (
{head}
-

{t("coverageHint", { controls: controlCount, reqs: requirements.length })}

+

{t("coverageHint", { controls: controlCount, reqs: activeRequirements.length })}

{t("byControl")} @@ -199,8 +192,8 @@ export default async function PoliciesPage({ - {[...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 ( @@ -241,8 +234,8 @@ export default async function PoliciesPage({ - {[...new Set(requirements.map((r) => r.policyCode))].map((policy) => ( - r.policyCode === policy)} clean={clean} /> + {[...new Set(activeRequirements.map((r) => r.policyCode))].map((policy) => ( + r.policyCode === policy)} clean={clean} /> ))} diff --git a/src/components/policy-modals.tsx b/src/components/policy-modals.tsx index d26f679..39c3338 100644 --- a/src/components/policy-modals.tsx +++ b/src/components/policy-modals.tsx @@ -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"; diff --git a/src/lib/policy-variables.ts b/src/lib/policy-variables.ts new file mode 100644 index 0000000..73a1167 --- /dev/null +++ b/src/lib/policy-variables.ts @@ -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); +} diff --git a/src/server/actions/policies.ts b/src/server/actions/policies.ts index f514e0c..1a896d8 100644 --- a/src/server/actions/policies.ts +++ b/src/server/actions/policies.ts @@ -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) {