Iteration 3: Risikoanalyse + Menü-Trennung + Prozesskategorie

- Risiko-Modul (SPEC §4.2): Risk/RiskAsset-Schema mit RLS, laufende
  Nummer je Mandant (R-001), Brutto-/Rest-Bewertung (5×5), Behandlung,
  Status, Owner- und Prozess-Bezug, n:m betroffene Assets
- /risks im Mockup-Layout: 5×5-Heatmap (Farb-Bänder + R-Chips, Legende,
  Achsen) und Risikoregister mit Score-Pillen, Behandlungs-Tags, Status
- Risiko-Popups nach App-Muster: Read-only-Detail (Bewertungs-Band
  Brutto vs. Rest, betroffene Assets, Maßnahmen-Platzhalter für
  Iteration 4), Bearbeiten im Popup (Bewertung, Assets verknüpfen,
  Löschen), Anlegen; Server-Actions mit Zod, RBAC und Audit-Log
- Menü aufgetrennt: eigene Punkte "Asset-Inventar", "Business Impact
  Analyse" und "Risikoanalyse" (Modul-Tabs entfernt)
- Prozesskategorie (Kernprozess/Managementprozess/Unterstützender
  Prozess): Schema-Feld, Formulare, Listen-Spalte, Popup-Tag
- Asset-Detail-Popup optisch ans Prozess-Popup angeglichen (Stammdaten-
  Karte mit violettem Rand, Abhängigkeiten-Tabelle, Risiken-Band)
