Basis: Certvia dev@a48c5fb als Fundament für Craftvia
CI / build-and-check (push) Canceled after 0s
CI / audit (push) Canceled after 0s
CI / sbom (push) Canceled after 0s

Unveränderter Stand von certvia/dev (a48c5fb) plus Craftvia-Spezifikation
und Brandbook unter docs/craftvia/. ISMS-Module werden im Folgecommit entfernt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-14 11:05:39 +02:00
co-authored by Claude Opus 5
commit c8e6f30a27
720 changed files with 140143 additions and 0 deletions
+7
View File
@@ -0,0 +1,7 @@
import { requireModule } from "@/server/modules";
/** Serverseitige Modul-Durchsetzung für den Onboarding-Wizard (§3.4). */
export default async function ModuleLayout({ children }: { children: React.ReactNode }) {
await requireModule("onboarding");
return <>{children}</>;
}
+250
View File
@@ -0,0 +1,250 @@
import Link from "next/link";
import { getTranslations } from "next-intl/server";
import { Check } from "lucide-react";
import type { ObjectReviewStatus } from "@prisma/client";
import { requireSession } from "@/server/auth";
import { dbForTenant } from "@/server/db";
import { hasPermission } from "@/server/rbac";
import { PageHead, Pill } from "@/components/mockup-ui";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { MODULE_KEYS } from "@/lib/modules";
import { getStep, getVisibleSteps, type StepCtx, type StepKey } from "@/lib/onboarding/registry";
import "@/lib/onboarding/register-steps"; // Dev-B: echte Schritt-Komponenten einklinken (nach Default-Registrierung)
import { advanceTarget, firstIncompleteIndex, isAwaitingValidation, isValidated, progressPercent } from "@/lib/onboarding/state";
import { advanceStep, rejectStep, resetStep, validateStep } from "@/server/actions/onboarding";
import { BiaPopup } from "@/components/bia-popup";
import { ProcessCreateModal, ProcessHouseDetailModal } from "@/components/process-modals";
const STATUS_TONE: Record<ObjectReviewStatus, "mut" | "warn" | "info" | "ok" | "risk"> = {
offen: "mut",
in_bearbeitung: "warn",
zur_validierung: "info",
validiert: "ok",
zurueckgewiesen: "risk",
};
/** Beschriftung der Bearbeiter-Aktion je Status (advance/rework); null = keine. */
function advanceKey(status: ObjectReviewStatus): "start" | "submit" | "rework" | null {
if (!advanceTarget(status)) return null;
if (status === "offen") return "start";
if (status === "zurueckgewiesen") return "rework";
return "submit";
}
export default async function OnboardingPage({
searchParams,
}: {
searchParams: Promise<{
step?: string;
bia?: string;
biaStep?: string;
biaNew?: string;
detail?: string;
new?: string;
}>;
}) {
const session = await requireSession();
const t = await getTranslations("onboarding");
const tc = await getTranslations("common");
const db = dbForTenant(session.user.tenantId);
const canUse = hasPermission(session, "onboarding:use");
const canValidate = hasPermission(session, "validate_objects");
// Sichtbarkeits-Kontext: alle Modul-Keys außer den deaktivierten (Default = aktiv).
const moduleRows = await db.tenantModule.findMany();
const disabled = new Set(moduleRows.filter((m) => !m.enabled).map((m) => m.moduleKey));
const ctx: StepCtx = { enabledModules: new Set(MODULE_KEYS.filter((k) => !disabled.has(k))) };
const steps = getVisibleSteps(ctx);
const rows = await db.onboardingProgress.findMany();
const byKey = new Map(rows.map((r) => [r.stepKey, r]));
const statusOf = (k: StepKey) => byKey.get(k)?.status ?? "offen";
// Aktueller Schritt: angefragter (jeder Schritt ist zugänglich) sonst Resume-Punkt.
const params = await searchParams;
const requestedIdx = steps.findIndex((s) => s.key === params.step);
const currentIdx = requestedIdx >= 0 ? requestedIdx : firstIncompleteIndex(steps, statusOf);
const current = steps[currentIdx];
const currentStatus = statusOf(current.key);
const currentComment = byKey.get(current.key)?.reviewComment ?? null;
const StepComponent = getStep(current.key)!.component;
const percent = progressPercent(steps, statusOf);
const done = steps.filter((s) => isValidated(statusOf(s.key))).length;
const advKey = advanceKey(currentStatus);
const awaitingValidation = isAwaitingValidation(currentStatus);
const prev = currentIdx > 0 ? steps[currentIdx - 1] : null;
const next = currentIdx < steps.length - 1 ? steps[currentIdx + 1] : null;
// TISAX v4B: Prozesshaus-Overlays (Detail read-only / Anlegen). Zustand über
// searchParams ?detail=/?new=1 — als Sibling über die Seite gelegt, die
// Onboarding-Hauptseite bleibt unverändert.
const PROC_BASE = "/onboarding?step=processes";
const biaEnabled = ctx.enabledModules.has("bia");
const canWriteProcess = canUse && hasPermission(session, "bia:write");
const detailProcess = params.detail
? await db.process.findUnique({
where: { id: params.detail },
include: {
owner: { select: { name: true } },
parent: { select: { name: true } },
children: { select: { id: true, name: true } },
bia: true,
},
})
: null;
const detailDeputy = detailProcess?.deputyOwnerId
? await db.user.findUnique({ where: { id: detailProcess.deputyOwnerId }, select: { name: true } })
: null;
const showCreate = Boolean(params.new) && canWriteProcess;
let processUsers: { id: string; name: string }[] = [];
let processOptions: { id: string; name: string }[] = [];
if (showCreate) {
[processUsers, processOptions] = await Promise.all([
db.user.findMany({
where: { status: "ACTIVE" },
select: { id: true, name: true },
orderBy: { name: "asc" },
}),
db.process.findMany({ select: { id: true, name: true }, orderBy: { name: "asc" } }),
]);
}
return (
<main className="flex-1 p-6">
<PageHead crumb={t("crumb")} title={t("title")} sub={t("progress", { done, total: steps.length, percent })} />
<div className="mt-4 grid gap-5 lg:grid-cols-[264px_1fr]">
{/* Stepper */}
<ol className="shadow-card h-fit space-y-1 rounded-xl border bg-card p-2">
{steps.map((s, i) => {
const st = statusOf(s.key);
const isCurrent = i === currentIdx;
const inner = (
<span className={`flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm ${isCurrent ? "bg-[var(--surface-soft)] font-semibold" : ""}`}>
<span className={`grid size-6 shrink-0 place-items-center rounded-full border text-[11px] ${isValidated(st) ? "border-[var(--ok)] bg-[var(--ok)] text-white" : "text-muted-foreground"}`}>
{isValidated(st) ? <Check className="size-3.5" /> : s.order}
</span>
<span className="flex-1 truncate">{t(s.title)}</span>
<Pill tone={STATUS_TONE[st]}>{t(`status.${st}`)}</Pill>
</span>
);
return (
<li key={s.key}>
<Link href={`/onboarding?step=${s.key}`} className="block hover:opacity-90">{inner}</Link>
</li>
);
})}
</ol>
{/* Aktueller Schritt + Aktionen */}
<section className="space-y-4">
<div className="flex items-center justify-between gap-2">
<h2 className="font-heading text-lg font-semibold">
{current.order}. {t(current.title)}
</h2>
<Pill tone={STATUS_TONE[currentStatus]}>{t(`status.${currentStatus}`)}</Pill>
</div>
{currentStatus === "zurueckgewiesen" && (
<div className="rounded-xl border border-[var(--risk-brd,var(--band-brd))] bg-[var(--band)] p-3 text-[12.5px] text-[var(--band-text)]">
<b>{t("rejectedTitle")}</b>
{currentComment ? <p className="mt-1">{currentComment}</p> : <p className="mt-1">{t("rejectedNoComment")}</p>}
</div>
)}
<StepComponent stepKey={current.key} status={currentStatus} />
<div className="flex flex-wrap items-center gap-2 border-t pt-4">
{/* Bearbeiter-Aktion */}
{canUse && advKey && (
<form action={advanceStep.bind(null, current.key)}>
<Button type="submit">{t(`actions.${advKey}`)}</Button>
</form>
)}
{/* Validator-Aktionen (nur zur_validierung + Recht) */}
{awaitingValidation && canValidate && (
<>
<form action={validateStep.bind(null, current.key)}>
<Button type="submit">{t("actions.validate")}</Button>
</form>
<details className="relative">
<summary className="inline-flex h-9 cursor-pointer list-none items-center rounded-md border border-input px-3 text-sm font-medium select-none hover:bg-muted [&::-webkit-details-marker]:hidden">
{t("actions.reject")}
</summary>
<form action={rejectStep.bind(null, current.key)} className="shadow-card absolute left-0 z-10 mt-2 w-80 space-y-2 rounded-xl border bg-card p-3">
<Textarea name="comment" rows={3} placeholder={t("rejectCommentPlaceholder")} />
<Button type="submit" variant="secondary" size="sm">{t("actions.reject")}</Button>
</form>
</details>
</>
)}
{awaitingValidation && !canValidate && (
<span className="text-[12.5px] text-muted-foreground">{t("awaitingValidation")}</span>
)}
{canUse && currentStatus !== "offen" && (
<form action={resetStep.bind(null, current.key)}>
<Button type="submit" variant="outline">{t("actions.reset")}</Button>
</form>
)}
<div className="ml-auto flex items-center gap-2">
{prev && (
<Button variant="outline" nativeButton={false} render={<Link href={`/onboarding?step=${prev.key}`} />}>
{t("actions.back")}
</Button>
)}
{next && (
<Button nativeButton={false} render={<Link href={`/onboarding?step=${next.key}`} />}>
{t("actions.next")}
</Button>
)}
{!next && isValidated(currentStatus) && <Pill tone="ok">{t("allDone")}</Pill>}
</div>
</div>
{!isValidated(currentStatus) && (
<p className="text-[12.5px] text-muted-foreground">{t("gateHint")}</p>
)}
{!canUse && <p className="text-[12.5px] text-muted-foreground">{tc("readOnly")}</p>}
</section>
</div>
{/* TISAX v3A: geführtes BIA-Popup je Prozess (Prozesshaus-Kachel). Zustand über
searchParams ?bia=<id>&biaStep=1..5 — nur bei aktivem BIA-Modul. */}
{params.bia && biaEnabled && (
<BiaPopup
processId={params.bia}
step={Number.parseInt(params.biaStep ?? "1", 10) || 1}
newForm={params.biaNew}
basePath="/onboarding?step=processes"
/>
)}
{/* TISAX v4B: „Details" read-only als Overlay über dem Prozesshaus. */}
{detailProcess && (
<ProcessHouseDetailModal
process={detailProcess}
deputyName={detailDeputy?.name ?? null}
closeHref={PROC_BASE}
biaHref={biaEnabled ? `${PROC_BASE}&bia=${detailProcess.id}&biaStep=1` : undefined}
/>
)}
{/* TISAX v4B: „Prozess anlegen" als Overlay (Abbrechen/Schließen kehrt sicher zurück). */}
{showCreate && (
<ProcessCreateModal
users={processUsers}
processes={processOptions}
closeHref={PROC_BASE}
returnTo="house"
/>
)}
</main>
);
}
@@ -0,0 +1,143 @@
import Link from "next/link";
import { Boxes } from "lucide-react";
import { requireSession } from "@/server/auth";
import { dbForTenant } from "@/server/db";
import { hasPermission } from "@/server/rbac";
import { Button } from "@/components/ui/button";
import { Pill } from "@/components/mockup-ui";
import { createAssetGapTasks } from "@/server/actions/onboarding";
import { getProcessCatalogSuggestions } from "@/server/actions/processes";
import { assetTypeLabel } from "@/lib/asset-labels";
/**
* Asset-Schritt (Schritt 5, Story A5-1). Nutzt das bestehende Assetinventar/BIA
* (keine Doppel-Datenhaltung): Kennzahlen-Übersicht + Lücken (ohne Eigentümer,
* ohne Schutzbedarfsbewertung). Pflege erfolgt im Asset-Modul; Lücken lassen sich
* als Aufgaben anlegen.
*/
export async function AssetsStep() {
const session = await requireSession();
const db = dbForTenant(session.user.tenantId);
const canUse = hasPermission(session, "onboarding:use");
const [total, withoutOwner, highProtection, unrated, inScope] = await Promise.all([
db.asset.count(),
db.asset.count({ where: { ownerId: null } }),
db.asset.count({ where: { OR: [{ confidentiality: { gte: 3 } }, { integrity: { gte: 3 } }, { availability: { gte: 3 } }] } }),
db.asset.count({ where: { confidentiality: 1, integrity: 1, availability: 1 } }),
db.asset.count({ where: { processAssets: { some: {} } } }),
]);
const kpis = [
{ label: "Assets im Inventar", value: total },
{ label: "hoher Schutzbedarf", value: highProtection },
{ label: "in Prozessen (BIA)", value: inScope },
];
const gaps = [
{ n: withoutOwner, label: "ohne Eigentümer", origin: "wizard:asset-no-owner" },
{ n: unrated, label: "ohne Schutzbedarfsbewertung (C/I/A)", origin: "wizard:asset-unrated" },
].filter((g) => g.n > 0);
// Rückmeldung: welche Lücken bereits eine (offene) Aufgabe haben (idempotent, Origin-Abgleich).
const gapTasks = await db.task.findMany({
where: { origin: { in: gaps.map((g) => g.origin) }, status: { in: ["PROPOSED", "OPEN"] } },
select: { origin: true },
});
const withTask = new Set(gapTasks.map((t) => t.origin));
const openGaps = gaps.filter((g) => !withTask.has(g.origin)).length;
// M2: vorgeschlagene Träger-Asset-Typen (SECONDARY) je übernommenem Katalog-Prozess.
const suggestions = await getProcessCatalogSuggestions();
const carrierSuggestions = suggestions
.map((s) => ({
...s,
carriers: s.suggestedAssetTypes.filter((t) => t !== "INFORMATION" && t !== "DATA"),
}))
.filter((s) => s.carriers.length > 0);
return (
<div className="space-y-5">
<div className="rounded-xl border bg-card p-4">
<div className="flex items-center justify-between gap-2">
<span className="font-heading text-sm font-semibold">Assetinventar</span>
<Button variant="outline" size="sm" nativeButton={false} render={<Link href="/assets" />}>
<Boxes className="size-4" /> Zum Assetinventar
</Button>
</div>
<div className="mt-3 grid gap-3 sm:grid-cols-3">
{kpis.map((k) => (
<div key={k.label} className="rounded-lg border bg-[var(--surface-soft)] p-3">
<p className="font-heading text-2xl font-bold leading-none">{k.value}</p>
<p className="mt-1 text-[11.5px] text-muted-foreground">{k.label}</p>
</div>
))}
</div>
<p className="mt-3 text-[11.5px] text-muted-foreground">
Der Wizard nutzt das bestehende Assetinventar (Schutzbedarf C/I/A, Eigentümer, BIA) —
keine doppelte Datenhaltung. Pflege im Asset-Modul.
</p>
</div>
<div className="rounded-xl border bg-card p-4">
<div className="flex flex-wrap items-center justify-between gap-2">
<span className="font-heading text-sm font-semibold">Vollständigkeit</span>
{canUse && (
<form action={createAssetGapTasks}>
<Button type="submit" size="sm" variant="outline" disabled={openGaps === 0}>Prüfen &amp; Aufgaben anlegen{openGaps ? ` (${openGaps})` : ""}</Button>
</form>
)}
</div>
{gaps.length === 0 ? (
<p className="mt-2 text-[12.5px] text-muted-foreground">Keine offensichtlichen Lücken (alle Assets mit Eigentümer und Schutzbedarf).</p>
) : (
<>
<ul className="mt-3 space-y-2">
{gaps.map((g) => (
<li key={g.label} className="flex items-center gap-2 border-b pb-2 text-[12.5px] last:border-0">
<Pill tone={withTask.has(g.origin) ? "ok" : "warn"}>{withTask.has(g.origin) ? "Aufgabe angelegt" : "Lücke"}</Pill>
<span><b>{g.n}</b> Asset(s) {g.label}</span>
</li>
))}
</ul>
<p className="mt-2 text-[11.5px] text-muted-foreground">
Die Aufgabe stößt die Nacharbeit an; die Lücke selbst schließt sich erst, wenn im Asset-Modul Eigentümer bzw. Schutzbedarf gepflegt sind.
</p>
</>
)}
</div>
{carrierSuggestions.length > 0 && (
<div className="rounded-xl border bg-card p-4">
<span className="font-heading text-sm font-semibold">Träger-Vorschläge je Prozess (Katalog)</span>
<p className="mt-1 text-[11.5px] text-muted-foreground">
Typische Träger-Assets (Systeme, Anwendungen, Standorte, Dienstleister …), auf denen
die primären Informationswerte dieser Prozesse liegen. Als Orientierung für die
Erfassung im Prozess/Asset-Modul.
</p>
<ul className="mt-3 space-y-2">
{carrierSuggestions.map((s) => (
<li key={s.processId} className="border-b pb-2 text-[12.5px] last:border-0">
<div className="flex flex-wrap items-center gap-2">
<span className="flex-1 font-medium">{s.processName}</span>
<Button
variant="outline"
size="sm"
nativeButton={false}
render={<Link href={`/processes?detail=${s.processId}`} />}
>
Träger erfassen
</Button>
</div>
<div className="mt-1.5 flex flex-wrap gap-1.5">
{s.carriers.map((t) => (
<Pill key={t} tone="mut">{assetTypeLabel(t)}</Pill>
))}
</div>
</li>
))}
</ul>
</div>
)}
</div>
);
}
@@ -0,0 +1,92 @@
import { requireSession } from "@/server/auth";
import { dbForTenant } from "@/server/db";
import { hasPermission } from "@/server/rbac";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Pill } from "@/components/mockup-ui";
import { SECTION_TITLES, WIZARD_QUESTIONS, type WizardQuestion, type WizardSection } from "@/lib/onboarding/questions";
import { deriveContext } from "@/lib/onboarding/facts";
import { evaluateCondition } from "@/lib/rules/engine";
import { getAssessmentLevel, protectionFlags } from "@/server/assessment-level";
import { saveFacts } from "@/server/actions/onboarding-facts";
const selectCls = "h-8 w-full max-w-xs rounded-md border bg-background px-2 text-sm";
const SECTIONS: WizardSection[] = ["A", "B", "C", "D", "E", "F"];
function QuestionField({ q, value }: { q: WizardQuestion; value: string | boolean }) {
return (
<label className="block">
<span className="text-[13px] font-medium">{q.label}</span>
{q.help && <span className="mt-0.5 block text-[11.5px] text-muted-foreground">{q.help}</span>}
<span className="mt-1 block">
{q.type === "boolean" ? (
<select name={q.id} defaultValue={String(value === true)} className={selectCls}>
<option value="true">Ja</option>
<option value="false">Nein</option>
</select>
) : q.type === "select" ? (
<select name={q.id} defaultValue={String(value)} className={selectCls}>
{q.choices?.map((c) => <option key={c.value} value={c.value}>{c.label}</option>)}
</select>
) : (
<Input name={q.id} defaultValue={String(value)} className="h-8 max-w-xs" />
)}
</span>
</label>
);
}
/**
* Fragebogen (Story B3, Schritt 2 „context"). Dynamischer, bedingter Katalog A–F
* (C2). Antworten werden als WizardFacts gespeichert; die Wirkung (Flags, Aufgaben)
* propagiert die saveFacts-Action über die B2-Engine. Bedingte Sichtbarkeit wird
* serverseitig aus den abgeleiteten Flags/Antworten berechnet.
*/
export async function ContextStep() {
const session = await requireSession();
const db = dbForTenant(session.user.tenantId);
const canUse = hasPermission(session, "onboarding:use");
const [facts, scope] = await Promise.all([db.wizardFact.findMany(), db.wizardScope.findFirst()]);
// Schutzbedarf-Flags zentral aus dem Assessment-Level (A2-1); Prüfziele zentral aus
// WizardScope (Scoping, A2-2) — beides nicht aus dem Fragebogen (keine Doppelquelle).
const level = await getAssessmentLevel(db);
const ctx = deriveContext(facts, protectionFlags(level), scope?.pruefziele ?? ["informationssicherheit"]);
const current = new Map(facts.map((f) => [f.key, f.value]));
const valueOf = (q: WizardQuestion): string | boolean =>
current.has(q.id) ? (current.get(q.id) as string | boolean) : (q.default ?? (q.type === "boolean" ? false : ""));
const visible = WIZARD_QUESTIONS.filter((q) => !q.showWhen || evaluateCondition(q.showWhen, ctx));
const activeFlags = Object.keys(ctx.flags).filter((k) => ctx.flags[k]).sort();
return (
<form action={saveFacts} className="space-y-4">
<p className="text-[12.5px] text-muted-foreground">
Antworten werden als wiederverwendbare Fakten gespeichert. Sie steuern Flags, Klauseln, Controls und
Aufgaben-Vorschläge. Organisation, Rollen und Schutzbedarf-Zentralwerte werden in den Einstellungen gepflegt.
</p>
{SECTIONS.map((sec) => {
const qs = visible.filter((q) => q.section === sec);
if (qs.length === 0) return null;
return (
<fieldset key={sec} className="rounded-xl border bg-card p-4">
<legend className="px-1 text-[13px] font-semibold">{SECTION_TITLES[sec]}</legend>
<div className="mt-2 grid gap-3 sm:grid-cols-2">
{qs.map((q) => <QuestionField key={q.id} q={q} value={valueOf(q)} />)}
</div>
</fieldset>
);
})}
<div className="flex flex-wrap items-center gap-2 border-t pt-3">
{canUse && <Button type="submit">Antworten speichern</Button>}
<span className="flex flex-wrap items-center gap-1 text-[12px] text-muted-foreground">
Abgeleitete Flags:
{activeFlags.length ? activeFlags.map((f) => <Pill key={f} tone="info">{f}</Pill>) : <span>—</span>}
</span>
</div>
</form>
);
}
@@ -0,0 +1,201 @@
import Link from "next/link";
import { ShieldCheck, ListChecks } from "lucide-react";
import { requireSession } from "@/server/auth";
import { dbForTenant } from "@/server/db";
import { hasPermission } from "@/server/rbac";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Pill } from "@/components/mockup-ui";
import { controlTitle } from "@/lib/control-titles";
import { policyTitles, verfahrenTitles } from "@/lib/policy-titles";
import { MATURITY_LABEL, type EvidenceStatus } from "@/lib/maturity";
import { buildControlRows, type ControlRow, type HintWithStatus } from "@/server/soa-context";
import { confirmControlMaturity, confirmAllSuggestions, createControlGapTasks, setImplementationStatus, createImplementationTask } from "@/server/actions/soa";
/**
* Control-Assessment-Schritt (Schritt 7, Story A7-1/A7-2). Je In-Scope-Control: erwartete
* Belege (Richtlinie/Verfahren/Asset/Risiko), abgeleiteter Belegstatus, regelbasierter
* Reifegrad-Vorschlag (C5 §2), Zielreifegrad (C5 §3) und offene Punkte (C5 §4). Der
* Bearbeiter bestätigt/überschreibt den Reifegrad (Pflicht); Lücken werden zu Aufgaben.
*/
const MATURITY_TONE = ["risk", "orange", "info", "ok"] as const;
const STATUS_LABEL: Record<EvidenceStatus, string> = { fehlt: "fehlt", verknuepft: "verknüpft", validiert: "validiert" };
const STATUS_TONE: Record<EvidenceStatus, "ok" | "warn" | "mut"> = { fehlt: "mut", verknuepft: "warn", validiert: "ok" };
const IMPL_STATUSES = ["offen", "in_umsetzung", "erledigt"] as const;
const IMPL_LABEL: Record<string, string> = { offen: "offen", in_umsetzung: "in Umsetzung", erledigt: "erledigt" };
const IMPL_TONE: Record<string, "mut" | "warn" | "ok"> = { offen: "mut", in_umsetzung: "warn", erledigt: "ok" };
const selectCls = "rounded-md border bg-[var(--surface-soft)] px-2 py-1 text-[11.5px]";
/** Umsetzungshinweise (Spiegelstriche) je Control: Status dokumentieren + Aufgabe erzeugen (#10). */
function HintList({ hints, canUse }: { hints: HintWithStatus[]; canUse: boolean }) {
const done = hints.filter((h) => h.status === "erledigt").length;
return (
<details className="mt-1.5">
<summary className="cursor-pointer text-[11.5px] text-muted-foreground">
Umsetzungshinweise ({done}/{hints.length} erledigt)
</summary>
<ul className="mt-1.5 space-y-2">
{hints.map((h) => (
<li key={h.hint.reqId} className="rounded-lg border bg-[var(--surface-soft)] p-2.5">
<div className="flex flex-wrap items-center gap-2">
<Pill tone={IMPL_TONE[h.status] ?? "mut"}>{IMPL_LABEL[h.status] ?? h.status}</Pill>
<span className="text-[12px] font-medium">{h.hint.requirement}</span>
</div>
<dl className="mt-1 grid gap-0.5 text-[11px] text-muted-foreground sm:grid-cols-3">
<div><b>Organisatorisch:</b> {h.hint.organisational}</div>
<div><b>Technisch:</b> {h.hint.technical}</div>
<div><b>Nachweise:</b> {h.hint.evidence}</div>
</dl>
{canUse && (
<div className="mt-2 flex flex-wrap items-end gap-2">
<form action={setImplementationStatus.bind(null, h.hint.reqId)} className="flex flex-wrap items-end gap-2">
<select name="status" defaultValue={h.status} className={`${selectCls} mt-1`}>
{IMPL_STATUSES.map((s) => <option key={s} value={s}>{IMPL_LABEL[s]}</option>)}
</select>
<Input name="note" defaultValue={h.note ?? ""} placeholder="Notiz / Nachweisverweis" className="h-7 w-56 text-[11.5px]" />
<Button type="submit" size="sm" variant="outline">Speichern</Button>
</form>
<form action={createImplementationTask.bind(null, h.hint.reqId)}>
<Button type="submit" size="sm" variant="ghost">Aufgabe</Button>
</form>
</div>
)}
</li>
))}
</ul>
</details>
);
}
function ControlLine({ row, hints, canUse }: { row: ControlRow; hints: HintWithStatus[]; canUse: boolean }) {
const { spec, evidence, suggestion, target, gaps, confirmed } = row;
const belowTarget = (confirmed ?? suggestion.value) < target;
const implComplete = hints.length > 0 && hints.every((h) => h.status === "erledigt");
return (
<li className="border-b py-2.5 last:border-0">
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
<span className="font-mono text-[11px] text-muted-foreground">{row.control}</span>
<span className="text-[12.5px] font-medium">{controlTitle(row.control)}</span>
<span className="ml-auto flex items-center gap-1.5">
{implComplete && <Pill tone="ok">Nachweis ✓</Pill>}
<Pill tone={MATURITY_TONE[suggestion.value]}>Vorschlag {suggestion.value}</Pill>
{confirmed !== null && <Pill tone={MATURITY_TONE[confirmed as 0 | 1 | 2 | 3]}>bestätigt {confirmed}</Pill>}
<Pill tone={belowTarget ? "warn" : "ok"}>Ziel {target}</Pill>
</span>
</div>
<div className="mt-1 flex flex-wrap items-center gap-1.5 text-[11px] text-muted-foreground" title={suggestion.reason}>
{spec.policy.length > 0 && (
<span className="flex items-center gap-1">Richtlinie: {policyTitles(spec.policy)} <Pill tone={STATUS_TONE[evidence.policy]}>{STATUS_LABEL[evidence.policy]}</Pill></span>
)}
{spec.verfahren.length > 0 && (
<span className="flex items-center gap-1">· Verfahren: {verfahrenTitles(spec.verfahren)} <Pill tone={STATUS_TONE[evidence.verfahren]}>{STATUS_LABEL[evidence.verfahren]}</Pill></span>
)}
{spec.needsAsset && <span>· Asset {evidence.assetLinked ? "✓" : "—"}</span>}
{spec.needsRisk && <span>· Risiko {evidence.riskLinked ? "✓" : "—"}</span>}
</div>
{gaps.length > 0 && (
<div className="mt-1 flex flex-wrap gap-1.5">
{gaps.map((g, i) => (
<Pill key={i} tone={g.kind === "widerspruch" ? "risk" : "warn"}>{g.missing}</Pill>
))}
</div>
)}
{hints.length > 0 && <HintList hints={hints} canUse={canUse} />}
</li>
);
}
export async function ControlsStep() {
const session = await requireSession();
const db = dbForTenant(session.user.tenantId);
const canUse = hasPermission(session, "onboarding:use");
const { rows, level, implementation } = await buildControlRows(db, session.user.tenantId);
const hintsFor = (control: string) => implementation.hintsByControl.get(control) ?? [];
const confirmedCount = rows.filter((r) => r.confirmed !== null).length;
const openGapCount = rows.reduce((n, r) => n + r.gaps.length, 0);
const unconfirmed = rows.filter((r) => r.confirmed === null);
const kpis = [
{ label: "Controls im Scope", value: rows.length },
{ label: "bestätigt", value: confirmedCount },
{ label: "Umsetzung nachgewiesen", value: implementation.completeControls.size },
{ label: "offene Punkte", value: openGapCount },
];
return (
<div className="space-y-5">
<div className="rounded-xl border bg-card p-4">
<div className="flex flex-wrap items-center justify-between gap-2">
<span className="flex items-center gap-2 font-heading text-sm font-semibold"><ShieldCheck className="size-4" /> Control-Assessment ({level})</span>
<Button variant="outline" size="sm" nativeButton={false} render={<Link href="/policies" />}>
<ListChecks className="size-4" /> Richtlinien
</Button>
</div>
<div className="mt-3 grid gap-3 sm:grid-cols-4">
{kpis.map((k) => (
<div key={k.label} className="rounded-lg border bg-[var(--surface-soft)] p-3">
<p className="font-heading text-2xl font-bold leading-none">{k.value}</p>
<p className="mt-1 text-[11.5px] text-muted-foreground">{k.label}</p>
</div>
))}
</div>
<p className="mt-3 text-[11.5px] text-muted-foreground">
Der Reifegrad-Vorschlag (0–3) wird regelbasiert aus dem Belegstand der Richtlinien-/Verfahrensdokumente,
des Inventars und des Risikoregisters hergeleitet (VDA-ISA, C5). Er ist nicht bindend — die Bestätigung
bzw. Überschreibung durch den Bearbeiter ist Pflicht. Je Control lassen sich unten die Umsetzungshinweise
(Spiegelstriche) dokumentieren; sind alle erledigt, gilt der Wirksamkeitsnachweis als erbracht und der
Vorschlag steigt auf Grad 3. Offene Punkte lassen sich je Hinweis zur Aufgabe machen.
</p>
{canUse && (
<div className="mt-3 flex flex-wrap gap-2">
<form action={confirmAllSuggestions}>
<Button type="submit" size="sm" variant="outline" disabled={unconfirmed.length === 0}>
Vorschläge übernehmen{unconfirmed.length ? ` (${unconfirmed.length})` : ""}
</Button>
</form>
<form action={createControlGapTasks}>
<Button type="submit" size="sm" variant="outline">Gap-Aufgaben anlegen{openGapCount ? ` (${openGapCount})` : ""}</Button>
</form>
</div>
)}
</div>
{canUse && (
<form action={confirmControlMaturity} className="rounded-xl border bg-card p-4">
<span className="font-heading text-sm font-semibold">Reifegrad bestätigen / überschreiben</span>
<p className="mt-1 text-[11.5px] text-muted-foreground">Absenkung unter den Vorschlag erfordert eine Begründung (C5 §4.6).</p>
<div className="mt-3 grid gap-3 sm:grid-cols-[2fr_1fr]">
<label className="text-[12px]">Control
<select name="control" required className="mt-1 w-full rounded-lg border bg-[var(--surface-soft)] px-2 py-1.5 text-[12.5px]">
{rows.map((r) => (
<option key={r.control} value={r.control}>{r.control} — {controlTitle(r.control)} (Vorschlag {r.suggestion.value})</option>
))}
</select>
</label>
<label className="text-[12px]">Reifegrad
<select name="value" required defaultValue="" className="mt-1 w-full rounded-lg border bg-[var(--surface-soft)] px-2 py-1.5 text-[12.5px]">
<option value="" disabled>wählen…</option>
{[0, 1, 2, 3].map((v) => <option key={v} value={v}>{MATURITY_LABEL[v as 0 | 1 | 2 | 3]}</option>)}
</select>
</label>
</div>
<label className="mt-3 block text-[12px]">Begründung (bei Absenkung)
<Textarea name="justification" rows={2} className="mt-1" placeholder="z. B. kompensierende Maßnahme, Restrisiko akzeptiert …" />
</label>
<div className="mt-3"><Button type="submit" size="sm">Bestätigen</Button></div>
</form>
)}
<div className="rounded-xl border bg-card p-4">
<span className="font-heading text-sm font-semibold">Controls im Assessment-Scope</span>
<ul className="mt-2">
{rows.map((r) => <ControlLine key={r.control} row={r} hints={hintsFor(r.control)} canUse={canUse} />)}
</ul>
</div>
</div>
);
}
@@ -0,0 +1,115 @@
"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";
/**
* Risikoakzeptanzkriterien & Bewertungsskala pflegen (Ebene 1 „Fundament").
*
* Schreibt die drei zentralen Konfigurationsmodelle des Risiko-Fundaments:
* - `RiskMatrixClass` : Risikoklassen mit Schwelle (maxScore) + Freigabeinstanz (acceptance)
* - `RiskEwLevel` : Eintrittswahrscheinlichkeits-Skala (Stufe/Label/Definition)
* - `RiskDamageDimension` : Schadensdimensionen (C/I/A-Bewertungsgrundlage) mit 4 Stufen
*
* EINE Pflegestelle: Der Wizard-Popup (Schritt 5) und die Einstellungen (/settings) rufen
* dieselbe Action und schreiben dieselben Werte. Speicherstrategie: pro Mandant ersetzen
* (delete + create in einer Transaktion) — analog zum verwalteten Import.
*/
const toneSchema = z.enum(["ok", "warn", "orange", "risk"]);
const payloadSchema = z.object({
matrixClasses: z
.array(
z.object({
name: z.string().trim().min(1, "Name der Risikoklasse fehlt."),
maxScore: z.coerce.number().int().min(1).max(999),
acceptance: z.string().trim().min(1, "Freigabeinstanz fehlt."),
tone: toneSchema,
}),
)
.max(12),
ewLevels: z
.array(
z.object({
level: z.coerce.number().int().min(1).max(9),
label: z.string().trim().min(1, "Label der Wahrscheinlichkeitsstufe fehlt."),
definition: z.string().trim().default(""),
}),
)
.max(9),
damageDims: z
.array(
z.object({
name: z.string().trim().min(1, "Name der Schadensdimension fehlt."),
levels: z.object({
"1": z.string().trim().default(""),
"2": z.string().trim().default(""),
"3": z.string().trim().default(""),
"4": z.string().trim().default(""),
"5": z.string().trim().default(""),
}),
}),
)
.max(20),
});
export async function saveRiskCriteria(formData: FormData) {
const session = await requireSession();
// Fundament-Pflege im Wizard und in den Einstellungen — beide Einstiege haben onboarding:use
// (Mandanten-Admin und ISB). Autoritative Prüfung (wirft ForbiddenError bei fehlendem Recht).
requirePermission(session, "onboarding:use");
const tenantId = session.user.tenantId;
const db = dbForTenant(tenantId);
const raw = String(formData.get("payload") ?? "");
let parsedJson: unknown;
try {
parsedJson = JSON.parse(raw);
} catch {
throw new Error("Ungültige Kriteriendaten.");
}
const { matrixClasses, ewLevels, damageDims } = payloadSchema.parse(parsedJson);
await db.$transaction([
db.riskMatrixClass.deleteMany({}),
db.riskMatrixClass.createMany({
data: matrixClasses.map((c, i) => ({
tenantId,
name: c.name,
maxScore: c.maxScore,
acceptance: c.acceptance,
tone: c.tone,
orderIdx: i,
})),
}),
db.riskEwLevel.deleteMany({}),
db.riskEwLevel.createMany({
data: ewLevels.map((e) => ({ tenantId, level: e.level, label: e.label, definition: e.definition })),
}),
db.riskDamageDimension.deleteMany({}),
db.riskDamageDimension.createMany({
data: damageDims.map((d, i) => ({ tenantId, name: d.name, levels: d.levels, orderIdx: i })),
}),
]);
await writeAuditLog({
tenantId,
actorId: session.user.id,
action: "update",
entity: "risk_criteria",
after: {
matrixClasses: matrixClasses.length,
ewLevels: ewLevels.length,
damageDims: damageDims.length,
},
});
revalidatePath("/onboarding");
revalidatePath("/settings");
revalidatePath("/risks");
}
@@ -0,0 +1,48 @@
"use client";
import { Pencil } from "lucide-react";
import { Popup } from "../roles/popup";
import {
CriteriaEditor,
type MatrixClassRow,
type EwLevelRow,
type DamageDimRow,
} from "./criteria-editor";
/**
* „Kriterien bearbeiten“ direkt im Wizard (Schritt 5). Öffnet den gemeinsamen
* `CriteriaEditor` im Popup (Optik wie Risiken-Popup). Schreibt über dieselbe
* Action wie die Einstellungen — eine Pflegestelle.
*/
export function CriteriaEditDialog({
matrixClasses,
ewLevels,
damageDims,
}: {
matrixClasses: MatrixClassRow[];
ewLevels: EwLevelRow[];
damageDims: DamageDimRow[];
}) {
return (
<Popup
triggerLabel="Kriterien bearbeiten"
triggerIcon={<Pencil className="size-4" />}
triggerVariant="default"
triggerSize="sm"
title="Risikoakzeptanzkriterien & Bewertungsskala bearbeiten"
sub="Risikoklassen, Eintrittswahrscheinlichkeit und Schadensdimensionen (C/I/A) — zentral gepflegt."
maxWidthClass="max-w-3xl"
>
{(close) => (
<div className="max-h-[70vh] overflow-y-auto p-5">
<CriteriaEditor
initialMatrixClasses={matrixClasses}
initialEwLevels={ewLevels}
initialDamageDims={damageDims}
onSaved={close}
/>
</div>
)}
</Popup>
);
}
@@ -0,0 +1,301 @@
"use client";
import { useState, useTransition } from "react";
import { Plus, Trash2, ShieldCheck, Scale, Activity } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { saveRiskCriteria } from "./actions";
export type Tone = "ok" | "warn" | "orange" | "risk";
export interface MatrixClassRow {
name: string;
maxScore: number;
acceptance: string;
tone: Tone;
}
export interface EwLevelRow {
level: number;
label: string;
definition: string;
}
export interface DamageDimRow {
name: string;
levels: { "1": string; "2": string; "3": string; "4": string; "5": string };
}
const TONE_OPTIONS: { value: Tone; label: string }[] = [
{ value: "ok", label: "grün (niedrig)" },
{ value: "warn", label: "gelb (mittel)" },
{ value: "orange", label: "orange (hoch)" },
{ value: "risk", label: "rot (kritisch)" },
];
const inputCls = "h-8 text-[12.5px]";
/**
* Gemeinsamer Editor für Risikoakzeptanzkriterien & Bewertungsskala. Wird sowohl im
* Wizard-Popup (Schritt 5) als auch inline in den Einstellungen verwendet und schreibt
* über dieselbe Server-Action `saveRiskCriteria` in dieselben Modelle (eine Pflegestelle).
*/
export function CriteriaEditor({
initialMatrixClasses,
initialEwLevels,
initialDamageDims,
onSaved,
}: {
initialMatrixClasses: MatrixClassRow[];
initialEwLevels: EwLevelRow[];
initialDamageDims: DamageDimRow[];
onSaved?: () => void;
}) {
const [matrixClasses, setMatrixClasses] = useState<MatrixClassRow[]>(initialMatrixClasses);
const [ewLevels, setEwLevels] = useState<EwLevelRow[]>(initialEwLevels);
const [damageDims, setDamageDims] = useState<DamageDimRow[]>(initialDamageDims);
const [error, setError] = useState<string | null>(null);
const [saved, setSaved] = useState(false);
const [pending, start] = useTransition();
function updateMatrix(idx: number, patch: Partial<MatrixClassRow>) {
setMatrixClasses((rows) => rows.map((r, i) => (i === idx ? { ...r, ...patch } : r)));
}
function updateEw(idx: number, patch: Partial<EwLevelRow>) {
setEwLevels((rows) => rows.map((r, i) => (i === idx ? { ...r, ...patch } : r)));
}
function updateDim(idx: number, patch: Partial<DamageDimRow>) {
setDamageDims((rows) => rows.map((r, i) => (i === idx ? { ...r, ...patch } : r)));
}
function updateDimLevel(idx: number, key: "1" | "2" | "3" | "4" | "5", value: string) {
setDamageDims((rows) =>
rows.map((r, i) => (i === idx ? { ...r, levels: { ...r.levels, [key]: value } } : r)),
);
}
function submit() {
setError(null);
setSaved(false);
start(async () => {
try {
const fd = new FormData();
fd.set("payload", JSON.stringify({ matrixClasses, ewLevels, damageDims }));
await saveRiskCriteria(fd);
setSaved(true);
onSaved?.();
} catch (e) {
setError(e instanceof Error ? e.message : "Speichern fehlgeschlagen.");
}
});
}
return (
<div className="space-y-5">
{/* Risikoklassen / Akzeptanzkriterien */}
<section>
<div className="flex items-center justify-between gap-2">
<p className="inline-flex items-center gap-1.5 text-[13px] font-semibold">
<Scale className="size-4" /> Risikoklassen & Akzeptanz
</p>
<Button
type="button"
size="sm"
variant="outline"
onClick={() =>
setMatrixClasses((r) => [...r, { name: "", maxScore: 0, acceptance: "", tone: "warn" }])
}
>
<Plus className="size-3.5" /> Klasse
</Button>
</div>
<p className="mt-0.5 text-[11.5px] text-muted-foreground">
Schwelle (bis Risikowert) und Freigabeinstanz je Risikoklasse.
</p>
<div className="mt-2 space-y-1.5">
<div className="grid grid-cols-[1fr_5rem_1.4fr_7rem_2rem] gap-2 px-1 text-[10.5px] font-medium text-muted-foreground max-sm:hidden">
<span>Risikoklasse</span>
<span>bis Wert</span>
<span>Akzeptanz / Freigabeinstanz</span>
<span>Farbe</span>
<span />
</div>
{matrixClasses.map((c, i) => (
<div key={i} className="grid grid-cols-1 gap-2 sm:grid-cols-[1fr_5rem_1.4fr_7rem_2rem]">
<Input
value={c.name}
onChange={(e) => updateMatrix(i, { name: e.target.value })}
placeholder="Niedrig"
className={inputCls}
/>
<Input
type="number"
min={1}
value={c.maxScore}
onChange={(e) => updateMatrix(i, { maxScore: Number(e.target.value) })}
className={inputCls}
/>
<Input
value={c.acceptance}
onChange={(e) => updateMatrix(i, { acceptance: e.target.value })}
placeholder="Akzeptanz durch …"
className={inputCls}
/>
<select
value={c.tone}
onChange={(e) => updateMatrix(i, { tone: e.target.value as Tone })}
className="h-8 rounded-md border border-input bg-background px-2 text-[12px]"
>
{TONE_OPTIONS.map((t) => (
<option key={t.value} value={t.value}>
{t.label}
</option>
))}
</select>
<Button
type="button"
size="icon-sm"
variant="ghost"
title="Klasse entfernen"
onClick={() => setMatrixClasses((r) => r.filter((_, j) => j !== i))}
className="text-muted-foreground hover:text-destructive"
>
<Trash2 className="size-3.5" />
</Button>
</div>
))}
{matrixClasses.length === 0 && (
<p className="text-[12px] text-muted-foreground">Noch keine Risikoklassen.</p>
)}
</div>
</section>
{/* Eintrittswahrscheinlichkeit */}
<section className="border-t pt-4">
<div className="flex items-center justify-between gap-2">
<p className="inline-flex items-center gap-1.5 text-[13px] font-semibold">
<Activity className="size-4" /> Eintrittswahrscheinlichkeit
</p>
<Button
type="button"
size="sm"
variant="outline"
onClick={() =>
setEwLevels((r) => [...r, { level: r.length + 1, label: "", definition: "" }])
}
>
<Plus className="size-3.5" /> Stufe
</Button>
</div>
<div className="mt-2 space-y-1.5">
{ewLevels.map((e, i) => (
<div key={i} className="grid grid-cols-1 gap-2 sm:grid-cols-[3.5rem_1fr_1.6fr_2rem]">
<Input
type="number"
min={1}
value={e.level}
onChange={(ev) => updateEw(i, { level: Number(ev.target.value) })}
className={inputCls}
aria-label="Stufe"
/>
<Input
value={e.label}
onChange={(ev) => updateEw(i, { label: ev.target.value })}
placeholder="Label"
className={inputCls}
/>
<Input
value={e.definition}
onChange={(ev) => updateEw(i, { definition: ev.target.value })}
placeholder="Definition"
className={inputCls}
/>
<Button
type="button"
size="icon-sm"
variant="ghost"
title="Stufe entfernen"
onClick={() => setEwLevels((r) => r.filter((_, j) => j !== i))}
className="text-muted-foreground hover:text-destructive"
>
<Trash2 className="size-3.5" />
</Button>
</div>
))}
{ewLevels.length === 0 && (
<p className="text-[12px] text-muted-foreground">Noch keine Stufen.</p>
)}
</div>
</section>
{/* Schadensdimensionen (C/I/A-Bewertungsgrundlage) */}
<section className="border-t pt-4">
<div className="flex items-center justify-between gap-2">
<p className="inline-flex items-center gap-1.5 text-[13px] font-semibold">
<ShieldCheck className="size-4" /> Schadensdimensionen (C/I/A-Skala)
</p>
<Button
type="button"
size="sm"
variant="outline"
onClick={() =>
setDamageDims((r) => [
...r,
{ name: "", levels: { "1": "", "2": "", "3": "", "4": "", "5": "" } },
])
}
>
<Plus className="size-3.5" /> Dimension
</Button>
</div>
<p className="mt-0.5 text-[11.5px] text-muted-foreground">
Bewertungsgrundlage der Schadensstufen 1–5 je Dimension.
</p>
<div className="mt-2 space-y-2">
{damageDims.map((d, i) => (
<div key={i} className="rounded-lg border bg-[var(--surface-soft)] p-2.5">
<div className="flex items-center gap-2">
<Input
value={d.name}
onChange={(e) => updateDim(i, { name: e.target.value })}
placeholder="Name der Dimension"
className={`${inputCls} font-medium`}
/>
<Button
type="button"
size="icon-sm"
variant="ghost"
title="Dimension entfernen"
onClick={() => setDamageDims((r) => r.filter((_, j) => j !== i))}
className="text-muted-foreground hover:text-destructive"
>
<Trash2 className="size-3.5" />
</Button>
</div>
<div className="mt-2 grid grid-cols-2 gap-2 sm:grid-cols-5">
{(["1", "2", "3", "4", "5"] as const).map((k) => (
<div key={k}>
<span className="text-[10.5px] text-muted-foreground">Stufe {k}</span>
<Input
value={d.levels[k]}
onChange={(e) => updateDimLevel(i, k, e.target.value)}
className={inputCls}
/>
</div>
))}
</div>
</div>
))}
{damageDims.length === 0 && (
<p className="text-[12px] text-muted-foreground">Noch keine Schadensdimensionen.</p>
)}
</div>
</section>
<div className="flex flex-wrap items-center gap-3 border-t pt-4">
<Button type="button" onClick={submit} disabled={pending}>
{pending ? "Speichern …" : "Speichern"}
</Button>
{saved && !error && <span className="text-[12px] text-[var(--ok)]">Gespeichert.</span>}
{error && <span className="text-[12px] text-destructive">{error}</span>}
</div>
</div>
);
}
@@ -0,0 +1,148 @@
import Link from "next/link";
import { Settings, ShieldCheck, Scale } from "lucide-react";
import { requireSession } from "@/server/auth";
import { dbForTenant } from "@/server/db";
import { hasPermission } from "@/server/rbac";
import { Pill } from "@/components/mockup-ui";
import { getAssessmentLevel } from "@/server/assessment-level";
import { CriteriaEditDialog } from "./criteria-edit-dialog";
import type { Tone } from "./criteria-editor";
/**
* Risikoakzeptanzkriterien & C/I/A-Skala (Ebene 1 „Fundament", M1). Als Fundament-
* Artefakt festgehalten: die zentrale Schutzbedarfsskala (C/I/A) und die
* Risikoakzeptanzkriterien (Bewertungsmatrix + Akzeptanzinstanzen). Beides wird
* zentral im Risiko-Modul/den Einstellungen gepflegt (keine Doppel-Datenhaltung) —
* hier zusammengeführt, damit das Fundament vollständig und prüffähig ist.
*/
export async function CriteriaStep() {
const session = await requireSession();
const db = dbForTenant(session.user.tenantId);
const canEdit = hasPermission(session, "onboarding:use");
const [level, damageDims, matrixClasses, ewLevels] = await Promise.all([
getAssessmentLevel(db),
db.riskDamageDimension.findMany({ orderBy: { orderIdx: "asc" } }),
db.riskMatrixClass.findMany({ orderBy: { orderIdx: "asc" } }),
db.riskEwLevel.findMany({ orderBy: { level: "asc" } }),
]);
const veryHigh = level === "AL3";
// Serialisierbare Editor-Daten (Popup + Einstellungen teilen sich denselben Editor).
const lvl = (v: unknown, k: string) => {
const rec = (v ?? {}) as Record<string, unknown>;
return typeof rec[k] === "string" ? (rec[k] as string) : "";
};
const editorMatrix = matrixClasses.map((c) => ({
name: c.name,
maxScore: c.maxScore,
acceptance: c.acceptance,
tone: (["ok", "warn", "orange", "risk"].includes(c.tone) ? c.tone : "warn") as Tone,
}));
const editorEw = ewLevels.map((e) => ({ level: e.level, label: e.label, definition: e.definition }));
const editorDims = damageDims.map((d) => ({
name: d.name,
levels: {
"1": lvl(d.levels, "1"),
"2": lvl(d.levels, "2"),
"3": lvl(d.levels, "3"),
"4": lvl(d.levels, "4"),
"5": lvl(d.levels, "5"),
},
}));
// Standard-Schutzbedarfsstufen (C/I/A). „sehr hoch" nur bei AL3 (Assessment-Level).
const ciaLevels = [
{ key: "normal", label: "normal", desc: "Begrenzte Auswirkungen; Standard-Schutz genügt." },
{ key: "hoch", label: "hoch", desc: "Beträchtliche Auswirkungen; erhöhter Schutzbedarf." },
...(veryHigh ? [{ key: "sehr_hoch", label: "sehr hoch", desc: "Existenzbedrohende/katastrophale Auswirkungen; höchster Schutzbedarf." }] : []),
];
return (
<div className="space-y-5">
{/* C/I/A-Schutzbedarfsskala */}
<div className="rounded-xl border bg-card p-4">
<div className="flex flex-wrap items-center justify-between gap-2">
<span className="inline-flex items-center gap-1.5 font-heading text-sm font-semibold"><ShieldCheck className="size-4" /> C/I/A-Schutzbedarfsskala</span>
<Pill tone={veryHigh ? "risk" : "info"}>{level}</Pill>
</div>
<p className="mt-1 text-[11.5px] text-muted-foreground">
Einheitliche Skala für Vertraulichkeit (C), Integrität (I) und Verfügbarkeit (A). Die Stufe
„sehr hoch“ ist {veryHigh ? "durch das Assessment-Level AL3 aktiv" : "erst ab Assessment-Level AL3 relevant"}.
</p>
<div className="mt-3 grid gap-2 sm:grid-cols-3">
{ciaLevels.map((l) => (
<div key={l.key} className="rounded-lg border bg-[var(--surface-soft)] p-3">
<p className="text-[13px] font-semibold">{l.label}</p>
<p className="mt-0.5 text-[11.5px] text-muted-foreground">{l.desc}</p>
</div>
))}
</div>
{damageDims.length > 0 && (
<div className="mt-3">
<p className="text-[11.5px] font-medium text-muted-foreground">Schadensdimensionen (Bewertungsgrundlage):</p>
<ul className="mt-1 flex flex-wrap gap-1.5">
{damageDims.map((d) => (
<li key={d.id}><Pill tone="info">{d.name}</Pill></li>
))}
</ul>
</div>
)}
</div>
{/* Risikoakzeptanzkriterien */}
<div className="rounded-xl border bg-card p-4">
<div className="flex flex-wrap items-center justify-between gap-2">
<span className="inline-flex items-center gap-1.5 font-heading text-sm font-semibold"><Scale className="size-4" /> Risikoakzeptanzkriterien</span>
<div className="flex items-center gap-2">
{canEdit && (
<CriteriaEditDialog matrixClasses={editorMatrix} ewLevels={editorEw} damageDims={editorDims} />
)}
<Link href="/settings" className="inline-flex items-center gap-1 text-[11.5px] font-medium text-[var(--primary)] hover:underline">
<Settings className="size-3.5" /> in Einstellungen
</Link>
</div>
</div>
{matrixClasses.length > 0 ? (
<div className="mt-3 overflow-x-auto">
<table className="w-full text-[12.5px]">
<thead>
<tr className="border-b text-left text-[11px] text-muted-foreground">
<th className="py-1.5 pr-3 font-medium">Risikoklasse</th>
<th className="py-1.5 pr-3 font-medium">bis Risikowert</th>
<th className="py-1.5 font-medium">Akzeptanz / Freigabeinstanz</th>
</tr>
</thead>
<tbody>
{matrixClasses.map((c) => (
<tr key={c.id} className="border-b last:border-0">
<td className="py-1.5 pr-3"><b>{c.name}</b></td>
<td className="py-1.5 pr-3 tabular-nums">{c.maxScore}</td>
<td className="py-1.5">{c.acceptance}</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<p className="mt-2 text-[12.5px] text-muted-foreground">
Noch keine Risikobewertungsmatrix hinterlegt. Die Akzeptanzkriterien (Schwellen und
Freigabeinstanzen je Risikoklasse) werden zentral gepflegt und hier als Fundament-Artefakt gespiegelt.
</p>
)}
{ewLevels.length > 0 && (
<p className="mt-3 text-[11px] text-muted-foreground">
Eintrittswahrscheinlichkeit: {ewLevels.map((e) => `${e.level} = ${e.label}`).join(" · ")}
</p>
)}
<p className="mt-3 text-[11.5px] text-muted-foreground">
Risikoeigner akzeptieren Restrisiken gemäß dieser Kriterien; Ausnahmen oberhalb der
Akzeptanzschwelle erfordern eine dokumentierte Managemententscheidung.
</p>
</div>
</div>
);
}
@@ -0,0 +1,257 @@
"use client";
import { useEffect, useRef, useState, useTransition } from "react";
import { Search, Plus, Link2, AlertTriangle, Sparkles } from "lucide-react";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import type { InfoLabel } from "@prisma/client";
import { Pill } from "@/components/mockup-ui";
import { infoLabelLabel } from "@/lib/asset-labels";
import {
searchAssets,
addPrimaryInformation,
linkPrimaryInformation,
type AssetSuggestion,
} from "@/server/actions/structure";
import {
nameSimilarity,
normalizeAssetName,
FUZZY_SIMILARITY_THRESHOLD,
} from "@/lib/normalize-asset";
const INFO_LABELS: { value: string; label: string }[] = [
{ value: "NONE", label: "ohne" },
{ value: "INFO_HIGH", label: "Info hoch" },
{ value: "INFO_VERY_HIGH", label: "Info sehr hoch" },
{ value: "PROTOTYPE", label: "Prototyp" },
{ value: "PERSONAL_DATA", label: "personenbezogen" },
];
/**
* M2 §2.1 — Combobox zur prozessgeführten Erfassung PRIMÄRER Informations-Assets
* mit Dedup + Autovervollständigung.
*
* - As-you-type ruft `searchAssets` (server) → Vorschläge (type INFORMATION/DATA),
* inkl. „bereits erfasst in Prozess X". Ein Klick VERKNÜPFT das bestehende Asset
* (linkPrimaryInformation) statt ein Duplikat anzulegen.
* - Weiche Fuzzy-Warnung: in-app Levenshtein (`nameSimilarity`) auf der
* Kandidatenliste — warnt „Meintest du …?" VOR dem Anlegen.
* - Exakt-Dedup erzwingt zusätzlich die DB (@@unique) im Schreibpfad.
*/
export function InformationCombobox({
processId,
suggestedLabels = [],
hideType = false,
hideLabel = false,
}: {
processId: string;
/** Katalog-Vorschläge (suggestedInfoLabels) des Prozesses — als Ein-Klick-Defaults. */
suggestedLabels?: InfoLabel[];
/** BIA-Info-Schritt: Typ-Auswahl (Information/Daten) ausblenden — immer INFORMATION. */
hideType?: boolean;
/** BIA-Info-Schritt: Klassifizierungs-Dropdown ausblenden — Schutzstufe folgt aus C/I/A. */
hideLabel?: boolean;
}) {
const [query, setQuery] = useState("");
const [type, setType] = useState<"INFORMATION" | "DATA">("INFORMATION");
const [label, setLabel] = useState("NONE");
const [results, setResults] = useState<AssetSuggestion[]>([]);
const [open, setOpen] = useState(false);
const [searching, startSearch] = useTransition();
const [saving, startSave] = useTransition();
const seq = useRef(0);
// Debounced As-you-type-Suche gegen das Tenant-Asset-Register. Das Leeren/Suchen
// läuft im Timeout-Callback (nicht synchron im Effect-Body), um kaskadierende
// Renders zu vermeiden (react-hooks/set-state-in-effect).
useEffect(() => {
const q = query.trim();
const mySeq = ++seq.current;
const handle = setTimeout(() => {
if (q.length < 2) {
if (mySeq === seq.current) {
setResults([]);
setOpen(false);
}
return;
}
startSearch(async () => {
const rows = await searchAssets(q);
if (mySeq === seq.current) {
setResults(rows);
setOpen(true);
}
});
}, 200);
return () => clearTimeout(handle);
}, [query]);
const normQuery = normalizeAssetName(query);
const exactHit = results.find((r) => r.normalizedName === normQuery && normQuery.length > 0);
// Nah-Duplikate (weiche Warnung): ähnlich, aber nicht exakt gleich.
const fuzzyHits = results
.filter((r) => r.normalizedName !== normQuery)
.map((r) => ({ r, sim: nameSimilarity(query, r.name) }))
.filter((x) => x.sim >= FUZZY_SIMILARITY_THRESHOLD)
.sort((a, b) => b.sim - a.sim)
.slice(0, 3);
function link(assetId: string) {
startSave(async () => {
const fd = new FormData();
fd.set("assetId", assetId);
await linkPrimaryInformation(processId, fd);
reset();
});
}
function createNew() {
if (!normQuery) return;
startSave(async () => {
const fd = new FormData();
fd.set("name", query.trim());
fd.set("type", type);
fd.set("label", label);
await addPrimaryInformation(processId, fd);
reset();
});
}
function reset() {
setQuery("");
setResults([]);
setOpen(false);
setLabel("NONE");
}
return (
<div className="relative">
<div className="flex flex-wrap items-center gap-2">
<div className="relative min-w-[220px] flex-1">
<Search className="pointer-events-none absolute top-1/2 left-2 size-3.5 -translate-y-1/2 text-muted-foreground" />
<Input
value={query}
onChange={(e) => setQuery(e.target.value)}
onFocus={() => results.length && setOpen(true)}
placeholder="Informationswert erfassen (z. B. Kundendaten) …"
className="pl-7"
aria-label="Informationswert suchen oder erfassen"
/>
</div>
{!hideType && (
<select
value={type}
onChange={(e) => setType(e.target.value as "INFORMATION" | "DATA")}
className="h-8 rounded-lg border border-input bg-transparent px-2 text-sm"
aria-label="Werttyp"
>
<option value="INFORMATION">Information</option>
<option value="DATA">Daten</option>
</select>
)}
{!hideLabel && (
<select
value={label}
onChange={(e) => setLabel(e.target.value)}
className="h-8 rounded-lg border border-input bg-transparent px-2 text-sm"
aria-label="Klassifizierungslabel"
>
{INFO_LABELS.map((l) => (
<option key={l.value} value={l.value}>
{l.label}
</option>
))}
</select>
)}
<Button
type="button"
size="sm"
onClick={createNew}
disabled={saving || !normQuery || !!exactHit}
>
<Plus className="size-4" /> Neu anlegen
</Button>
</div>
{/* Katalog-Vorschläge (M2): suggestedInfoLabels des Prozesses als Ein-Klick-Default. */}
{!hideLabel && suggestedLabels.length > 0 && (
<div className="mt-2 flex flex-wrap items-center gap-1.5 text-[11px] text-muted-foreground">
<span className="inline-flex items-center gap-1 font-medium">
<Sparkles className="size-3.5" /> Katalog-Vorschlag:
</span>
{suggestedLabels.map((l) => (
<button
key={l}
type="button"
onClick={() => setLabel(l)}
className={`rounded-full border px-2 py-0.5 hover:bg-muted ${
label === l ? "border-[var(--brand)] bg-muted font-medium" : ""
}`}
aria-pressed={label === l}
>
{infoLabelLabel(l)}
</button>
))}
</div>
)}
{/* Weiche Fuzzy-Warnung (§2.1): Nah-Duplikate vor dem Anlegen. */}
{!exactHit && fuzzyHits.length > 0 && normQuery.length >= 2 && (
<div className="mt-2 rounded-lg border border-[var(--band-brd,var(--warn))] bg-[var(--band,transparent)] p-2 text-[12px]">
<span className="inline-flex items-center gap-1 font-medium">
<AlertTriangle className="size-3.5 text-[var(--warn)]" /> Ähnlicher Wert vorhanden — meintest du:
</span>
<div className="mt-1 flex flex-wrap gap-1.5">
{fuzzyHits.map(({ r, sim }) => (
<button
key={r.id}
type="button"
onClick={() => link(r.id)}
disabled={saving}
className="inline-flex items-center gap-1 rounded-full border px-2 py-0.5 hover:bg-muted"
>
<Link2 className="size-3" /> {r.name}{" "}
<span className="text-muted-foreground">({Math.round(sim * 100)}%)</span>
</button>
))}
</div>
</div>
)}
{/* Autocomplete-Liste */}
{open && results.length > 0 && (
<ul className="shadow-card absolute z-20 mt-1 max-h-72 w-full overflow-auto rounded-xl border bg-card p-1 text-sm">
{results.map((r) => {
const isExact = r.normalizedName === normQuery && normQuery.length > 0;
return (
<li key={r.id}>
<button
type="button"
onClick={() => link(r.id)}
disabled={saving}
className="flex w-full items-start gap-2 rounded-lg px-2 py-1.5 text-left hover:bg-[var(--surface-soft)]"
>
<Link2 className="mt-0.5 size-3.5 shrink-0 text-muted-foreground" />
<span className="flex-1">
<span className="font-medium">{r.name}</span>
<span className="ml-1.5 text-[11px] text-muted-foreground">{r.type}</span>
{r.processes.length > 0 && (
<span className="mt-0.5 block text-[11px] text-muted-foreground">
bereits erfasst in: {r.processes.join(", ")}
</span>
)}
</span>
{isExact ? <Pill tone="ok">verknüpfen</Pill> : <Pill tone="info">vorhanden</Pill>}
</button>
</li>
);
})}
</ul>
)}
{searching && query.trim().length >= 2 && (
<p className="mt-1 text-[11px] text-muted-foreground">suche …</p>
)}
</div>
);
}
@@ -0,0 +1,117 @@
import Link from "next/link";
import { Database } from "lucide-react";
import { requireSession } from "@/server/auth";
import { dbForTenant } from "@/server/db";
import { hasPermission } from "@/server/rbac";
import { Button } from "@/components/ui/button";
import { Pill } from "@/components/mockup-ui";
import { getProcessCatalogSuggestions } from "@/server/actions/processes";
import { InformationCombobox } from "./information-combobox";
/**
* M2 Schritt „Information" (Ebene 2). Kernprinzip: Ein Informationswert IST ein
* primäres Asset (type INFORMATION/DATA) — kein Extra-Objekt. Je Prozess werden hier
* die primären Informations-Assets erfasst, mit Dedup + Autovervollständigung (§2.1,
* Combobox). Träger-Assets folgen im nächsten Schritt via ProcessAsset(SECONDARY).
*/
export async function InformationStep() {
const session = await requireSession();
const db = dbForTenant(session.user.tenantId);
const canUse = hasPermission(session, "onboarding:use");
const processes = await db.process.findMany({
orderBy: { name: "asc" },
select: {
id: true,
name: true,
category: true,
processAssets: {
where: { role: "PRIMARY", asset: { type: { in: ["INFORMATION", "DATA"] } } },
select: { asset: { select: { id: true, name: true, type: true, label: true } } },
},
},
});
const totalPrimary = await db.asset.count({ where: { type: { in: ["INFORMATION", "DATA"] } } });
const withoutProcess = processes.filter((p) => p.processAssets.length === 0).length;
// M2: Katalog-Vorschläge (suggestedInfoLabels) je übernommenem Prozess als Defaults.
const suggestions = await getProcessCatalogSuggestions();
const labelsByProcess = new Map(
suggestions.map((s) => [s.processId, s.suggestedInfoLabels.filter((l) => l !== "NONE")]),
);
return (
<div className="space-y-5">
<div className="rounded-xl border bg-card p-4">
<div className="flex flex-wrap items-center justify-between gap-2">
<span className="font-heading text-sm font-semibold">Informationswerte je Prozess</span>
<Button variant="outline" size="sm" nativeButton={false} render={<Link href="/assets" />}>
<Database className="size-4" /> Zum Assetinventar
</Button>
</div>
<p className="mt-2 text-[11.5px] text-muted-foreground">
Ein Informationswert ist ein <b>primäres Asset</b> (Information/Daten) — kein Extra-Objekt.
Erfassen mit Dedup: gleiche Werte (auch bei anderer Schreibweise) werden verknüpft statt
doppelt angelegt. Ähnliche Werte werden vor dem Anlegen gewarnt.
</p>
<div className="mt-3 grid gap-3 sm:grid-cols-3">
{[
{ label: "Prozesse", value: processes.length },
{ label: "primäre Informationswerte", value: totalPrimary },
{ label: "Prozesse ohne Informationswert", value: withoutProcess },
].map((k) => (
<div key={k.label} className="rounded-lg border bg-[var(--surface-soft)] p-3">
<p className="font-heading text-2xl font-bold leading-none">{k.value}</p>
<p className="mt-1 text-[11.5px] text-muted-foreground">{k.label}</p>
</div>
))}
</div>
</div>
{processes.length === 0 ? (
<div className="rounded-xl border bg-card p-4 text-[12.5px] text-muted-foreground">
Noch keine Prozesse. Lege im vorigen Schritt „Prozesse“ welche an (Katalog oder eigene),
dann erfasse hier die zugehörigen Informationswerte.
</div>
) : (
<div className="space-y-3">
{processes.map((p) => (
<div key={p.id} className="rounded-xl border bg-card p-4">
<div className="flex flex-wrap items-center justify-between gap-2">
<span className="font-heading text-sm font-semibold">{p.name}</span>
<Pill tone="mut">{p.category}</Pill>
</div>
{p.processAssets.length > 0 ? (
<ul className="mt-2 flex flex-wrap gap-1.5">
{p.processAssets.map((pa) => (
<li
key={pa.asset.id}
className="inline-flex items-center gap-1 rounded-full border bg-[var(--surface-soft)] px-2 py-0.5 text-[12px]"
>
{pa.asset.name}
<span className="text-[10.5px] text-muted-foreground">{pa.asset.type}</span>
{pa.asset.label !== "NONE" && <Pill tone="warn">{pa.asset.label}</Pill>}
</li>
))}
</ul>
) : (
<p className="mt-2 text-[12px] text-muted-foreground">
Noch kein primärer Informationswert erfasst.
</p>
)}
{canUse && (
<div className="mt-3">
<InformationCombobox
processId={p.id}
suggestedLabels={labelsByProcess.get(p.id) ?? []}
/>
</div>
)}
</div>
))}
</div>
)}
</div>
);
}
@@ -0,0 +1,18 @@
import { getTranslations } from "next-intl/server";
import type { StepProps } from "@/lib/onboarding/registry";
/**
* Platzhalter-Schritt (Story A1-1). Die echten Schritt-Komponenten werden über die
* Step-Registry eingeklinkt: `scoping` (A2), `context`/`policies` (Dev B), weitere Schritte
* in Folge-Stories. Bis dahin rendert die Shell diesen Platzhalter, damit Navigation,
* State-Machine und Gate bereits vollständig demonstrierbar sind.
*/
export async function PlaceholderStep({ stepKey }: StepProps) {
const t = await getTranslations("onboarding");
return (
<div className="rounded-xl border border-dashed bg-[var(--surface-soft)] p-6 text-sm text-muted-foreground">
<p className="font-heading text-[15px] font-semibold text-foreground">{t(`stepTitle.${stepKey}`)}</p>
<p className="mt-1">{t("placeholderNote")}</p>
</div>
);
}
@@ -0,0 +1,139 @@
import Link from "next/link";
import { BookOpen, FileCheck2 } from "lucide-react";
import { requireSession } from "@/server/auth";
import { dbForTenant } from "@/server/db";
import { hasPermission } from "@/server/rbac";
import { Button } from "@/components/ui/button";
import { Pill } from "@/components/mockup-ui";
import { PolicyDomainView } from "@/components/policy-domain-view";
import { createPolicyGapTasks } from "@/server/actions/onboarding-steps";
/**
* Richtlinien-/Leitlinien-Schritt (Ebene 1 „Fundament", M1). Setzt auf dem bestehenden
* Richtlinienmodul auf (Import/Coverage/Freigabe, Stories B4–B6) — keine Doppel-
* Datenhaltung. Die Informationssicherheits-Leitlinie & das Management-Commitment
* werden als Fundament-Artefakt gespiegelt (kein Cockpit-Task); zusätzlich Übernahme-/
* Freigabestand des Pakets und Aufgaben für Lücken.
*/
export async function PoliciesStep() {
const session = await requireSession();
const db = dbForTenant(session.user.tenantId);
const canUse = hasPermission(session, "onboarding:use");
// Fachbereichs-Bearbeitung (Zuordnung/Editieren/Zur-Prüfung) nur mit policy:write —
// sonst wird die Ansicht rein lesend gerendert.
const canWritePolicies = hasPermission(session, "policy:write");
const [leitlinie, state, total, freigegeben, entwurf, openApprovals, requirements] = await Promise.all([
db.policyDocument.findFirst({ where: { type: "LEITLINIE", archivedAt: null }, select: { code: true, title: true, status: true, version: true, approvedAt: true } }),
db.policyPackageState.findFirst({ select: { importedVersion: true } }),
db.policyDocument.count({ where: { archivedAt: null } }),
db.policyDocument.count({ where: { archivedAt: null, status: "FREIGEGEBEN" } }),
db.policyDocument.count({ where: { archivedAt: null, status: "ENTWURF" } }),
db.task.count({ where: { type: "policy_approval", status: "OPEN" } }),
db.policyRequirement.count({ where: { archivedAt: null } }),
]);
const leitlinieFreigegeben = leitlinie?.status === "FREIGEGEBEN";
const kpis = [
{ label: "Dokumente", value: total },
{ label: "freigegeben", value: freigegeben },
{ label: "abgedeckte Anforderungen", value: requirements },
];
const gaps = [
!state?.importedVersion ? { label: "Vorlagenpaket noch nicht übernommen (Paket-Updates)", tone: "warn" as const } : null,
entwurf > 0 ? { label: `${entwurf} Richtlinie(n) im Entwurf`, tone: "warn" as const } : null,
openApprovals > 0 ? { label: `${openApprovals} offene Freigabe(n)`, tone: "info" as const } : null,
].filter(Boolean) as { label: string; tone: "warn" | "info" }[];
return (
<div className="space-y-5">
{/* Leitlinie & Management-Commitment — Fundament-Artefakt (kein Cockpit-Task) */}
<div className="rounded-xl border border-l-[3px] border-l-[var(--primary)] bg-[var(--surface-soft)] p-4">
<div className="flex flex-wrap items-center justify-between gap-2">
<span className="inline-flex items-center gap-1.5 font-heading text-sm font-semibold"><FileCheck2 className="size-4" /> Informationssicherheits-Leitlinie &amp; Management-Commitment</span>
{leitlinie ? (
<Pill tone={leitlinieFreigegeben ? "ok" : "warn"}>{leitlinieFreigegeben ? "freigegeben" : "in Arbeit"}</Pill>
) : (
<Pill tone="warn">nicht vorhanden</Pill>
)}
</div>
{leitlinie ? (
<p className="mt-1.5 text-[12.5px] text-muted-foreground">
<b>{leitlinie.code} · {leitlinie.title}</b> (v{leitlinie.version})
{leitlinieFreigegeben
? " — von der Leitung freigegeben; dokumentiert das Management-Commitment zum ISMS."
: " — noch nicht freigegeben. Die Freigabe durch die oberste Leitung ist das Fundament (Verpflichtungserklärung)."}
</p>
) : (
<p className="mt-1.5 text-[12.5px] text-muted-foreground">
Es ist noch keine Leitlinie (L00) angelegt. Sie ist das Fundament-Artefakt der Ebene 1 —
die oberste Leitung verpflichtet sich darin zum ISMS. Über das Richtlinien-Modul anlegen/übernehmen.
</p>
)}
<div className="mt-2">
<Button variant="outline" size="sm" nativeButton={false} render={<Link href={`/policies${leitlinie?.code ? `/${leitlinie.code}` : ""}`} />}>
<BookOpen className="size-4" /> Leitlinie öffnen
</Button>
</div>
</div>
<div className="rounded-xl border bg-card p-4">
<div className="flex flex-wrap items-center justify-between gap-2">
<span className="font-heading text-sm font-semibold">Richtlinien &amp; Verfahren</span>
<div className="flex gap-2">
<Button variant="outline" size="sm" nativeButton={false} render={<Link href="/policies/updates" />}>Paket-Updates</Button>
<Button variant="outline" size="sm" nativeButton={false} render={<Link href="/policies?view=domains" />}><BookOpen className="size-4" /> Zu den Richtlinien</Button>
</div>
</div>
<div className="mt-3 grid gap-3 sm:grid-cols-3">
{kpis.map((k) => (
<div key={k.label} className="rounded-lg border bg-[var(--surface-soft)] p-3">
<p className="font-heading text-2xl font-bold leading-none">{k.value}</p>
<p className="mt-1 text-[11.5px] text-muted-foreground">{k.label}</p>
</div>
))}
</div>
<p className="mt-3 text-[11.5px] text-muted-foreground">
Übernommene Paketversion: {state?.importedVersion ?? "—"}. Der Wizard nutzt das bestehende
Richtlinienmodul (Import/Upload, Coverage, Vier-Augen-Freigabe) — hier direkt nach Fachbereich.
</p>
{/* Richtlinien nach Fachbereich — Zuordnung, Bearbeiten, „Zur Prüfung geben" direkt im Schritt.
Ohne policy:write rein lesend (geteilte Komponente, identisch zu /policies?view=domains). */}
<div className="mt-4 border-t pt-4">
<p className="font-heading text-[13px] font-semibold">Richtlinien nach Fachbereich</p>
<p className="mt-0.5 text-[11.5px] text-muted-foreground">
{canWritePolicies
? "Fachbereich zuordnen, bearbeiten und zur Prüfung geben — aus dem primären Control abgeleitet, manuell überschreibbar."
: "Übersicht nach Fachbereich (Lesezugriff). Zum Bearbeiten fehlt die Berechtigung „policy:write“."}
</p>
<PolicyDomainView db={db} currentUserId={session.user.id} canWrite={canWritePolicies} compact returnTo="/onboarding?step=policy" />
</div>
</div>
<div className="rounded-xl border bg-card p-4">
<div className="flex flex-wrap items-center justify-between gap-2">
<span className="font-heading text-sm font-semibold">Vollständigkeit</span>
{canUse && (
<form action={createPolicyGapTasks}>
<Button type="submit" size="sm" variant="outline">Prüfen &amp; Aufgaben anlegen{gaps.length ? ` (${gaps.length})` : ""}</Button>
</form>
)}
</div>
{gaps.length === 0 ? (
<p className="mt-2 text-[12.5px] text-muted-foreground">Paket übernommen, keine Entwürfe/offenen Freigaben — Richtlinienstand ist assessment-tauglich.</p>
) : (
<ul className="mt-3 space-y-2">
{gaps.map((g) => (
<li key={g.label} className="flex items-center gap-2 border-b pb-2 text-[12.5px] last:border-0">
<Pill tone={g.tone}>{g.tone === "info" ? "Hinweis" : "Lücke"}</Pill>
<span>{g.label}</span>
</li>
))}
</ul>
)}
</div>
</div>
);
}
@@ -0,0 +1,287 @@
import Link from "next/link";
import { getTranslations } from "next-intl/server";
import { ChevronRight, Plus, Trash2, Workflow } from "lucide-react";
import { requireSession } from "@/server/auth";
import { dbForTenant } from "@/server/db";
import { hasPermission } from "@/server/rbac";
import { Button } from "@/components/ui/button";
import { CriticalityPill, OwnerChip, Pill, Tag } from "@/components/mockup-ui";
import { adoptCatalogProcess, deleteProcess, toggleProcessScope } from "@/server/actions/processes";
import { BIA_TONE } from "@/components/bia-popup";
const BASE = "/onboarding?step=processes";
const biaHref = (id: string) => `${BASE}&bia=${id}&biaStep=1`;
// Bahnen des Prozesshauses (VDA/TISAX-Ordnung: Management oben, Kern in der Mitte,
// Unterstützung unten).
const LANES = ["MANAGEMENT", "CORE", "SUPPORT"] as const;
/** Linker Rahmen der Kachel nach biaStatus (Farbkennzeichnung offen/teilweise/komplett). */
const BIA_BORDER: Record<string, string> = {
offen: "border-l-[rgba(139,147,173,0.55)]",
teilweise: "border-l-[var(--warn)]",
komplett: "border-l-[var(--ok)]",
};
/**
* TISAX v3A — Prozesshaus als Onboarding-Schritt. Bahnen Management / Kern /
* Unterstützung; je Prozess eine Kachel mit Aktivierungs-Schalter (inScope), Owner +
* Stellvertreter (bzw. „unbesetzt"), Haupt-/Teilprozess-Tiefe (parentId, aufklappbar)
* und Farbkennzeichnung nach biaStatus. Klick auf die Kachel öffnet das geführte
* BIA-Popup (Informationswert → Träger → Schutzbedarf → Risiken → Abschluss).
*/
export async function ProcessesStep() {
const session = await requireSession();
const db = dbForTenant(session.user.tenantId);
const t = await getTranslations("processHouse");
const tCat = await getTranslations("processCategory");
const tc = await getTranslations("common");
const canUse = hasPermission(session, "onboarding:use") && hasPermission(session, "bia:write");
const [processes, users, catalog] = await Promise.all([
db.process.findMany({
orderBy: { name: "asc" },
select: {
id: true,
name: true,
category: true,
catalogCode: true,
inScope: true,
biaStatus: true,
ownerId: true,
deputyOwnerId: true,
parentId: true,
owner: { select: { name: true } },
bia: { select: { criticality: true } },
processAssets: {
select: { role: true, asset: { select: { type: true, confidentiality: true, integrity: true, availability: true } } },
},
_count: { select: { risks: true } },
},
}),
db.user.findMany({ where: { status: "ACTIVE" }, select: { id: true, name: true }, orderBy: { name: "asc" } }),
db.processCatalogEntry.findMany({ orderBy: { orderIdx: "asc" } }),
]);
type Node = (typeof processes)[number];
const usersById = new Map(users.map((u) => [u.id, u.name]));
const childrenByParent = new Map<string, Node[]>();
for (const p of processes) {
if (p.parentId) {
const arr = childrenByParent.get(p.parentId) ?? [];
arr.push(p);
childrenByParent.set(p.parentId, arr);
}
}
const topLevel = processes.filter((p) => !p.parentId);
// Katalog-Übernahme: bereits vorhandene Codes/Namen markieren.
const takenCodes = new Set(processes.map((p) => p.name));
const takenCatalog = new Set(processes.map((p) => p.catalogCode).filter((c): c is string => !!c));
// Katalog-Prozess-Tiefe: nur Hauptprozesse listen, Teilprozesse (parentCode) je
// Hauptprozess zählen — beim „Übernehmen" werden sie automatisch mit angelegt.
const catalogTop = catalog.filter((c) => !c.parentCode);
const catalogChildCount = new Map<string, number>();
for (const c of catalog) {
if (c.parentCode) catalogChildCount.set(c.parentCode, (catalogChildCount.get(c.parentCode) ?? 0) + 1);
}
const inScopeCount = processes.filter((p) => p.inScope).length;
const doneCount = processes.filter((p) => p.biaStatus === "komplett").length;
function progressDots(p: Node) {
const primaries = p.processAssets.filter(
(pa) => pa.role === "PRIMARY" && (pa.asset.type === "INFORMATION" || pa.asset.type === "DATA"),
);
const secondaries = p.processAssets.filter((pa) => pa.role === "SECONDARY");
const dots: { label: string; ok: boolean }[] = [
{ label: t("dotInfo"), ok: primaries.length > 0 },
{ label: t("dotCarrier"), ok: secondaries.length > 0 },
{ label: t("dotCia"), ok: primaries.some((pa) => pa.asset.confidentiality > 1 || pa.asset.integrity > 1 || pa.asset.availability > 1) },
{ label: t("dotRisk"), ok: p._count.risks > 0 },
];
return dots;
}
function tile(p: Node, depth: number) {
const kids = childrenByParent.get(p.id) ?? [];
const deputyName = p.deputyOwnerId ? usersById.get(p.deputyOwnerId) : null;
return (
<div key={p.id} className={depth > 0 ? "ml-4 border-l pl-3" : ""}>
<div
className={`rounded-xl border border-l-[3px] bg-card p-3 ${BIA_BORDER[p.biaStatus] ?? BIA_BORDER.offen} ${
p.inScope ? "" : "opacity-60"
}`}
>
<div className="flex flex-wrap items-center gap-2">
<Link href={biaHref(p.id)} className="flex-1 font-heading text-[13.5px] font-semibold hover:underline">
{p.name}
</Link>
<Pill tone={BIA_TONE[p.biaStatus] ?? "mut"}>{t(`status.${p.biaStatus}`)}</Pill>
{p.bia && <CriticalityPill level={p.bia.criticality} label={String(p.bia.criticality)} />}
</div>
<div className="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1.5 text-[12px]">
<OwnerChip name={p.owner?.name} noOwnerLabel={t("unassigned")} />
<span className="text-muted-foreground">
{t("deputy")}: {deputyName ?? <span className="italic">{t("unassigned")}</span>}
</span>
</div>
<div className="mt-2 flex flex-wrap items-center gap-1.5">
{progressDots(p).map((d) => (
<Pill key={d.label} tone={d.ok ? "ok" : "mut"}>{d.label}</Pill>
))}
</div>
<div className="mt-2.5 flex flex-wrap items-center gap-2">
<Button size="sm" nativeButton={false} render={<Link href={biaHref(p.id)} />}>
{t("openBia")} <ChevronRight className="size-3.5" />
</Button>
{canUse && (
<form action={toggleProcessScope.bind(null, p.id)}>
<input type="hidden" name="inScope" value={p.inScope ? "" : "on"} />
<Button type="submit" size="sm" variant="outline">
{p.inScope ? t("deactivate") : t("activate")}
</Button>
</form>
)}
<Button size="sm" variant="ghost" nativeButton={false} render={<Link href={`${BASE}&detail=${p.id}`} />}>
{t("details")}
</Button>
{canUse && (
<details className="relative">
<summary className="inline-flex h-7 cursor-pointer list-none items-center gap-1 rounded-[min(var(--radius-md),12px)] border border-input px-2.5 text-[0.8rem] font-semibold text-destructive select-none hover:bg-destructive/10 [&::-webkit-details-marker]:hidden">
<Trash2 className="size-3.5" /> {t("delete")}
</summary>
<div className="shadow-card absolute right-0 z-20 mt-1 w-72 rounded-xl border bg-card p-3 text-[12px]">
<p className="mb-2.5 text-muted-foreground">{t("deleteConfirm")}</p>
<form action={deleteProcess.bind(null, p.id)}>
<input type="hidden" name="returnTo" value="house" />
<Button type="submit" size="sm" variant="destructive">
<Trash2 className="size-3.5" /> {t("deleteConfirmBtn")}
</Button>
</form>
</div>
</details>
)}
</div>
</div>
{kids.length > 0 && (
<details className="mt-2" open>
<summary className="cursor-pointer text-[11.5px] font-semibold text-muted-foreground">
{t("subProcesses", { count: kids.length })}
</summary>
<div className="mt-2 space-y-2">{kids.map((k) => tile(k, depth + 1))}</div>
</details>
)}
</div>
);
}
return (
<div className="space-y-5">
{/* Kopf + Kennzahlen + Anlegen */}
<div className="rounded-xl border bg-card p-4">
<div className="flex flex-wrap items-center justify-between gap-2">
<span className="inline-flex items-center gap-1.5 font-heading text-sm font-semibold">
<Workflow className="size-4" /> {t("title")}
</span>
<div className="flex gap-2">
{canUse && (
<Button size="sm" nativeButton={false} render={<Link href={`${BASE}&new=1`} />}>
<Plus className="size-4" /> {t("newProcess")}
</Button>
)}
<Button variant="outline" size="sm" nativeButton={false} render={<Link href="/processes" />}>
{t("toModule")}
</Button>
</div>
</div>
<p className="mt-2 text-[11.5px] text-muted-foreground">{t("intro")}</p>
<div className="mt-3 grid gap-3 sm:grid-cols-3">
{[
{ label: t("kpiTotal"), value: processes.length },
{ label: t("kpiInScope"), value: inScopeCount },
{ label: t("kpiDone"), value: doneCount },
].map((k) => (
<div key={k.label} className="rounded-lg border bg-[var(--surface-soft)] p-3">
<p className="font-heading text-2xl font-bold leading-none">{k.value}</p>
<p className="mt-1 text-[11.5px] text-muted-foreground">{k.label}</p>
</div>
))}
</div>
{/* Legende Farbkennzeichnung */}
<div className="mt-3 flex flex-wrap items-center gap-3 text-[11px] text-muted-foreground">
<span className="font-medium">{t("legend")}:</span>
{(["offen", "teilweise", "komplett"] as const).map((s) => (
<span key={s} className="inline-flex items-center gap-1.5">
<i className={`inline-block h-3 w-3 rounded-sm border-l-[3px] ${BIA_BORDER[s]} bg-card`} />
{t(`status.${s}`)}
</span>
))}
</div>
</div>
{/* Bahnen */}
{processes.length === 0 ? (
<div className="rounded-xl border bg-card p-4 text-[12.5px] text-muted-foreground">{t("empty")}</div>
) : (
LANES.map((lane) => {
const laneTop = topLevel.filter((p) => p.category === lane);
return (
<div key={lane} className="rounded-xl border bg-card p-4">
<div className="mb-3 flex items-center gap-2">
<span className="font-heading text-sm font-semibold">{tCat(lane)}</span>
<Tag>{laneTop.length}</Tag>
</div>
{laneTop.length === 0 ? (
<p className="text-[12px] text-muted-foreground">{t("laneEmpty")}</p>
) : (
<div className="grid gap-3 md:grid-cols-2">{laneTop.map((p) => tile(p, 0))}</div>
)}
</div>
);
})
)}
{/* Aus Standard-Katalog übernehmen */}
<div className="rounded-xl border bg-card p-4">
<span className="font-heading text-sm font-semibold">{t("catalogTitle")}</span>
<p className="mt-1 text-[11.5px] text-muted-foreground">{t("catalogHint")}</p>
<ul className="mt-3 space-y-2">
{catalogTop.map((c) => {
const taken = takenCatalog.has(c.code) || takenCodes.has(c.name);
const childCount = catalogChildCount.get(c.code) ?? 0;
return (
<li key={c.code} className="flex items-center gap-2 border-b pb-2 text-[12.5px] last:border-0">
<Pill tone="mut">{tCat(c.category)}</Pill>
<span className="flex-1">
<span className="font-medium">{c.name}</span>
<span className="ml-1.5 text-[10.5px] text-muted-foreground">{c.code}</span>
{childCount > 0 && (
<span className="ml-1.5 text-[10.5px] text-muted-foreground">
· {t("inclSub", { count: childCount })}
</span>
)}
</span>
{taken ? (
<Pill tone="ok">{t("adopted")}</Pill>
) : canUse ? (
<form action={adoptCatalogProcess}>
<input type="hidden" name="code" value={c.code} />
<Button type="submit" size="sm" variant="outline">{t("adopt")}</Button>
</form>
) : (
<Pill tone="info">{tc("none")}</Pill>
)}
</li>
);
})}
</ul>
</div>
</div>
);
}
@@ -0,0 +1,136 @@
import Link from "next/link";
import { ShieldCheck } from "lucide-react";
import { requireSession } from "@/server/auth";
import { dbForTenant } from "@/server/db";
import { Button } from "@/components/ui/button";
import { Pill, CiaBadge } from "@/components/mockup-ui";
/**
* M2 Schritt „Schutzbedarf" (Ebene 2). Der Schutzbedarf C/I/A liegt am Asset.
* Primäre Informations-Assets tragen den echten Wert; sekundäre Träger erben per
* MAXIMUM der von ihnen (im selben Prozess) getragenen primären Werte
* (Kumulation/Verteilung bleiben fachliche Entscheidung im Asset-Modul).
*
* Dieser Schritt analysiert (read-only) den Bestand: unbewertete primäre Werte und
* Träger, deren C/I/A unter dem geerbten Maximum liegt. Pflege erfolgt im Asset-Modul.
*/
export async function ProtectionStep() {
const session = await requireSession();
const db = dbForTenant(session.user.tenantId);
const processes = await db.process.findMany({
select: {
processAssets: {
select: {
role: true,
asset: {
select: {
id: true,
name: true,
type: true,
confidentiality: true,
integrity: true,
availability: true,
},
},
},
},
},
});
// Primäre Informations-Werte ohne echte Bewertung (alles auf 1) — Bewertung nachholen.
const unratedPrimary = new Map<string, string>();
// Träger, deren C/I/A unter dem geerbten Maximum liegt (Vererbung verletzt).
const inheritanceGaps: { name: string; have: [number, number, number]; need: [number, number, number] }[] = [];
for (const p of processes) {
const primaries = p.processAssets.filter(
(pa) => pa.role === "PRIMARY" && (pa.asset.type === "INFORMATION" || pa.asset.type === "DATA"),
);
const maxC = Math.max(1, ...primaries.map((pa) => pa.asset.confidentiality));
const maxI = Math.max(1, ...primaries.map((pa) => pa.asset.integrity));
const maxA = Math.max(1, ...primaries.map((pa) => pa.asset.availability));
for (const pa of primaries) {
const a = pa.asset;
if (a.confidentiality === 1 && a.integrity === 1 && a.availability === 1) {
unratedPrimary.set(a.id, a.name);
}
}
if (primaries.length === 0) continue;
for (const pa of p.processAssets) {
if (pa.role !== "SECONDARY") continue;
const a = pa.asset;
if (a.confidentiality < maxC || a.integrity < maxI || a.availability < maxA) {
inheritanceGaps.push({
name: a.name,
have: [a.confidentiality, a.integrity, a.availability],
need: [maxC, maxI, maxA],
});
}
}
}
return (
<div className="space-y-5">
<div className="rounded-xl border bg-card p-4">
<div className="flex flex-wrap items-center justify-between gap-2">
<span className="font-heading text-sm font-semibold">Schutzbedarf C/I/A</span>
<Button variant="outline" size="sm" nativeButton={false} render={<Link href="/assets" />}>
<ShieldCheck className="size-4" /> Zum Assetinventar
</Button>
</div>
<p className="mt-2 text-[11.5px] text-muted-foreground">
Der Schutzbedarf liegt am Asset. Primäre Informationswerte tragen den echten Wert; Träger
erben per <b>Maximum</b> der getragenen primären Werte. Pflege im Asset-Modul.
</p>
<div className="mt-3 grid gap-3 sm:grid-cols-2">
<div className="rounded-lg border bg-[var(--surface-soft)] p-3">
<p className="font-heading text-2xl font-bold leading-none">{unratedPrimary.size}</p>
<p className="mt-1 text-[11.5px] text-muted-foreground">primäre Werte ohne Bewertung</p>
</div>
<div className="rounded-lg border bg-[var(--surface-soft)] p-3">
<p className="font-heading text-2xl font-bold leading-none">{inheritanceGaps.length}</p>
<p className="mt-1 text-[11.5px] text-muted-foreground">Träger unter geerbtem Maximum</p>
</div>
</div>
</div>
{unratedPrimary.size > 0 && (
<div className="rounded-xl border bg-card p-4">
<span className="font-heading text-sm font-semibold">Unbewertete primäre Werte</span>
<ul className="mt-2 flex flex-wrap gap-1.5">
{[...unratedPrimary.values()].map((name) => (
<li key={name} className="inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-[12px]">
{name} <Pill tone="warn">C/I/A offen</Pill>
</li>
))}
</ul>
</div>
)}
<div className="rounded-xl border bg-card p-4">
<span className="font-heading text-sm font-semibold">Vererbung (Maximum-Prinzip)</span>
{inheritanceGaps.length === 0 ? (
<p className="mt-2 text-[12.5px] text-muted-foreground">
Alle Träger erfüllen mindestens das geerbte Maximum ihrer primären Werte.
</p>
) : (
<ul className="mt-3 space-y-2">
{inheritanceGaps.map((g, i) => (
<li key={`${g.name}-${i}`} className="flex flex-wrap items-center gap-2 border-b pb-2 text-[12.5px] last:border-0">
<span className="flex-1 font-medium">{g.name}</span>
<span className="inline-flex items-center gap-1">
ist <CiaBadge c={g.have[0]} i={g.have[1]} a={g.have[2]} />
</span>
<span className="inline-flex items-center gap-1">
soll (min.) <CiaBadge c={g.need[0]} i={g.need[1]} a={g.need[2]} />
</span>
</li>
))}
</ul>
)}
</div>
</div>
);
}
@@ -0,0 +1,130 @@
import Link from "next/link";
import { ShieldAlert } from "lucide-react";
import { requireSession } from "@/server/auth";
import { dbForTenant } from "@/server/db";
import { hasPermission } from "@/server/rbac";
import { Button } from "@/components/ui/button";
import { Pill } from "@/components/mockup-ui";
import { createRiskGapTasks } from "@/server/actions/onboarding-steps";
import { getSuggestedRisksForProcesses, adoptSuggestedRisk } from "@/server/actions/risk-catalog";
/**
* Risiken-Schritt (Schritt 6). Setzt auf dem bestehenden Risikomodul auf (Register,
* Heatmap, Standard-Risikokatalog C4) — keine Doppel-Datenhaltung. Zeigt Register-/
* Katalogstand und offene Behandlungslücken und legt dafür Aufgaben an.
*/
export async function RisksStep() {
const session = await requireSession();
const db = dbForTenant(session.user.tenantId);
const canUse = hasPermission(session, "onboarding:use");
const [total, high, highUntreated, catalogTotal, adopted, accepted] = await Promise.all([
db.risk.count(),
db.risk.count({ where: { score: { gte: 10 } } }),
db.risk.count({ where: { score: { gte: 10 }, treatment: "MITIGATE", riskMeasures: { none: {} } } }),
db.riskCatalogEntry.count(),
db.risk.count({ where: { catalogCode: { not: null } } }),
db.risk.count({ where: { status: "ACCEPTED" } }),
]);
const kpis = [
{ label: "Risiken im Register", value: total },
{ label: `Katalog übernommen (von ${catalogTotal})`, value: adopted },
{ label: "Hoch-Risiken", value: high },
];
const gaps = [
total === 0 ? { label: "Register leer — Standard-Risikokatalog sichten und relevante Risiken übernehmen", tone: "warn" as const } : null,
total > 0 && adopted === 0 && catalogTotal > 0 ? { label: "Standard-Risikokatalog (C4) noch nicht genutzt", tone: "info" as const } : null,
highUntreated > 0 ? { label: `${highUntreated} Hoch-Risiko/-Risiken ohne verknüpfte Maßnahme`, tone: "warn" as const } : null,
].filter(Boolean) as { label: string; tone: "warn" | "info" }[];
// M2: Standard-Risiken (suggestedRiskCodes) je übernommenem Katalog-Prozess mit
// Ein-Klick-Übernahme (adoptSuggestedRisk, ohne den Wizard zu verlassen).
const riskSuggestions = await getSuggestedRisksForProcesses();
return (
<div className="space-y-5">
<div className="rounded-xl border bg-card p-4">
<div className="flex flex-wrap items-center justify-between gap-2">
<span className="font-heading text-sm font-semibold">Risikoanalyse</span>
<div className="flex gap-2">
<Button variant="outline" size="sm" nativeButton={false} render={<Link href="/risks/catalog" />}>Risikokatalog</Button>
<Button variant="outline" size="sm" nativeButton={false} render={<Link href="/risks" />}><ShieldAlert className="size-4" /> Zum Risikoregister</Button>
</div>
</div>
<div className="mt-3 grid gap-3 sm:grid-cols-3">
{kpis.map((k) => (
<div key={k.label} className="rounded-lg border bg-[var(--surface-soft)] p-3">
<p className="font-heading text-2xl font-bold leading-none">{k.value}</p>
<p className="mt-1 text-[11.5px] text-muted-foreground">{k.label}</p>
</div>
))}
</div>
<p className="mt-3 text-[11.5px] text-muted-foreground">
Davon {accepted} akzeptiert. Der Wizard nutzt das bestehende Risikomodul (Register,
Heatmap, Katalog-Übernahme, Maßnahmenverknüpfung) — Pflege dort.
</p>
</div>
<div className="rounded-xl border bg-card p-4">
<div className="flex flex-wrap items-center justify-between gap-2">
<span className="font-heading text-sm font-semibold">Behandlungsstand</span>
{canUse && (
<form action={createRiskGapTasks}>
<Button type="submit" size="sm" variant="outline">Prüfen &amp; Aufgaben anlegen{gaps.length ? ` (${gaps.length})` : ""}</Button>
</form>
)}
</div>
{gaps.length === 0 ? (
<p className="mt-2 text-[12.5px] text-muted-foreground">Risiken erfasst und Hoch-Risiken mit Maßnahmen hinterlegt — Risikostand ist assessment-tauglich.</p>
) : (
<ul className="mt-3 space-y-2">
{gaps.map((g) => (
<li key={g.label} className="flex items-center gap-2 border-b pb-2 text-[12.5px] last:border-0">
<Pill tone={g.tone}>{g.tone === "info" ? "Hinweis" : "Lücke"}</Pill>
<span>{g.label}</span>
</li>
))}
</ul>
)}
</div>
{riskSuggestions.length > 0 && (
<div className="rounded-xl border bg-card p-4">
<span className="font-heading text-sm font-semibold">Empfohlene Risiken je Prozess (Katalog)</span>
<p className="mt-1 text-[11.5px] text-muted-foreground">
Standard-Risiken (C4) aus dem Prozess-Katalog. „Übernehmen“ legt das Risiko mit
Default-Bewertung im Register an — anschließend dort anpassen.
</p>
<div className="mt-3 space-y-3">
{riskSuggestions.map((s) => (
<div key={s.processId}>
<p className="text-[12.5px] font-medium">{s.processName}</p>
<ul className="mt-1.5 space-y-2">
{s.risks.map((r) => (
<li key={r.code} className="flex items-center gap-2 border-b pb-2 text-[12.5px] last:border-0">
<Pill tone="mut">{r.category}</Pill>
<span className="flex-1">
<span className="font-medium">{r.title}</span>
<span className="ml-1.5 text-[10.5px] text-muted-foreground">{r.code}</span>
</span>
{r.adopted ? (
<Pill tone="ok">übernommen</Pill>
) : canUse ? (
<form action={adoptSuggestedRisk.bind(null, r.code)}>
<Button type="submit" size="sm" variant="outline">Übernehmen</Button>
</form>
) : (
<Pill tone="info">offen</Pill>
)}
</li>
))}
</ul>
</div>
))}
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,93 @@
"use client";
import { useEffect, useState, type ReactNode } from "react";
import { X } from "lucide-react";
import { Button } from "@/components/ui/button";
/**
* Client-seitiges Wizard-Popup im Stil des serverseitigen `Modal` (src/components/modal.tsx),
* aber mit lokalem Open-State statt searchParams — die Schritt-Komponenten des Onboardings
* erhalten keine searchParams, daher wird das Öffnen/Schließen hier per State gesteuert.
* Optik (Overlay, Karte, Header, Footer) bleibt bewusst identisch zum bestehenden Modal.
*/
export function Popup({
triggerLabel,
triggerIcon,
triggerVariant = "outline",
triggerSize = "sm",
triggerClassName,
title,
sub,
maxWidthClass = "max-w-2xl",
children,
}: {
triggerLabel: ReactNode;
triggerIcon?: ReactNode;
triggerVariant?: "default" | "outline" | "secondary" | "ghost" | "link";
triggerSize?: "default" | "sm" | "xs" | "lg";
triggerClassName?: string;
title: string;
sub?: string;
maxWidthClass?: string;
/** Erhält eine `close`-Funktion, damit Formulare nach dem Speichern schließen können. */
children: (close: () => void) => ReactNode;
}) {
const [open, setOpen] = useState(false);
const close = () => setOpen(false);
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") setOpen(false);
};
document.addEventListener("keydown", onKey);
const prev = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => {
document.removeEventListener("keydown", onKey);
document.body.style.overflow = prev;
};
}, [open]);
return (
<>
<Button
type="button"
variant={triggerVariant}
size={triggerSize}
className={triggerClassName}
onClick={() => setOpen(true)}
>
{triggerIcon}
{triggerLabel}
</Button>
{open && (
<div
className="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-black/30 p-6 backdrop-blur-[2px]"
onClick={(e) => {
if (e.target === e.currentTarget) setOpen(false);
}}
>
<div className={`shadow-card my-auto w-full ${maxWidthClass} rounded-2xl border bg-card`}>
<div className="flex items-start justify-between gap-3 border-b p-5">
<div>
<p className="font-heading text-[15px] font-semibold">{title}</p>
{sub && <p className="mt-0.5 text-[12.5px] text-muted-foreground">{sub}</p>}
</div>
<button
type="button"
title="Schließen"
onClick={() => setOpen(false)}
className="rounded-md p-1 text-muted-foreground hover:bg-muted hover:text-foreground"
>
<X className="size-4.5" />
</button>
</div>
{children(close)}
</div>
</div>
)}
</>
);
}
@@ -0,0 +1,179 @@
"use client";
import { UserPlus, UserMinus, Mail, CircleSlash, Pencil } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Pill } from "@/components/mockup-ui";
import {
assignFunction,
unassignFunction,
markFunctionUnfilled,
inviteFunctionHolder,
} from "@/server/actions/onboarding-team";
import { Popup } from "./popup";
export interface HolderView {
id: string;
userName: string | null;
userEmail: string | null;
invitedEmail: string | null;
}
export interface FunctionCardData {
key: string;
label: string;
desc: string;
domain: string | null;
multi: boolean;
soll: string | null;
holders: HolderView[];
}
/**
* Bearbeiten einer ISMS-Funktion im Popup — Logik wie beim Risiken-Popup, nutzt die
* bestehenden M1-Server-Actions (`assignFunction`, `unassignFunction`,
* `markFunctionUnfilled`, `inviteFunctionHolder`). Auslöser ist der „Bearbeiten“-Button
* auf der Funktionskarte (siehe step.tsx).
*/
export function FunctionEditDialog({
fn,
users,
canUse,
canInvite,
}: {
fn: FunctionCardData;
users: { id: string; name: string; email: string }[];
canUse: boolean;
canInvite: boolean;
}) {
const filled = fn.holders.length > 0;
const canAssignMore = canUse && (fn.multi || !filled);
return (
<Popup
triggerLabel={canUse ? "Bearbeiten" : "Ansehen"}
triggerIcon={<Pencil className="size-3.5" />}
triggerVariant="outline"
triggerSize="sm"
title={fn.label}
sub={fn.domain ? `Bereich: ${fn.domain}` : undefined}
>
{() => (
<div className="space-y-4 p-5">
<div className="rounded-xl border border-l-[3px] border-l-[var(--primary)] bg-[var(--surface-soft)] p-4">
<p className="text-[12.5px] leading-relaxed">{fn.desc}</p>
{fn.soll && (
<p className="mt-2 text-[11.5px] text-muted-foreground">
Soll (zentral): <b>{fn.soll}</b>
</p>
)}
<div className="mt-2">
{filled ? (
<Pill tone="ok">besetzt{fn.multi ? ` (${fn.holders.length})` : ""}</Pill>
) : (
<Pill tone="warn">unbesetzt</Pill>
)}
</div>
</div>
{/* Aktuelle Halter */}
<div>
<p className="text-[12.5px] font-medium">Aktuelle Besetzung</p>
{filled ? (
<ul className="mt-1.5 space-y-1">
{fn.holders.map((h) => (
<li
key={h.id}
className="flex items-center justify-between gap-2 rounded-lg border bg-background px-3 py-2 text-[12.5px]"
>
<span>
<b>{h.userName ?? h.invitedEmail ?? "—"}</b>
{h.userEmail && (
<span className="ml-1 text-[11px] text-muted-foreground">{h.userEmail}</span>
)}
{h.invitedEmail && !h.userName && (
<span className="ml-1 text-[11px] text-[var(--info)]">eingeladen</span>
)}
</span>
{canUse && (
<form action={unassignFunction.bind(null, h.id)}>
<Button type="submit" size="sm" variant="ghost" className="h-7 px-2 text-[11.5px]">
<UserMinus className="size-3.5" /> entfernen
</Button>
</form>
)}
</li>
))}
</ul>
) : (
<p className="mt-1 text-[12px] text-muted-foreground">Noch niemand zugewiesen.</p>
)}
</div>
{/* Zuweisen / Einladen / unbesetzt lassen */}
{canAssignMore && (
<div className="space-y-3 border-t pt-3">
<form action={assignFunction.bind(null, fn.key)} className="flex items-center gap-1.5">
<select
name="userId"
required
defaultValue=""
className="h-8 flex-1 rounded-md border border-input bg-background px-2 text-[12.5px]"
>
<option value="" disabled>
Account auswählen …
</option>
{users.map((u) => (
<option key={u.id} value={u.id}>
{u.name} ({u.email})
</option>
))}
</select>
<Button type="submit" size="sm" variant="outline" className="h-8">
<UserPlus className="size-3.5" /> zuweisen
</Button>
</form>
<form action={inviteFunctionHolder.bind(null, fn.key)} className="flex items-center gap-1.5">
<Input
name="email"
type="email"
required
placeholder="E-Mail einladen"
className="h-8 flex-1 text-[12.5px]"
disabled={!canInvite}
/>
<Button type="submit" size="sm" variant="outline" className="h-8" disabled={!canInvite}>
<Mail className="size-3.5" /> einladen
</Button>
</form>
{!canInvite && (
<p className="text-[11px] text-muted-foreground">
Einladen neuer Accounts erfordert das Recht „Benutzerverwaltung“.
</p>
)}
{!filled && (
<form action={markFunctionUnfilled.bind(null, fn.key)}>
<Button
type="submit"
size="sm"
variant="ghost"
className="h-7 px-2 text-[11.5px] text-muted-foreground"
>
<CircleSlash className="size-3.5" /> unbesetzt lassen (Aufgabe anlegen)
</Button>
</form>
)}
</div>
)}
{!canUse && (
<p className="border-t pt-3 text-[12px] text-muted-foreground">
Nur Lesezugriff — Änderungen erfordern das Recht „Onboarding nutzen“.
</p>
)}
</div>
)}
</Popup>
);
}
@@ -0,0 +1,179 @@
import Link from "next/link";
import { Settings, Users } from "lucide-react";
import { requireSession } from "@/server/auth";
import { dbForTenant } from "@/server/db";
import { hasPermission } from "@/server/rbac";
import { Button } from "@/components/ui/button";
import { Pill } from "@/components/mockup-ui";
import { loadRoleContext, loadFunctionAssignments, listAssignableUsers } from "@/server/roles";
import { evaluateFt, FT_SEVERITY_LABEL, FT_SEVERITY_TONE } from "@/lib/ft-rules";
import { renderIsbBestellung } from "@/lib/isb-bestellung";
import { FUNCTION_DEFS, type FunctionDef } from "@/lib/onboarding/functions";
import { createFtTasks } from "@/server/actions/onboarding";
import { FunctionEditDialog } from "./role-edit-dialog";
/**
* Team / Funktionszuordnung (Ebene 1 „Fundament", M1). Löst die read-only
* Rollen-Anzeige durch eine echte Zuweisung Funktion → User(n) ab: bestehende
* Accounts zuweisen, per E-Mail einladen (SEC2) oder eine Funktion bewusst
* unbesetzt lassen (→ Task „Funktion besetzen"). Ergänzend die Funktionstrennungs-
* Prüfung (FT-01…06) und die ISB-Bestellung als Vorlage.
*/
export async function RolesStep() {
const session = await requireSession();
const db = dbForTenant(session.user.tenantId);
const canUse = hasPermission(session, "onboarding:use");
const canInvite = hasPermission(session, "user:manage");
const [ctx, assignments, users] = await Promise.all([
loadRoleContext(db),
loadFunctionAssignments(db),
listAssignableUsers(db),
]);
const findings = evaluateFt(ctx);
const openTasks = findings.filter((f) => f.task).length;
const date = new Date().toLocaleDateString("de-DE");
const bestellung = renderIsbBestellung({ ISB: ctx.roles.ISB, MANAGEMENT: ctx.roles.MANAGEMENT, ORG_NAME: ctx.orgName, DOC_DATE: date });
const byFunction = new Map<string, typeof assignments>();
for (const a of assignments) {
const list = byFunction.get(a.functionKey) ?? [];
list.push(a);
byFunction.set(a.functionKey, list);
}
// Soll-Benennung aus der korrespondierenden Zentralvariable (Vorschlag/Abgleich).
const sollOf = (def: FunctionDef): string | null => {
if (!def.roleVar) return null;
const key = def.roleVar.replace(/^ROLE_/, "") as keyof typeof ctx.roles;
const v = (ctx.roles[key] ?? "").trim();
return v || null;
};
return (
<div className="space-y-5">
{/* Team / Funktionszuordnung */}
<div className="rounded-xl border bg-card p-4">
<div className="flex flex-wrap items-center justify-between gap-2">
<span className="font-heading text-sm font-semibold">Team &amp; Funktionszuordnung</span>
<Link href="/settings" className="inline-flex items-center gap-1 text-[11.5px] font-medium text-[var(--primary)] hover:underline">
<Settings className="size-3" /> Soll-Rollen &amp; Zentralvariablen
</Link>
</div>
<p className="mt-1 text-[11.5px] text-muted-foreground">
Jede ISMS-Funktion als Karte — Besetzung auf einen Blick. Zum Zuweisen, Einladen oder
bewussten Unbesetzt-Lassen (erzeugt eine Aufgabe „Funktion besetzen“) die Karte
<b> bearbeiten</b>.
</p>
<div className="mt-3 grid gap-3 sm:grid-cols-2">
{FUNCTION_DEFS.map((def) => {
const holders = byFunction.get(def.key) ?? [];
const soll = sollOf(def);
const filled = holders.length > 0;
return (
<div
key={def.key}
className={`flex flex-col rounded-xl border border-l-[3px] bg-[var(--surface-soft)] p-3.5 ${filled ? "border-l-[var(--ok)]" : "border-l-[var(--warn)]"}`}
>
<div className="flex items-start justify-between gap-2">
<p className="text-[13px] font-semibold">{def.label}</p>
{filled ? (
<Pill tone="ok">besetzt{def.multi ? ` (${holders.length})` : ""}</Pill>
) : (
<Pill tone="warn">unbesetzt</Pill>
)}
</div>
{def.domain && (
<p className="mt-0.5 text-[11px] text-muted-foreground">Bereich: {def.domain}</p>
)}
<p className="mt-1 line-clamp-2 text-[11.5px] text-muted-foreground">{def.desc}</p>
<div className="mt-2 min-h-[1.25rem] text-[12px]">
{filled ? (
<span className="inline-flex flex-wrap items-center gap-1.5">
<Users className="size-3.5 text-muted-foreground" />
{holders.map((h) => (
<span key={h.id} className="rounded-md bg-background px-1.5 py-0.5">
{h.userName ?? h.invitedEmail ?? "—"}
{h.invitedEmail && !h.userName && (
<span className="ml-1 text-[10.5px] text-[var(--info)]">eingeladen</span>
)}
</span>
))}
</span>
) : (
<span className="text-muted-foreground">
{soll ? (
<>Soll (zentral): <b>{soll}</b></>
) : (
"Noch keine Person zugewiesen."
)}
</span>
)}
</div>
<div className="mt-3 flex justify-end border-t pt-2.5">
<FunctionEditDialog
fn={{
key: def.key,
label: def.label,
desc: def.desc,
domain: def.domain,
multi: Boolean(def.multi),
soll,
holders: holders.map((h) => ({
id: h.id,
userName: h.userName,
userEmail: h.userEmail,
invitedEmail: h.invitedEmail,
})),
}}
users={users}
canUse={canUse}
canInvite={canInvite}
/>
</div>
</div>
);
})}
</div>
{!canInvite && <p className="mt-2 text-[11px] text-muted-foreground">Einladen neuer Accounts erfordert das Recht „Benutzerverwaltung“.</p>}
</div>
{/* Funktionstrennung FT-01…06 */}
<div className="rounded-xl border bg-card p-4">
<div className="flex flex-wrap items-center justify-between gap-2">
<span className="font-heading text-sm font-semibold">Funktionstrennung (FT-01…06)</span>
{canUse && (
<form action={createFtTasks}>
<Button type="submit" size="sm" variant="outline">Prüfen &amp; Aufgaben anlegen{openTasks ? ` (${openTasks})` : ""}</Button>
</form>
)}
</div>
<ul className="mt-3 space-y-2">
{findings.map((f) => (
<li key={f.code} className="flex flex-wrap items-start gap-2 border-b pb-2 text-[12.5px] last:border-0">
<span className="font-mono text-[11px] text-muted-foreground">{f.code}</span>
<Pill tone={FT_SEVERITY_TONE[f.severity]}>{FT_SEVERITY_LABEL[f.severity]}</Pill>
<span className="flex-1">
<b>{f.title}</b>
{f.control ? <span className="text-muted-foreground"> · Control {f.control}</span> : null}
<span className="block text-[11.5px] text-muted-foreground">{f.message}</span>
{f.task && <span className="text-[11px] text-[var(--info)]">→ Aufgabe{f.task.reuse ? " (aus Fragebogen)" : ""}</span>}
</span>
</li>
))}
</ul>
</div>
{/* ISB-Bestellung (inline, aus C7-Baustein) */}
<details className="rounded-xl border bg-card p-4">
<summary className="cursor-pointer list-none text-[13px] font-semibold [&::-webkit-details-marker]:hidden">ISB-Bestellung anzeigen (Vorlage)</summary>
<p className="mt-1 text-[11.5px] text-muted-foreground">Erzeugt aus den zentralen Variablen — Vorschau/Kopiervorlage (keine Paket-Vorlage).</p>
<pre className="mt-2 max-h-80 overflow-auto rounded-lg border bg-[var(--surface-soft)] p-3 text-[11.5px] whitespace-pre-wrap">{bestellung}</pre>
</details>
</div>
);
}
@@ -0,0 +1,107 @@
import { Lock } from "lucide-react";
import { requireSession } from "@/server/auth";
import { dbForTenant } from "@/server/db";
import { hasPermission } from "@/server/rbac";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Pill } from "@/components/mockup-ui";
import { getAssessmentLevel, protectionFlags } from "@/server/assessment-level";
import { scopeSummary, type Pruefziel } from "@/lib/scope-filter";
import { saveScope } from "@/server/actions/onboarding";
/**
* Scoping-Schritt (Story A2). Zeigt das Assessment-Level read-only (A2-1) und erfasst
* den Geltungsbereich (Prüfziele, Standorte, Ausschlüsse) als WizardScope (A2-2). Eine
* Live-Vorschau zeigt die daraus resultierenden aktiven Anforderungen (Scope-Filter).
*/
export async function ScopingStep() {
const session = await requireSession();
const db = dbForTenant(session.user.tenantId);
const canUse = hasPermission(session, "onboarding:use");
const level = await getAssessmentLevel(db);
const veryHigh = level === "AL3";
const scopeLabel = veryHigh ? "MUSS · SOLL · HOCH · SEHR HOCH" : "MUSS · SOLL · HOCH";
const scope = await db.wizardScope.findFirst();
const pruefziele = (scope?.pruefziele as Pruefziel[]) ?? ["informationssicherheit"];
const hasProto = pruefziele.includes("prototypenschutz");
const hasDatenschutz = pruefziele.includes("datenschutz");
// FLAG_INCLUDE_SHOULD (SOLL-Anforderungen) stammt aus dem Fragebogen (Q-GOV-05, Default an).
const should = await db.wizardFact.findUnique({ where: { tenantId_key: { tenantId: session.user.tenantId, key: "Q-GOV-05" } } });
const includeShould = should ? should.value === true : true;
const summary = scopeSummary({ pruefziele, flags: { ...protectionFlags(level), FLAG_INCLUDE_SHOULD: includeShould } });
return (
<div className="space-y-5">
{/* Assessment-Level (read-only, A2-1) */}
<div className="rounded-xl border border-l-[3px] border-l-[var(--primary)] bg-[var(--surface-soft)] p-4">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
<span className="text-[13px] font-semibold">Assessment-Level</span>
<Pill tone={veryHigh ? "risk" : "info"}>{level}</Pill>
</div>
<span className="inline-flex items-center gap-1 text-[11.5px] text-muted-foreground"><Lock className="size-3" /> read-only</span>
</div>
<p className="mt-1.5 text-[12.5px] text-muted-foreground">
Geprüfte Anforderungsstufen: <b>{scopeLabel}</b>. Zentral im Admin-/Superadmin-Portal
gesetzt (einzige Quelle des Schutzbedarfs); im Wizard nicht änderbar.
</p>
</div>
{/* Scope erfassen */}
<form action={saveScope} className="space-y-4">
<fieldset className="rounded-xl border bg-card p-4">
<legend className="px-1 text-[13px] font-semibold">Prüfziele</legend>
<div className="mt-1 space-y-2 text-sm">
<label className="flex items-center gap-2 text-muted-foreground">
<input type="checkbox" checked disabled /> Informationssicherheit <span className="text-[11.5px]">(immer aktiv)</span>
</label>
<label className="flex items-center gap-2">
<input type="checkbox" name="pz_prototypenschutz" defaultChecked={hasProto} /> Prototypenschutz <span className="text-[11.5px] text-muted-foreground">(Kapitel 8.x)</span>
</label>
<label className="flex items-center gap-2">
<input type="checkbox" name="pz_datenschutz" defaultChecked={hasDatenschutz} /> Datenschutz <span className="text-[11.5px] text-muted-foreground">(Kapitel 9.x)</span>
</label>
</div>
</fieldset>
<div>
<label className="text-[13px] font-medium" htmlFor="geltungsbereich">Geltungsbereich</label>
<Input id="geltungsbereich" name="geltungsbereich" defaultValue={scope?.geltungsbereich ?? ""} placeholder="z. B. gesamte Organisation / definierter Bereich" className="mt-1" />
</div>
<div className="grid gap-4 md:grid-cols-2">
<div>
<label className="text-[13px] font-medium" htmlFor="standorte">Standorte <span className="text-[11.5px] text-muted-foreground">(einer je Zeile)</span></label>
<Textarea id="standorte" name="standorte" rows={3} defaultValue={(scope?.standorte ?? []).join("\n")} className="mt-1" />
</div>
<div>
<label className="text-[13px] font-medium" htmlFor="ausschluesse">Ausschlüsse</label>
<Textarea id="ausschluesse" name="ausschluesse" rows={3} defaultValue={scope?.ausschluesse ?? ""} placeholder="Ausgeschlossene Bereiche/Systeme" className="mt-1" />
</div>
</div>
{canUse && <Button type="submit">Scope speichern</Button>}
</form>
{/* Live-Vorschau der aktiven Anforderungen (Scope-Filter) */}
<div className="rounded-xl border border-[var(--band-brd)] bg-[var(--band)] p-4 text-[12.5px] text-[var(--band-text)]">
<div className="flex items-center justify-between gap-2">
<b>Resultierender Scope</b>
<span className="font-heading text-[15px] font-bold text-foreground">{summary.total} Anforderungen</span>
</div>
<p className="mt-1">
über {summary.controls} Controls · MUSS {summary.byType.MUSS} · SOLL {summary.byType.SOLL} · HOCH {summary.byType.HOCH} · SEHR HOCH {summary.byType["SEHR HOCH"]}
</p>
<p className="mt-1 text-muted-foreground">
Abgeleitet aus Prüfzielen, Assessment-Level ({level}) und SOLL-Einbezug
({includeShould ? "an" : "aus"}). Ändert sich der Scope, passen sich die nachgelagerten Schritte an.
</p>
</div>
</div>
);
}