- "Zugeordnete Risiken" jetzt echt: im Asset-Popup (verknüpfte Risiken)
  und im Prozess-Popup (direkt + über Assets aggregiert); Dashboard-KPI
  "Offene Risiken" mit echten Zahlen; Seed mit 4 Beispiel-Risiken

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Martin
2026-07-02 15:43:25 +02:00
co-authored by Claude Fable 5
parent c5a1f0095b
commit df634e356d
20 changed files with 1633 additions and 106 deletions
+18 -9
View File
@@ -6,7 +6,6 @@ import { requireSession } from "@/server/auth";
import { dbForTenant } from "@/server/db";
import { hasPermission, requirePermission } from "@/server/rbac";
import { Button } from "@/components/ui/button";
import { ModuleTabs } from "@/components/module-tabs";
import { FilterTabs } from "@/components/filter-tabs";
import {
CiaBadge,
@@ -89,9 +88,26 @@ export default async function AssetsPage({
where: { id: modalId },
include: {
owner: { select: { name: true } },
relationsFrom: { include: { relatedAsset: { select: { id: true, name: true } } } },
relationsFrom: {
include: {
relatedAsset: {
select: {
id: true,
name: true,
type: true,
confidentiality: true,
integrity: true,
availability: true,
},
},
},
},
relationsTo: { include: { asset: { select: { id: true, name: true } } } },
processAssets: { include: { process: { select: { id: true, name: true } } } },
riskAssets: {
include: { risk: { select: { id: true, refNo: true, title: true, score: true } } },
orderBy: { risk: { score: "desc" } },
},
},
})
: null;
@@ -138,13 +154,6 @@ export default async function AssetsPage({
}
/>
<ModuleTabs
active="/assets"
tabs={[
{ href: "/assets", label: t("tabAssets") },
{ href: "/processes", label: t("tabProcesses") },
]}
/>
<div className="mt-4 grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
<KpiCard label={t("kpiTotal")} value={total} trend={t("kpiTotalTrend", { count: typeCount })} />
+3 -3
View File
@@ -6,14 +6,14 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com
export default async function DashboardPage() {
const session = await requireSession();
const t = await getTranslations("dashboard");
const tc = await getTranslations("common");
const format = await getFormatter();
const db = dbForTenant(session.user.tenantId);
const [assetCount, processCount, criticalCount, activities] = await Promise.all([
const [assetCount, processCount, criticalCount, openRisks, activities] = await Promise.all([
db.asset.count(),
db.process.count(),
db.biaEntry.count({ where: { criticality: { gte: 3 } } }),
db.risk.count({ where: { status: { in: ["OPEN", "IN_TREATMENT"] } } }),
db.auditLog.findMany({
orderBy: { createdAt: "desc" },
take: 6,
@@ -34,7 +34,7 @@ export default async function DashboardPage() {
{ label: t("kpiAssets"), value: String(assetCount), hint: null },
{ label: t("kpiProcesses"), value: String(processCount), hint: null },
{ label: t("kpiCritical"), value: String(criticalCount), hint: t("kpiCriticalHint") },
{ label: t("kpiRisks"), value: tc("none"), hint: t("kpiRisksHint") },
{ label: t("kpiRisks"), value: String(openRisks), hint: null },
];
return (
+5 -3
View File
@@ -5,6 +5,7 @@ import { getTranslations } from "next-intl/server";
import {
LayoutDashboard,
Boxes,
GitBranch,
ShieldAlert,
ClipboardCheck,
KanbanSquare,
@@ -32,8 +33,9 @@ export default async function AppLayout({
const nav = [
{ href: "/dashboard", label: t("dashboard"), icon: LayoutDashboard, enabled: true },
{ href: "/assets", label: t("assetsBia"), icon: Boxes, enabled: true, match: ["/assets", "/processes"] },
{ href: "/risks", label: t("risks"), icon: ShieldAlert, enabled: false },
{ href: "/assets", label: t("assets"), icon: Boxes, enabled: true },
{ href: "/processes", label: t("bia"), icon: GitBranch, enabled: true },
{ href: "/risks", label: t("risks"), icon: ShieldAlert, enabled: true },
{ href: "/soa", label: t("soa"), icon: ClipboardCheck, enabled: false },
{ href: "/measures", label: t("measures"), icon: KanbanSquare, enabled: false },
{ href: "/incidents", label: t("incidents"), icon: Siren, enabled: false },
@@ -74,7 +76,7 @@ export default async function AppLayout({
<nav className="flex-1 space-y-0.5 overflow-y-auto px-2.5 py-3">
{nav.map((item) =>
item.enabled ? (
<NavLink key={item.href} href={item.href} match={item.match}>
<NavLink key={item.href} href={item.href}>
<item.icon className="size-[18px] opacity-85" />
{item.label}
</NavLink>
+22 -11
View File
@@ -5,8 +5,7 @@ import { requireSession } from "@/server/auth";
import { dbForTenant } from "@/server/db";
import { hasPermission, requirePermission } from "@/server/rbac";
import { Button } from "@/components/ui/button";
import { ModuleTabs } from "@/components/module-tabs";
import { CriticalityPill, PageHead, SectTitle } from "@/components/mockup-ui";
import { CriticalityPill, PageHead, SectTitle, Tag } from "@/components/mockup-ui";
import {
ProcessCreateModal,
ProcessDetailModal,
@@ -51,6 +50,7 @@ export default async function ProcessesPage({
const t = await getTranslations("processes");
const tAssets = await getTranslations("assets");
const tCrit = await getTranslations("criticality");
const tCat = await getTranslations("processCategory");
const tc = await getTranslations("common");
const params = await searchParams;
@@ -80,6 +80,20 @@ export default async function ProcessesPage({
orderBy: { name: "asc" },
})
: [];
// Zugeordnete Risiken: direkt am Prozess ODER an einem seiner Assets
const modalRisks = modalProcess
? await db.risk.findMany({
where: {
OR: [
{ processId: modalProcess.id },
{ riskAssets: { some: { assetId: { in: modalProcess.processAssets.map((pa) => pa.assetId) } } } },
],
},
select: { id: true, refNo: true, title: true, score: true },
orderBy: { score: "desc" },
})
: [];
const availableAssets =
canWrite && params.edit && modalProcess
? await db.asset.findMany({
@@ -111,13 +125,6 @@ export default async function ProcessesPage({
}
/>
<ModuleTabs
active="/processes"
tabs={[
{ href: "/assets", label: tAssets("tabAssets") },
{ href: "/processes", label: tAssets("tabProcesses") },
]}
/>
<div className="shadow-card mt-4 rounded-xl border bg-card">
<div className="p-4 pb-0">
@@ -127,6 +134,7 @@ export default async function ProcessesPage({
<TableHeader>
<TableRow>
<TableHead>{t("process")}</TableHead>
<TableHead>{t("category")}</TableHead>
<TableHead>{t("owner")}</TableHead>
<TableHead>RTO</TableHead>
<TableHead>RPO</TableHead>
@@ -137,7 +145,7 @@ export default async function ProcessesPage({
<TableBody>
{processes.length === 0 && (
<TableRow>
<TableCell colSpan={6} className="py-8 text-center text-muted-foreground">
<TableCell colSpan={7} className="py-8 text-center text-muted-foreground">
{t("empty")}
</TableCell>
</TableRow>
@@ -149,6 +157,9 @@ export default async function ProcessesPage({
{process.name}
</Link>
</TableCell>
<TableCell>
<Tag>{tCat(process.category)}</Tag>
</TableCell>
<TableCell className="text-muted-foreground">
{process.owner?.name ?? tc("none")}
</TableCell>
@@ -174,7 +185,7 @@ export default async function ProcessesPage({
{modalProcess && params.edit && canWrite ? (
<ProcessEditModal process={modalProcess} users={users} availableAssets={availableAssets} />
) : modalProcess ? (
<ProcessDetailModal process={modalProcess} canWrite={canWrite} />
<ProcessDetailModal process={modalProcess} risks={modalRisks} canWrite={canWrite} />
) : params.new && canWrite ? (
<ProcessCreateModal users={users} />
) : null}
+252
View File
@@ -0,0 +1,252 @@
import Link from "next/link";
import { getTranslations } from "next-intl/server";
import { Plus } from "lucide-react";
import { requireSession } from "@/server/auth";
import { dbForTenant } from "@/server/db";
import { hasPermission, requirePermission } from "@/server/rbac";
import { Button } from "@/components/ui/button";
import { PageHead, Pill, SectTitle, Tag } from "@/components/mockup-ui";
import {
RiskCreateModal,
RiskDetailModal,
RiskEditModal,
} from "@/components/risk-modals";
import { riskLevel, riskRef, RISK_CELL_COLORS, RISK_PILL_TONE } from "@/lib/risk";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
const RISK_INCLUDE = {
owner: { select: { id: true, name: true } },
process: { select: { id: true, name: true } },
riskAssets: {
include: {
asset: {
select: {
id: true,
name: true,
type: true,
confidentiality: true,
integrity: true,
availability: true,
},
},
},
},
} as const;
const STATUS_TONE = {
OPEN: "risk",
IN_TREATMENT: "warn",
ACCEPTED: "info",
CLOSED: "mut",
} as const;
export default async function RisksPage({
searchParams,
}: {
searchParams: Promise<{ detail?: string; edit?: string; new?: string }>;
}) {
const session = await requireSession();
requirePermission(session, "risk:read");
const t = await getTranslations("risks");
const tAssets = await getTranslations("assets");
const tTreat = await getTranslations("riskTreatment");
const tStatus = await getTranslations("riskStatus");
const tLevel = await getTranslations("riskLevel");
const tc = await getTranslations("common");
const params = await searchParams;
const db = dbForTenant(session.user.tenantId);
const canWrite = hasPermission(session, "risk:write");
const risks = await db.risk.findMany({
include: { owner: { select: { name: true } } },
orderBy: { score: "desc" },
take: 200,
});
const modalId = params.edit && canWrite ? params.edit : params.detail;
const modalRisk = modalId
? await db.risk.findUnique({ where: { id: modalId }, include: RISK_INCLUDE })
: null;
const needsFormData = canWrite && (params.edit || params.new);
const [users, processes] = needsFormData
? 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" } }),
])
: [[], []];
const availableAssets =
canWrite && params.edit && modalRisk
? await db.asset.findMany({
where: { id: { notIn: modalRisk.riskAssets.map((ra) => ra.assetId) } },
select: { id: true, name: true },
orderBy: { name: "asc" },
})
: [];
// Heatmap-Zellen: Auswirkung (Y, 5 oben) × Wahrscheinlichkeit (X)
const cell = (likelihood: number, impact: number) =>
risks.filter((r) => r.likelihood === likelihood && r.impact === impact);
const legend = [
["low", t("legendLow")],
["medium", t("legendMedium")],
["elevated", t("legendElevated")],
["high", t("legendHigh")],
["critical", t("legendCritical")],
] as const;
return (
<main className="flex-1 p-6">
<PageHead
crumb={tAssets("crumb")}
title={t("title")}
sub={t("sub")}
actions={
<>
<Button variant="outline" disabled title={tc("comingSoon")}>
{t("export")}
</Button>
{canWrite && (
<Button nativeButton={false} render={<Link href="/risks?new=1" />}>
<Plus className="size-4" /> {t("newRisk")}
</Button>
)}
</>
}
/>
<div className="grid gap-4 xl:grid-cols-2">
{/* Heatmap */}
<div className="shadow-card rounded-xl border bg-card p-4">
<SectTitle title={t("heatmap")} sub={t("heatmapSub")} />
<div className="mt-3 grid grid-cols-[1.4rem_repeat(5,1fr)] gap-1">
{[5, 4, 3, 2, 1].map((impact) => (
<div key={impact} className="contents">
<div className="grid place-items-center text-[11px] font-bold text-muted-foreground">
{impact}
</div>
{[1, 2, 3, 4, 5].map((likelihood) => {
const cellRisks = cell(likelihood, impact);
return (
<div
key={likelihood}
className="flex min-h-14 flex-wrap content-start gap-1 rounded-md p-1.5"
style={{ background: RISK_CELL_COLORS[riskLevel(likelihood * impact)] }}
title={`${likelihood} × ${impact} = ${likelihood * impact}`}
>
{cellRisks.map((r) => (
<Link
key={r.id}
href={`/risks?detail=${r.id}`}
title={r.title}
className="rounded-md bg-white/85 px-1.5 py-0.5 font-heading text-[10.5px] font-bold text-foreground shadow-sm hover:bg-white"
>
{riskRef(r.refNo)}
</Link>
))}
</div>
);
})}
</div>
))}
<div />
{[1, 2, 3, 4, 5].map((l) => (
<div key={l} className="text-center text-[11px] font-bold text-muted-foreground">
{l}
</div>
))}
</div>
<div className="mt-2 flex items-center justify-between text-[11px] text-muted-foreground">
<span>{t("axisY")}</span>
<span>{t("axisX")}</span>
</div>
<div className="mt-3 flex flex-wrap gap-3 text-[11.5px] text-muted-foreground">
{legend.map(([key, label]) => (
<span key={key} className="inline-flex items-center gap-1.5">
<i
className="inline-block size-3 rounded-[4px]"
style={{ background: RISK_CELL_COLORS[key] }}
/>
{label}
</span>
))}
</div>
</div>
{/* Register */}
<div className="shadow-card rounded-xl border bg-card">
<div className="p-4 pb-0">
<SectTitle title={t("register")} sub={t("registerSub")} />
</div>
<Table className="mt-1">
<TableHeader>
<TableRow>
<TableHead>{t("id")}</TableHead>
<TableHead>{t("risk")}</TableHead>
<TableHead>{t("rating")}</TableHead>
<TableHead>{t("treatment")}</TableHead>
<TableHead>{t("status")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{risks.length === 0 && (
<TableRow>
<TableCell colSpan={5} className="py-8 text-center text-muted-foreground">
{t("empty")}
</TableCell>
</TableRow>
)}
{risks.map((risk) => (
<TableRow key={risk.id}>
<TableCell className="text-muted-foreground">{riskRef(risk.refNo)}</TableCell>
<TableCell>
<Link href={`/risks?detail=${risk.id}`} className="font-bold hover:underline">
{risk.title}
</Link>
</TableCell>
<TableCell>
<Pill tone={RISK_PILL_TONE[riskLevel(risk.score)]}>
{t("scoreLabel", { score: risk.score, level: tLevel(riskLevel(risk.score)) })}
</Pill>
</TableCell>
<TableCell>
<Tag>{tTreat(risk.treatment)}</Tag>
</TableCell>
<TableCell>
<Pill tone={STATUS_TONE[risk.status]}>{tStatus(risk.status)}</Pill>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
{modalRisk && params.edit && canWrite ? (
<RiskEditModal
risk={modalRisk}
users={users}
processes={processes}
availableAssets={availableAssets}
/>
) : modalRisk ? (
<RiskDetailModal risk={modalRisk} canWrite={canWrite} />
) : params.new && canWrite ? (
<RiskCreateModal users={users} processes={processes} />
) : null}
</main>
);
}
+96 -35
View File
@@ -13,19 +13,36 @@ import { Button } from "@/components/ui/button";
import { Modal } from "@/components/modal";
import { AssetForm } from "@/components/asset-form";
import { CiaBadge, Pill, Tag } from "@/components/mockup-ui";
import { riskLevel, riskRef, RISK_PILL_TONE } from "@/lib/risk";
export type AssetWithRelations = Prisma.AssetGetPayload<{
include: {
owner: { select: { name: true } };
relationsFrom: { include: { relatedAsset: { select: { id: true; name: true } } } };
relationsFrom: {
include: {
relatedAsset: {
select: {
id: true;
name: true;
type: true;
confidentiality: true;
integrity: true;
availability: true;
};
};
};
};
relationsTo: { include: { asset: { select: { id: true; name: true } } } };
processAssets: { include: { process: { select: { id: true; name: true } } } };
riskAssets: {
include: { risk: { select: { id: true; refNo: true; title: true; score: true } } };
};
};
}>;
const STATUS_TONE = { ACTIVE: "ok", PLANNED: "info", RETIRED: "mut" } as const;
/** Read-only-Detailansicht als Popup — keine veränderbaren Werte. */
/** Read-only-Detailansicht als Popup — optisch analog zum Prozess-Detail. */
export async function AssetDetailModal({
asset,
canWrite,
@@ -37,13 +54,14 @@ export async function AssetDetailModal({
const tType = await getTranslations("assetType");
const tStatus = await getTranslations("assetStatus");
const tRole = await getTranslations("processRole");
const tLevel = await getTranslations("riskLevel");
const tc = await getTranslations("common");
const tp = await getTranslations("processes");
return (
<Modal
title={asset.name}
sub={t("detailTitle")}
sub={t("detailSub")}
headerExtra={
<span className="flex items-center gap-2">
<Tag>{tType(asset.type)}</Tag>
@@ -70,25 +88,34 @@ export async function AssetDetailModal({
}
>
<div className="grid gap-5 p-5 md:grid-cols-2">
{/* Stammdaten — violette Karte wie das primäre Asset im Prozess-Popup */}
<div>
<dl className="grid grid-cols-[8.5rem_1fr] gap-2 text-sm">
<dt className="text-muted-foreground">{t("description")}</dt>
<dd className="whitespace-pre-wrap">{asset.description ?? tc("none")}</dd>
<dt className="text-muted-foreground">{t("owner")}</dt>
<dd>{asset.owner?.name ?? tc("none")}</dd>
<dt className="text-muted-foreground">{t("location")}</dt>
<dd>{asset.location ?? tc("none")}</dd>
<dt className="text-muted-foreground">{t("protection")}</dt>
<dd>
<div className="mb-2.5 flex items-center gap-2.5">
<Pill tone="violet">{t("masterPill")}</Pill>
<span className="text-[12.5px] text-muted-foreground">{t("masterNote")}</span>
</div>
<div className="rounded-xl border border-l-[3px] border-l-[#5d52a3] bg-[#faf9fd] p-4">
<div className="flex items-center justify-between gap-2">
<b>{asset.name}</b>
<CiaBadge c={asset.confidentiality} i={asset.integrity} a={asset.availability} />
</dd>
<dt className="text-muted-foreground">Tags</dt>
<dd className="flex flex-wrap gap-1">
{asset.tags.length === 0 ? tc("none") : asset.tags.map((tag) => <Tag key={tag}>{tag}</Tag>)}
</dd>
</dl>
</div>
<div className="mt-1.5 text-[12.5px] leading-relaxed text-muted-foreground">
{t("owner")}: {asset.owner?.name ?? tc("none")} · {t("type")}: {tType(asset.type)}
{asset.location ? ` · ${t("location")}: ${asset.location}` : ""}
</div>
{asset.description && (
<p className="mt-2 text-[12.5px] leading-relaxed">{asset.description}</p>
)}
{asset.tags.length > 0 && (
<div className="mt-2 flex flex-wrap gap-1">
{asset.tags.map((tag) => (
<Tag key={tag}>{tag}</Tag>
))}
</div>
)}
</div>
<div className="mt-5">
<div className="mt-4">
<p className="text-sm font-semibold">{t("processes")}</p>
{asset.processAssets.length === 0 && (
<p className="mt-1 text-sm text-muted-foreground">{tc("none")}</p>
@@ -106,21 +133,39 @@ export async function AssetDetailModal({
</div>
</div>
{/* Abhängigkeiten — Tabelle wie die sekundären Assets im Prozess-Popup */}
<div>
<p className="text-sm font-semibold">{t("relations")}</p>
<p className="mt-2 text-[12.5px] text-muted-foreground">{t("relationHint")}</p>
<div className="mb-2.5 flex items-center gap-2.5">
<Pill tone="info">{t("depPill")}</Pill>
<span className="text-[12.5px] text-muted-foreground">{t("depNote")}</span>
</div>
<p className="text-[12.5px] text-muted-foreground">{t("relationHint")}</p>
{asset.relationsFrom.length === 0 && (
<p className="text-sm text-muted-foreground">{tc("none")}</p>
)}
<ul className="mt-1 space-y-1 text-sm">
{asset.relationsFrom.map((rel) => (
<li key={rel.id}>
<Link href={`/assets?detail=${rel.relatedAsset.id}`} className="hover:underline">
{rel.relatedAsset.name}
</Link>
</li>
))}
</ul>
<table className="w-full text-sm">
<tbody>
{asset.relationsFrom.map((rel) => (
<tr key={rel.id} className="border-b last:border-0">
<td className="py-2.5 pr-2 font-bold">
<Link href={`/assets?detail=${rel.relatedAsset.id}`} className="hover:underline">
{rel.relatedAsset.name}
</Link>
</td>
<td className="py-2.5 pr-2">
<Tag>{tType(rel.relatedAsset.type)}</Tag>
</td>
<td className="py-2.5 text-right">
<CiaBadge
c={rel.relatedAsset.confidentiality}
i={rel.relatedAsset.integrity}
a={rel.relatedAsset.availability}
/>
</td>
</tr>
))}
</tbody>
</table>
<p className="mt-3 text-[12.5px] text-muted-foreground">{t("relationReverseHint")}</p>
{asset.relationsTo.length === 0 && (
<p className="text-sm text-muted-foreground">{tc("none")}</p>
@@ -134,13 +179,29 @@ export async function AssetDetailModal({
</li>
))}
</ul>
<div className="mt-5 rounded-xl border border-[#e6e2f3] bg-[#f2f0f9] p-4 text-[12.5px] text-[#5a4e86]">
<b>{t("linkedRisks")}</b>
<p className="mt-1">{t("linkedRisksPlaceholder")}</p>
</div>
</div>
</div>
{/* Zugeordnete Risiken — Band wie die BIA-Kennzahlen im Prozess-Popup */}
<div className="mx-5 mb-5 rounded-xl border border-[#e6e2f3] bg-[#f2f0f9] p-4 text-[12.5px] text-[#5a4e86]">
<b>{t("linkedRisks")}</b>
{asset.riskAssets.length === 0 && <p className="mt-1">{t("linkedRisksPlaceholder")}</p>}
<ul className="mt-2 space-y-1.5">
{asset.riskAssets.map((ra) => (
<li key={ra.id} className="flex items-center gap-2">
<Link href={`/risks?detail=${ra.risk.id}`} className="font-bold hover:underline">
{riskRef(ra.risk.refNo)}
</Link>
<Link href={`/risks?detail=${ra.risk.id}`} className="hover:underline">
{ra.risk.title}
</Link>
<Pill tone={RISK_PILL_TONE[riskLevel(ra.risk.score)]}>
{ra.risk.score} · {tLevel(riskLevel(ra.risk.score))}
</Pill>
</li>
))}
</ul>
</div>
</Modal>
);
}
-30
View File
@@ -1,30 +0,0 @@
import Link from "next/link";
import { cn } from "@/lib/utils";
/** Tab-Leiste innerhalb eines Moduls (z. B. Assets ↔ Prozesse & BIA). */
export function ModuleTabs({
tabs,
active,
}: {
tabs: { href: string; label: string }[];
active: string;
}) {
return (
<div className="flex gap-1 border-b">
{tabs.map((tab) => (
<Link
key={tab.href}
href={tab.href}
className={cn(
"-mb-px border-b-2 px-4 py-2 text-sm transition-colors",
active === tab.href
? "border-primary font-medium text-foreground"
: "border-transparent text-muted-foreground hover:text-foreground"
)}
>
{tab.label}
</Link>
))}
</div>
);
}
+16
View File
@@ -19,6 +19,7 @@ export async function ProcessForm({
cancelHref: string;
}) {
const t = await getTranslations("processes");
const tCat = await getTranslations("processCategory");
const tc = await getTranslations("common");
return (
@@ -37,6 +38,21 @@ export async function ProcessForm({
className="mt-1"
/>
</div>
<div>
<Label htmlFor="category">{t("category")}</Label>
<select
id="category"
name="category"
defaultValue={process?.category ?? "CORE"}
className="mt-1 h-9 w-full rounded-md border border-input bg-transparent px-3 text-sm"
>
{(["CORE", "MANAGEMENT", "SUPPORT"] as const).map((v) => (
<option key={v} value={v}>
{tCat(v)}
</option>
))}
</select>
</div>
<div>
<Label htmlFor="ownerId">{t("owner")}</Label>
<select
+59 -6
View File
@@ -17,6 +17,14 @@ import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Modal } from "@/components/modal";
import { CiaBadge, CriticalityPill, Pill, Tag } from "@/components/mockup-ui";
import { riskLevel, riskRef, RISK_PILL_TONE } from "@/lib/risk";
export type LinkedRisk = {
id: string;
refNo: number;
title: string;
score: number;
};
export type ProcessWithDetail = Prisma.ProcessGetPayload<{
include: {
@@ -62,15 +70,19 @@ export async function ProcessCreateModal({
/** Read-only-Prozess-Detail als Popup (Mockup: "Prozess-Detail · …"). */
export async function ProcessDetailModal({
process,
risks,
canWrite,
}: {
process: ProcessWithDetail;
risks: LinkedRisk[];
canWrite: boolean;
}) {
const t = await getTranslations("processes");
const tAssets = await getTranslations("assets");
const tType = await getTranslations("assetType");
const tCrit = await getTranslations("criticality");
const tCat = await getTranslations("processCategory");
const tLevel = await getTranslations("riskLevel");
const tc = await getTranslations("common");
const hours = (v: number | null | undefined) => (v != null ? `${v} h` : tc("none"));
@@ -82,12 +94,15 @@ export async function ProcessDetailModal({
title={t("detailHeading", { name: process.name })}
sub={t("detailSub")}
headerExtra={
process.bia && (
<CriticalityPill
level={process.bia.criticality}
label={t("critLabel", { label: tCrit(String(process.bia.criticality)) })}
/>
)
<span className="flex items-center gap-2">
<Tag>{tCat(process.category)}</Tag>
{process.bia && (
<CriticalityPill
level={process.bia.criticality}
label={t("critLabel", { label: tCrit(String(process.bia.criticality)) })}
/>
)}
</span>
}
closeHref="/processes"
closeLabel={t("close")}
@@ -193,6 +208,28 @@ export async function ProcessDetailModal({
<p className="mt-3 border-t border-[#e6e2f3] pt-2.5">{process.bia.notes}</p>
)}
</div>
<div className="mx-5 mb-5">
<p className="text-sm font-semibold">{t("linkedRisks")}</p>
{risks.length === 0 && (
<p className="mt-1 text-sm text-muted-foreground">{tc("none")}</p>
)}
<ul className="mt-1.5 space-y-1.5 text-sm">
{risks.map((r) => (
<li key={r.id} className="flex items-center gap-2">
<Link href={`/risks?detail=${r.id}`} className="font-bold hover:underline">
{riskRef(r.refNo)}
</Link>
<Link href={`/risks?detail=${r.id}`} className="hover:underline">
{r.title}
</Link>
<Pill tone={RISK_PILL_TONE[riskLevel(r.score)]}>
{r.score} · {tLevel(riskLevel(r.score))}
</Pill>
</li>
))}
</ul>
</div>
</Modal>
);
}
@@ -211,6 +248,7 @@ export async function ProcessEditModal({
const tAssets = await getTranslations("assets");
const tRole = await getTranslations("processRole");
const tCrit = await getTranslations("criticality");
const tCat = await getTranslations("processCategory");
const tc = await getTranslations("common");
const bia = process.bia;
@@ -258,6 +296,21 @@ export async function ProcessEditModal({
className="mt-1"
/>
</div>
<div>
<Label htmlFor="category">{t("category")}</Label>
<select
id="category"
name="category"
defaultValue={process.category}
className={`${selectClass} mt-1 w-full`}
>
{(["CORE", "MANAGEMENT", "SUPPORT"] as const).map((v) => (
<option key={v} value={v}>
{tCat(v)}
</option>
))}
</select>
</div>
<div>
<Label htmlFor="ownerId">{t("owner")}</Label>
<select
+494
View File
@@ -0,0 +1,494 @@
import Link from "next/link";
import { getTranslations } from "next-intl/server";
import { Pencil, Trash2, X } from "lucide-react";
import type { Prisma } from "@prisma/client";
import {
addRiskAsset,
createRisk,
deleteRisk,
removeRiskAsset,
updateRisk,
} from "@/server/actions/risks";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Modal } from "@/components/modal";
import { CiaBadge, Pill, Tag } from "@/components/mockup-ui";
import { riskLevel, riskRef, RISK_PILL_TONE } from "@/lib/risk";
export type RiskWithDetail = Prisma.RiskGetPayload<{
include: {
owner: { select: { id: true; name: true } };
process: { select: { id: true; name: true } };
riskAssets: {
include: {
asset: {
select: {
id: true;
name: true;
type: true;
confidentiality: true;
integrity: true;
availability: true;
};
};
};
};
};
}>;
const SCALE = [1, 2, 3, 4, 5] as const;
const TREATMENTS = ["AVOID", "MITIGATE", "TRANSFER", "ACCEPT"] as const;
const STATUSES = ["OPEN", "IN_TREATMENT", "ACCEPTED", "CLOSED"] as const;
const STATUS_TONE = {
OPEN: "risk",
IN_TREATMENT: "warn",
ACCEPTED: "info",
CLOSED: "mut",
} as const;
async function scorePill(score: number) {
const tLevel = await getTranslations("riskLevel");
const level = riskLevel(score);
return (
<Pill tone={RISK_PILL_TONE[level]}>
{score} · {tLevel(level)}
</Pill>
);
}
/** Read-only-Risiko-Detail als Popup (SPEC §4.2 Risiko-Detailansicht). */
export async function RiskDetailModal({
risk,
canWrite,
}: {
risk: RiskWithDetail;
canWrite: boolean;
}) {
const t = await getTranslations("risks");
const tType = await getTranslations("assetType");
const tTreat = await getTranslations("riskTreatment");
const tStatus = await getTranslations("riskStatus");
const tc = await getTranslations("common");
return (
<Modal
title={t("detailHeading", { ref: riskRef(risk.refNo) })}
sub={t("detailSub")}
headerExtra={
<span className="flex items-center gap-2">
<Pill tone={STATUS_TONE[risk.status]}>{tStatus(risk.status)}</Pill>
{await scorePill(risk.score)}
</span>
}
closeHref="/risks"
closeLabel={t("close")}
footer={
<>
{canWrite && (
<Button
variant="outline"
nativeButton={false}
render={<Link href={`/risks?edit=${risk.id}`} />}
>
<Pencil className="size-4" /> {tc("edit")}
</Button>
)}
<Button nativeButton={false} render={<Link href="/risks" />}>
{t("close")}
</Button>
</>
}
>
<div className="grid gap-5 p-5 md:grid-cols-2">
<div>
<div className="rounded-xl border border-l-[3px] border-l-[#5d52a3] bg-[#faf9fd] p-4">
<b>{risk.title}</b>
{risk.description && (
<p className="mt-1.5 text-[12.5px] leading-relaxed">{risk.description}</p>
)}
<dl className="mt-3 grid grid-cols-[7.5rem_1fr] gap-1.5 text-[12.5px]">
<dt className="text-muted-foreground">{t("threat")}</dt>
<dd>{risk.threat ?? tc("none")}</dd>
<dt className="text-muted-foreground">{t("vulnerability")}</dt>
<dd>{risk.vulnerability ?? tc("none")}</dd>
<dt className="text-muted-foreground">{t("owner")}</dt>
<dd>{risk.owner?.name ?? tc("none")}</dd>
<dt className="text-muted-foreground">{t("treatment")}</dt>
<dd>
<Tag>{tTreat(risk.treatment)}</Tag>
</dd>
<dt className="text-muted-foreground">{t("process")}</dt>
<dd>
{risk.process ? (
<Link href={`/processes?detail=${risk.process.id}`} className="hover:underline">
{risk.process.name}
</Link>
) : (
tc("none")
)}
</dd>
</dl>
</div>
</div>
<div>
<div className="mb-2.5 flex items-center gap-2.5">
<Pill tone="info">{t("affectedAssets")}</Pill>
<span className="text-[12.5px] text-muted-foreground">{t("affectedNote")}</span>
</div>
{risk.riskAssets.length === 0 && (
<p className="text-sm text-muted-foreground">{t("noAssets")}</p>
)}
<table className="w-full text-sm">
<tbody>
{risk.riskAssets.map((ra) => (
<tr key={ra.id} className="border-b last:border-0">
<td className="py-2.5 pr-2 font-bold">
<Link href={`/assets?detail=${ra.asset.id}`} className="hover:underline">
{ra.asset.name}
</Link>
</td>
<td className="py-2.5 pr-2">
<Tag>{tType(ra.asset.type)}</Tag>
</td>
<td className="py-2.5 text-right">
<CiaBadge
c={ra.asset.confidentiality}
i={ra.asset.integrity}
a={ra.asset.availability}
/>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
{/* Brutto- vs. Rest-Risiko */}
<div className="mx-5 rounded-xl border border-[#e6e2f3] bg-[#f2f0f9] p-4 text-[12.5px] text-[#5a4e86]">
<div className="grid gap-2 sm:grid-cols-2">
<div>
<b>{t("gross")}</b>
<div className="mt-1.5 flex items-center gap-2">
{risk.likelihood} × {risk.impact} = {await scorePill(risk.score)}
</div>
</div>
<div>
<b>{t("residual")}</b>
<div className="mt-1.5 flex items-center gap-2">
{risk.residualScore != null ? (
<>
{risk.residualLikelihood} × {risk.residualImpact} ={" "}
{await scorePill(risk.residualScore)}
</>
) : (
t("noResidual")
)}
</div>
<p className="mt-1 text-muted-foreground">{t("residualHint")}</p>
</div>
</div>
</div>
<div className="mx-5 mb-5 mt-4">
<p className="text-sm font-semibold">{t("measures")}</p>
<p className="mt-1 text-sm text-muted-foreground">{t("measuresPlaceholder")}</p>
</div>
</Modal>
);
}
/** Bearbeiten als Popup: Bewertung, Behandlung, betroffene Assets, Löschen. */
export async function RiskEditModal({
risk,
users,
processes,
availableAssets,
}: {
risk: RiskWithDetail;
users: { id: string; name: string }[];
processes: { id: string; name: string }[];
availableAssets: { id: string; name: string }[];
}) {
const t = await getTranslations("risks");
const tc = await getTranslations("common");
const selectClass = "h-9 rounded-md border border-input bg-transparent px-3 text-sm";
return (
<Modal
title={t("editTitle")}
sub={`${riskRef(risk.refNo)} · ${risk.title}`}
closeHref={`/risks?detail=${risk.id}`}
closeLabel={t("close")}
>
<div className="grid gap-6 p-5 md:grid-cols-2">
<form
key={risk.updatedAt.toISOString()}
action={updateRisk.bind(null, risk.id)}
className="space-y-4"
>
<RiskFields risk={risk} users={users} processes={processes} selectClass={selectClass} />
<Button type="submit">{tc("save")}</Button>
</form>
<div className="space-y-4 text-sm">
<div>
<p className="font-medium">{t("affectedAssets")}</p>
<p className="mb-1 text-xs text-muted-foreground">{t("affectedNote")}</p>
{risk.riskAssets.length === 0 && <p>{tc("none")}</p>}
<ul className="space-y-1.5">
{risk.riskAssets.map((ra) => (
<li key={ra.id} className="flex items-center gap-2">
{ra.asset.name}
<form action={removeRiskAsset.bind(null, risk.id, ra.id)}>
<button
type="submit"
title={tc("remove")}
className="text-muted-foreground hover:text-destructive"
>
<X className="size-3.5" />
</button>
</form>
</li>
))}
</ul>
</div>
{availableAssets.length > 0 && (
<form action={addRiskAsset.bind(null, risk.id)} className="flex gap-2 border-t pt-3">
<select name="assetId" required className={`${selectClass} flex-1`}>
{availableAssets.map((a) => (
<option key={a.id} value={a.id}>
{a.name}
</option>
))}
</select>
<Button type="submit" variant="secondary" size="sm">
{t("addAsset")}
</Button>
</form>
)}
<div className="border-t pt-4">
<form action={deleteRisk.bind(null, risk.id)}>
<Button type="submit" variant="destructive" size="sm">
<Trash2 className="size-4" /> {tc("delete")}
</Button>
</form>
</div>
</div>
</div>
</Modal>
);
}
/** Formularfelder (Anlegen + Bearbeiten teilen sich das Markup). */
async function RiskFields({
risk,
users,
processes,
selectClass,
}: {
risk?: RiskWithDetail;
users: { id: string; name: string }[];
processes: { id: string; name: string }[];
selectClass: string;
}) {
const t = await getTranslations("risks");
const tTreat = await getTranslations("riskTreatment");
const tStatus = await getTranslations("riskStatus");
const tc = await getTranslations("common");
return (
<>
<div>
<Label htmlFor="title">{t("titleField")}</Label>
<Input id="title" name="title" required defaultValue={risk?.title} className="mt-1" />
</div>
<div>
<Label htmlFor="description">{t("description")}</Label>
<Textarea
id="description"
name="description"
rows={2}
defaultValue={risk?.description ?? ""}
className="mt-1"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="threat">{t("threat")}</Label>
<Input id="threat" name="threat" defaultValue={risk?.threat ?? ""} className="mt-1" />
</div>
<div>
<Label htmlFor="vulnerability">{t("vulnerability")}</Label>
<Input
id="vulnerability"
name="vulnerability"
defaultValue={risk?.vulnerability ?? ""}
className="mt-1"
/>
</div>
<div>
<Label htmlFor="likelihood">{t("likelihood")}</Label>
<select
id="likelihood"
name="likelihood"
defaultValue={risk?.likelihood ?? 3}
className={`${selectClass} mt-1 w-full`}
>
{SCALE.map((v) => (
<option key={v} value={v}>
{v}
</option>
))}
</select>
</div>
<div>
<Label htmlFor="impact">{t("impact")}</Label>
<select
id="impact"
name="impact"
defaultValue={risk?.impact ?? 3}
className={`${selectClass} mt-1 w-full`}
>
{SCALE.map((v) => (
<option key={v} value={v}>
{v}
</option>
))}
</select>
</div>
<div>
<Label htmlFor="residualLikelihood">
{t("residual")} {t("likelihood")}
</Label>
<select
id="residualLikelihood"
name="residualLikelihood"
defaultValue={risk?.residualLikelihood ?? ""}
className={`${selectClass} mt-1 w-full`}
>
<option value="">{tc("none")}</option>
{SCALE.map((v) => (
<option key={v} value={v}>
{v}
</option>
))}
</select>
</div>
<div>
<Label htmlFor="residualImpact">
{t("residual")} {t("impact")}
</Label>
<select
id="residualImpact"
name="residualImpact"
defaultValue={risk?.residualImpact ?? ""}
className={`${selectClass} mt-1 w-full`}
>
<option value="">{tc("none")}</option>
{SCALE.map((v) => (
<option key={v} value={v}>
{v}
</option>
))}
</select>
</div>
<div>
<Label htmlFor="treatment">{t("treatment")}</Label>
<select
id="treatment"
name="treatment"
defaultValue={risk?.treatment ?? "MITIGATE"}
className={`${selectClass} mt-1 w-full`}
>
{TREATMENTS.map((v) => (
<option key={v} value={v}>
{tTreat(v)}
</option>
))}
</select>
</div>
<div>
<Label htmlFor="status">{t("status")}</Label>
<select
id="status"
name="status"
defaultValue={risk?.status ?? "OPEN"}
className={`${selectClass} mt-1 w-full`}
>
{STATUSES.map((v) => (
<option key={v} value={v}>
{tStatus(v)}
</option>
))}
</select>
</div>
<div>
<Label htmlFor="ownerId">{t("owner")}</Label>
<select
id="ownerId"
name="ownerId"
defaultValue={risk?.ownerId ?? ""}
className={`${selectClass} mt-1 w-full`}
>
<option value="">{tc("none")}</option>
{users.map((u) => (
<option key={u.id} value={u.id}>
{u.name}
</option>
))}
</select>
</div>
<div>
<Label htmlFor="processId">{t("process")}</Label>
<select
id="processId"
name="processId"
defaultValue={risk?.processId ?? ""}
className={`${selectClass} mt-1 w-full`}
>
<option value="">{tc("none")}</option>
{processes.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
</option>
))}
</select>
</div>
</div>
</>
);
}
/** Anlegen als Popup. */
export async function RiskCreateModal({
users,
processes,
}: {
users: { id: string; name: string }[];
processes: { id: string; name: string }[];
}) {
const t = await getTranslations("risks");
const tc = await getTranslations("common");
const selectClass = "h-9 rounded-md border border-input bg-transparent px-3 text-sm";
return (
<Modal title={t("createTitle")} closeHref="/risks" closeLabel={t("close")}>
<form action={createRisk} className="space-y-4 p-5">
<RiskFields users={users} processes={processes} selectClass={selectClass} />
<div className="flex gap-2 pt-1">
<Button type="submit">{tc("save")}</Button>
<Button variant="outline" nativeButton={false} render={<Link href="/risks" />}>
{tc("cancel")}
</Button>
</div>
</form>
</Modal>
);
}
+33
View File
@@ -0,0 +1,33 @@
/** Score-Bänder der 5×5-Matrix (Farben/Stufen aus dem Mockup). */
export type RiskLevelKey = "low" | "medium" | "elevated" | "high" | "critical";
export function riskLevel(score: number): RiskLevelKey {
if (score >= 20) return "critical";
if (score >= 15) return "high";
if (score >= 10) return "elevated";
if (score >= 5) return "medium";
return "low";
}
/** Zellen-Hintergrund der Heatmap (Mockup-Legende). */
export const RISK_CELL_COLORS: Record<RiskLevelKey, string> = {
low: "#dff3e5",
medium: "#fdf0cf",
elevated: "#fbdcc0",
high: "#f6c3c1",
critical: "#ef9a98",
};
/** Pillen-Ton je Stufe (mockup-ui Pill). */
export const RISK_PILL_TONE: Record<RiskLevelKey, "ok" | "info" | "warn" | "risk"> = {
low: "ok",
medium: "info",
elevated: "warn",
high: "risk",
critical: "risk",
};
export function riskRef(refNo: number): string {
return `R-${String(refNo).padStart(3, "0")}`;
}
+2
View File
@@ -11,6 +11,7 @@ import { writeAuditLog } from "@/server/audit";
const processSchema = z.object({
name: z.string().trim().min(1).max(200),
description: z.string().trim().max(5000).optional(),
category: z.enum(["CORE", "MANAGEMENT", "SUPPORT"]),
ownerId: z.string().optional(),
});
@@ -18,6 +19,7 @@ function parseProcessForm(formData: FormData) {
const parsed = processSchema.parse({
name: formData.get("name"),
description: formData.get("description") || undefined,
category: formData.get("category"),
ownerId: formData.get("ownerId") || undefined,
});
return {
+191
View File
@@ -0,0 +1,191 @@
"use server";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { z } from "zod";
import { requireSession } from "@/server/auth";
import { dbForTenant, prisma } from "@/server/db";
import { requirePermission } from "@/server/rbac";
import { writeAuditLog } from "@/server/audit";
const scale = z.coerce.number().int().min(1).max(5);
const optionalScale = z
.union([z.literal(""), z.literal(null), scale])
.transform((v) => (v === "" || v === null ? null : v));
const riskSchema = z.object({
title: z.string().trim().min(1).max(200),
description: z.string().trim().max(5000).optional(),
threat: z.string().trim().max(500).optional(),
vulnerability: z.string().trim().max(500).optional(),
likelihood: scale,
impact: scale,
residualLikelihood: optionalScale,
residualImpact: optionalScale,
treatment: z.enum(["AVOID", "MITIGATE", "TRANSFER", "ACCEPT"]),
status: z.enum(["OPEN", "IN_TREATMENT", "ACCEPTED", "CLOSED"]),
ownerId: z.string().optional(),
processId: z.string().optional(),
});
function parseRiskForm(formData: FormData) {
const parsed = riskSchema.parse({
title: formData.get("title"),
description: formData.get("description") || undefined,
threat: formData.get("threat") || undefined,
vulnerability: formData.get("vulnerability") || undefined,
likelihood: formData.get("likelihood"),
impact: formData.get("impact"),
residualLikelihood: formData.get("residualLikelihood") ?? "",
residualImpact: formData.get("residualImpact") ?? "",
treatment: formData.get("treatment"),
status: formData.get("status"),
ownerId: formData.get("ownerId") || undefined,
processId: formData.get("processId") || undefined,
});
const residual =
parsed.residualLikelihood != null && parsed.residualImpact != null
? parsed.residualLikelihood * parsed.residualImpact
: null;
return {
title: parsed.title,
description: parsed.description || null,
threat: parsed.threat || null,
vulnerability: parsed.vulnerability || null,
likelihood: parsed.likelihood,
impact: parsed.impact,
score: parsed.likelihood * parsed.impact,
residualLikelihood: parsed.residualLikelihood,
residualImpact: parsed.residualImpact,
residualScore: residual,
treatment: parsed.treatment,
status: parsed.status,
ownerId: parsed.ownerId || null,
processId: parsed.processId || null,
};
}
export async function createRisk(formData: FormData) {
const session = await requireSession();
requirePermission(session, "risk:write");
const db = dbForTenant(session.user.tenantId);
const data = parseRiskForm(formData);
// Laufende Nummer je Mandant (MVP: max+1; bei Kollision greift der Unique-Index)
const last = await prisma.risk.aggregate({
where: { tenantId: session.user.tenantId },
_max: { refNo: true },
});
const risk = await db.risk.create({
data: {
...data,
refNo: (last._max.refNo ?? 0) + 1,
tenantId: session.user.tenantId,
createdBy: session.user.id,
},
});
await writeAuditLog({
tenantId: session.user.tenantId,
actorId: session.user.id,
action: "create",
entity: "risk",
entityId: risk.id,
after: data,
});
revalidatePath("/risks");
redirect(`/risks?edit=${risk.id}`);
}
export async function updateRisk(riskId: string, formData: FormData) {
const session = await requireSession();
requirePermission(session, "risk:write");
const db = dbForTenant(session.user.tenantId);
const before = await db.risk.findUnique({ where: { id: riskId } });
if (!before) throw new Error("Risiko nicht gefunden");
const data = parseRiskForm(formData);
await db.risk.update({ where: { id: riskId }, data });
await writeAuditLog({
tenantId: session.user.tenantId,
actorId: session.user.id,
action: "update",
entity: "risk",
entityId: riskId,
before,
after: data,
});
revalidatePath("/risks");
redirect(`/risks?detail=${riskId}`);
}
export async function deleteRisk(riskId: string) {
const session = await requireSession();
requirePermission(session, "risk:write");
const db = dbForTenant(session.user.tenantId);
const before = await db.risk.findUnique({ where: { id: riskId } });
if (!before) throw new Error("Risiko nicht gefunden");
await db.risk.delete({ where: { id: riskId } });
await writeAuditLog({
tenantId: session.user.tenantId,
actorId: session.user.id,
action: "delete",
entity: "risk",
entityId: riskId,
before,
});
revalidatePath("/risks");
redirect("/risks");
}
export async function addRiskAsset(riskId: string, formData: FormData) {
const session = await requireSession();
requirePermission(session, "risk:write");
const db = dbForTenant(session.user.tenantId);
const assetId = z.string().min(1).parse(formData.get("assetId"));
const assetCount = await db.asset.count({ where: { id: assetId } });
const riskCount = await db.risk.count({ where: { id: riskId } });
if (assetCount !== 1 || riskCount !== 1) throw new Error("Nicht gefunden");
await db.riskAsset.upsert({
where: { riskId_assetId: { riskId, assetId } },
update: {},
create: { riskId, assetId, tenantId: session.user.tenantId },
});
await writeAuditLog({
tenantId: session.user.tenantId,
actorId: session.user.id,
action: "create",
entity: "risk_asset",
entityId: riskId,
after: { riskId, assetId },
});
revalidatePath("/risks");
}
export async function removeRiskAsset(riskId: string, riskAssetId: string) {
const session = await requireSession();
requirePermission(session, "risk:write");
const db = dbForTenant(session.user.tenantId);
const before = await db.riskAsset.findUnique({ where: { id: riskAssetId } });
if (!before) return;
await db.riskAsset.delete({ where: { id: riskAssetId } });
await writeAuditLog({
tenantId: session.user.tenantId,
actorId: session.user.id,
action: "delete",
entity: "risk_asset",
entityId: riskAssetId,
before,
});
revalidatePath("/risks");
}
+2
View File
@@ -33,6 +33,8 @@ const TENANT_MODELS = new Set<string>([
"Process",
"ProcessAsset",
"BiaEntry",
"Risk",
"RiskAsset",
]);
/**