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
+83 -3
View File
@@ -28,7 +28,9 @@
"dependencies": "Abhängigkeiten", "dependencies": "Abhängigkeiten",
"evidence": "Nachweise", "evidence": "Nachweise",
"suppliers": "Lieferanten", "suppliers": "Lieferanten",
"review": "Management-Review" "review": "Management-Review",
"assets": "Asset-Inventar",
"bia": "Business Impact Analyse"
}, },
"login": { "login": {
"title": "Anmelden", "title": "Anmelden",
@@ -104,7 +106,13 @@
"all": "Alle", "all": "Alle",
"filter": "Filtern …", "filter": "Filtern …",
"noOwner": "kein Owner", "noOwner": "kein Owner",
"dependencies": "Abhängigkeiten" "dependencies": "Abhängigkeiten",
"detailSub": "Stammdaten, Abhängigkeiten & Risiken",
"masterPill": "■ Stammdaten",
"masterNote": "Klassifizierung & Verantwortung",
"depPill": "◆ Abhängigkeiten",
"depNote": "Verknüpfte Assets",
"riskCount": "{count} Risiken"
}, },
"assetType": { "assetType": {
"INFORMATION": "Information", "INFORMATION": "Information",
@@ -166,7 +174,8 @@
"showDependencies": "Abhängigkeiten anzeigen →", "showDependencies": "Abhängigkeiten anzeigen →",
"impactShort": "Schadenshöhe (C/I/A)", "impactShort": "Schadenshöhe (C/I/A)",
"close": "Schließen", "close": "Schließen",
"noAssets": "Noch keine Assets zugeordnet." "noAssets": "Noch keine Assets zugeordnet.",
"category": "Prozesskategorie"
}, },
"processRole": { "processRole": {
"PRIMARY": "Primär", "PRIMARY": "Primär",
@@ -183,5 +192,76 @@
"2": "Erhöht", "2": "Erhöht",
"3": "Hoch", "3": "Hoch",
"4": "Sehr hoch" "4": "Sehr hoch"
},
"processCategory": {
"CORE": "Kernprozess",
"MANAGEMENT": "Managementprozess",
"SUPPORT": "Unterstützender Prozess"
},
"risks": {
"title": "Risikoanalyse",
"sub": "5×5-Matrix · Eintrittswahrscheinlichkeit × Auswirkung",
"export": "Risikoregister exportieren",
"newRisk": "Risiko",
"heatmap": "Risiko-Heatmap",
"heatmapSub": "Auswirkung (Y) × Eintrittswahrscheinlichkeit (X)",
"register": "Risikoregister",
"registerSub": "Risiken nach Bewertung",
"id": "ID",
"risk": "Risiko",
"rating": "Bewertung",
"treatment": "Behandlung",
"status": "Status",
"owner": "Owner",
"empty": "Noch keine Risiken erfasst.",
"detailHeading": "Risiko-Detail · {ref}",
"detailSub": "Bewertung, betroffene Assets & Behandlung",
"editTitle": "Risiko bearbeiten",
"createTitle": "Risiko anlegen",
"titleField": "Titel",
"description": "Beschreibung",
"threat": "Bedrohung",
"vulnerability": "Schwachstelle",
"likelihood": "Eintrittswahrscheinlichkeit (15)",
"impact": "Auswirkung (15)",
"gross": "Brutto-Risiko",
"residual": "Rest-Risiko",
"residualHint": "Nach Umsetzung der Maßnahmen",
"noResidual": "Noch nicht bewertet",
"process": "Geschäftsprozess",
"affectedAssets": "Betroffene Assets",
"affectedNote": "Assets, auf die dieses Risiko wirkt",
"addAsset": "Asset verknüpfen",
"noAssets": "Keine Assets verknüpft.",
"measures": "Notwendige Maßnahmen",
"measuresPlaceholder": "Maßnahmen werden mit dem Maßnahmen-Modul (Iteration 4) verknüpft und erscheinen dann hier.",
"close": "Schließen",
"scoreLabel": "{score} · {level}",
"legendLow": "Gering",
"legendMedium": "Mittel",
"legendElevated": "Erhöht",
"legendHigh": "Hoch",
"legendCritical": "Kritisch",
"axisX": "Eintrittswahrscheinlichkeit →",
"axisY": "Auswirkung ↑"
},
"riskTreatment": {
"AVOID": "Vermeiden",
"MITIGATE": "Vermindern",
"TRANSFER": "Übertragen",
"ACCEPT": "Akzeptieren"
},
"riskStatus": {
"OPEN": "Offen",
"IN_TREATMENT": "In Behandlung",
"ACCEPTED": "Akzeptiert",
"CLOSED": "Geschlossen"
},
"riskLevel": {
"low": "Gering",
"medium": "Mittel",
"elevated": "Erhöht",
"high": "Hoch",
"critical": "Kritisch"
} }
} }
+83 -3
View File
@@ -28,7 +28,9 @@
"dependencies": "Dependencies", "dependencies": "Dependencies",
"evidence": "Evidence", "evidence": "Evidence",
"suppliers": "Suppliers", "suppliers": "Suppliers",
"review": "Management review" "review": "Management review",
"assets": "Asset inventory",
"bia": "Business impact analysis"
}, },
"login": { "login": {
"title": "Sign in", "title": "Sign in",
@@ -104,7 +106,13 @@
"all": "All", "all": "All",
"filter": "Filter …", "filter": "Filter …",
"noOwner": "no owner", "noOwner": "no owner",
"dependencies": "Dependencies" "dependencies": "Dependencies",
"detailSub": "Master data, dependencies & risks",
"masterPill": "■ Master data",
"masterNote": "Classification & responsibility",
"depPill": "◆ Dependencies",
"depNote": "Linked assets",
"riskCount": "{count} risks"
}, },
"assetType": { "assetType": {
"INFORMATION": "Information", "INFORMATION": "Information",
@@ -166,7 +174,8 @@
"showDependencies": "Show dependencies →", "showDependencies": "Show dependencies →",
"impactShort": "Damage level (C/I/A)", "impactShort": "Damage level (C/I/A)",
"close": "Close", "close": "Close",
"noAssets": "No assets assigned yet." "noAssets": "No assets assigned yet.",
"category": "Process category"
}, },
"processRole": { "processRole": {
"PRIMARY": "Primary", "PRIMARY": "Primary",
@@ -183,5 +192,76 @@
"2": "Elevated", "2": "Elevated",
"3": "High", "3": "High",
"4": "Very high" "4": "Very high"
},
"processCategory": {
"CORE": "Core process",
"MANAGEMENT": "Management process",
"SUPPORT": "Supporting process"
},
"risks": {
"title": "Risk analysis",
"sub": "5×5 matrix · likelihood × impact",
"export": "Export risk register",
"newRisk": "Risk",
"heatmap": "Risk heatmap",
"heatmapSub": "Impact (Y) × likelihood (X)",
"register": "Risk register",
"registerSub": "Risks by rating",
"id": "ID",
"risk": "Risk",
"rating": "Rating",
"treatment": "Treatment",
"status": "Status",
"owner": "Owner",
"empty": "No risks recorded yet.",
"detailHeading": "Risk detail · {ref}",
"detailSub": "Rating, affected assets & treatment",
"editTitle": "Edit risk",
"createTitle": "Create risk",
"titleField": "Title",
"description": "Description",
"threat": "Threat",
"vulnerability": "Vulnerability",
"likelihood": "Likelihood (15)",
"impact": "Impact (15)",
"gross": "Gross risk",
"residual": "Residual risk",
"residualHint": "After implementing measures",
"noResidual": "Not yet rated",
"process": "Business process",
"affectedAssets": "Affected assets",
"affectedNote": "Assets this risk applies to",
"addAsset": "Link asset",
"noAssets": "No assets linked.",
"measures": "Required measures",
"measuresPlaceholder": "Measures will be linked with the measures module (iteration 4) and appear here.",
"close": "Close",
"scoreLabel": "{score} · {level}",
"legendLow": "Low",
"legendMedium": "Medium",
"legendElevated": "Elevated",
"legendHigh": "High",
"legendCritical": "Critical",
"axisX": "Likelihood →",
"axisY": "Impact ↑"
},
"riskTreatment": {
"AVOID": "Avoid",
"MITIGATE": "Mitigate",
"TRANSFER": "Transfer",
"ACCEPT": "Accept"
},
"riskStatus": {
"OPEN": "Open",
"IN_TREATMENT": "In treatment",
"ACCEPTED": "Accepted",
"CLOSED": "Closed"
},
"riskLevel": {
"low": "Low",
"medium": "Medium",
"elevated": "Elevated",
"high": "High",
"critical": "Critical"
} }
} }
@@ -0,0 +1,74 @@
-- CreateEnum
CREATE TYPE "ProcessCategory" AS ENUM ('CORE', 'MANAGEMENT', 'SUPPORT');
-- CreateEnum
CREATE TYPE "RiskTreatment" AS ENUM ('AVOID', 'MITIGATE', 'TRANSFER', 'ACCEPT');
-- CreateEnum
CREATE TYPE "RiskStatus" AS ENUM ('OPEN', 'IN_TREATMENT', 'ACCEPTED', 'CLOSED');
-- AlterTable
ALTER TABLE "processes" ADD COLUMN "category" "ProcessCategory" NOT NULL DEFAULT 'CORE';
-- CreateTable
CREATE TABLE "risks" (
"id" TEXT NOT NULL,
"tenant_id" TEXT NOT NULL,
"ref_no" INTEGER NOT NULL,
"title" TEXT NOT NULL,
"description" TEXT,
"threat" TEXT,
"vulnerability" TEXT,
"likelihood" INTEGER NOT NULL DEFAULT 1,
"impact" INTEGER NOT NULL DEFAULT 1,
"score" INTEGER NOT NULL DEFAULT 1,
"residual_likelihood" INTEGER,
"residual_impact" INTEGER,
"residual_score" INTEGER,
"treatment" "RiskTreatment" NOT NULL DEFAULT 'MITIGATE',
"status" "RiskStatus" NOT NULL DEFAULT 'OPEN',
"owner_id" TEXT,
"process_id" TEXT,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
"created_by" TEXT,
CONSTRAINT "risks_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "risk_assets" (
"id" TEXT NOT NULL,
"tenant_id" TEXT NOT NULL,
"risk_id" TEXT NOT NULL,
"asset_id" TEXT NOT NULL,
CONSTRAINT "risk_assets_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "risks_tenant_id_idx" ON "risks"("tenant_id");
-- CreateIndex
CREATE INDEX "risks_tenant_id_status_idx" ON "risks"("tenant_id", "status");
-- CreateIndex
CREATE UNIQUE INDEX "risks_tenant_id_ref_no_key" ON "risks"("tenant_id", "ref_no");
-- CreateIndex
CREATE INDEX "risk_assets_tenant_id_idx" ON "risk_assets"("tenant_id");
-- CreateIndex
CREATE UNIQUE INDEX "risk_assets_risk_id_asset_id_key" ON "risk_assets"("risk_id", "asset_id");
-- AddForeignKey
ALTER TABLE "risks" ADD CONSTRAINT "risks_owner_id_fkey" FOREIGN KEY ("owner_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "risks" ADD CONSTRAINT "risks_process_id_fkey" FOREIGN KEY ("process_id") REFERENCES "processes"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "risk_assets" ADD CONSTRAINT "risk_assets_risk_id_fkey" FOREIGN KEY ("risk_id") REFERENCES "risks"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "risk_assets" ADD CONSTRAINT "risk_assets_asset_id_fkey" FOREIGN KEY ("asset_id") REFERENCES "assets"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,9 @@
-- RLS-Policies für die Risiko-Tabellen (analog zu den vorherigen RLS-Migrationen)
ALTER TABLE "risks" ENABLE ROW LEVEL SECURITY;
ALTER TABLE "risk_assets" ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON "risks"
USING ("tenant_id" = current_setting('app.tenant_id', true));
CREATE POLICY tenant_isolation ON "risk_assets"
USING ("tenant_id" = current_setting('app.tenant_id', true));
+80
View File
@@ -60,6 +60,7 @@ model User {
userRoles UserRole[] userRoles UserRole[]
ownedAssets Asset[] @relation("assetOwner") ownedAssets Asset[] @relation("assetOwner")
ownedProcesses Process[] @relation("processOwner") ownedProcesses Process[] @relation("processOwner")
ownedRisks Risk[] @relation("riskOwner")
@@unique([tenantId, email]) @@unique([tenantId, email])
@@index([tenantId]) @@index([tenantId])
@@ -135,6 +136,12 @@ enum ProcessAssetRole {
SECONDARY SECONDARY
} }
enum ProcessCategory {
CORE // Kernprozess
MANAGEMENT // Managementprozess
SUPPORT // Unterstützender Prozess
}
model Asset { model Asset {
id String @id @default(cuid()) id String @id @default(cuid())
tenantId String @map("tenant_id") tenantId String @map("tenant_id")
@@ -157,6 +164,7 @@ model Asset {
relationsFrom AssetRelation[] @relation("relationFrom") relationsFrom AssetRelation[] @relation("relationFrom")
relationsTo AssetRelation[] @relation("relationTo") relationsTo AssetRelation[] @relation("relationTo")
processAssets ProcessAsset[] processAssets ProcessAsset[]
riskAssets RiskAsset[]
@@index([tenantId]) @@index([tenantId])
@@index([tenantId, type]) @@index([tenantId, type])
@@ -184,6 +192,7 @@ model Process {
tenantId String @map("tenant_id") tenantId String @map("tenant_id")
name String name String
description String? description String?
category ProcessCategory @default(CORE)
ownerId String? @map("owner_id") ownerId String? @map("owner_id")
createdAt DateTime @default(now()) @map("created_at") createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at") updatedAt DateTime @updatedAt @map("updated_at")
@@ -192,6 +201,7 @@ model Process {
owner User? @relation("processOwner", fields: [ownerId], references: [id]) owner User? @relation("processOwner", fields: [ownerId], references: [id])
processAssets ProcessAsset[] processAssets ProcessAsset[]
bia BiaEntry? bia BiaEntry?
risks Risk[]
@@index([tenantId]) @@index([tenantId])
@@map("processes") @@map("processes")
@@ -236,6 +246,76 @@ model BiaEntry {
@@map("bia_entries") @@map("bia_entries")
} }
// ── Risikoanalyse (SPEC §4.2) ───────────────────────────────────────────────
enum RiskTreatment {
AVOID // Vermeiden
MITIGATE // Vermindern
TRANSFER // Übertragen
ACCEPT // Akzeptieren
}
enum RiskStatus {
OPEN
IN_TREATMENT
ACCEPTED
CLOSED
}
model Risk {
id String @id @default(cuid())
tenantId String @map("tenant_id")
// Laufende Nummer je Mandant → Anzeige als "R-001"
refNo Int @map("ref_no")
title String
description String?
threat String? // Bedrohung (Katalog folgt, SPEC §5)
vulnerability String? // Schwachstelle
// Brutto-Bewertung: Eintrittswahrscheinlichkeit × Auswirkung (je 15)
likelihood Int @default(1)
impact Int @default(1)
score Int @default(1) // redundant für Listen/Heatmap
// Rest-Risiko nach Maßnahmen
residualLikelihood Int? @map("residual_likelihood")
residualImpact Int? @map("residual_impact")
residualScore Int? @map("residual_score")
treatment RiskTreatment @default(MITIGATE)
status RiskStatus @default(OPEN)
ownerId String? @map("owner_id")
processId String? @map("process_id")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
createdBy String? @map("created_by")
owner User? @relation("riskOwner", fields: [ownerId], references: [id])
process Process? @relation(fields: [processId], references: [id])
riskAssets RiskAsset[]
@@unique([tenantId, refNo])
@@index([tenantId])
@@index([tenantId, status])
@@map("risks")
}
model RiskAsset {
id String @id @default(cuid())
tenantId String @map("tenant_id")
riskId String @map("risk_id")
assetId String @map("asset_id")
risk Risk @relation(fields: [riskId], references: [id], onDelete: Cascade)
asset Asset @relation(fields: [assetId], references: [id], onDelete: Cascade)
@@unique([riskId, assetId])
@@index([tenantId])
@@map("risk_assets")
}
model AuditLog { model AuditLog {
id String @id @default(cuid()) id String @id @default(cuid())
tenantId String @map("tenant_id") tenantId String @map("tenant_id")
+108
View File
@@ -138,6 +138,7 @@ async function main() {
tenantId: tenant.id, tenantId: tenant.id,
name: "Auftragsabwicklung", name: "Auftragsabwicklung",
description: "Vom Kundenauftrag bis zur Auslieferung.", description: "Vom Kundenauftrag bis zur Auslieferung.",
category: "CORE",
ownerId: owner.id, ownerId: owner.id,
}, },
}); });
@@ -169,6 +170,7 @@ async function main() {
tenantId: tenant.id, tenantId: tenant.id,
name: "Kundenbetreuung", name: "Kundenbetreuung",
description: "Anfragen, Angebote, Support.", description: "Anfragen, Angebote, Support.",
category: "SUPPORT",
ownerId: isb.id, ownerId: isb.id,
}, },
}); });
@@ -195,6 +197,112 @@ async function main() {
console.log("✔ Beispiel-Assets, -Prozesse und BIA angelegt"); console.log("✔ Beispiel-Assets, -Prozesse und BIA angelegt");
} }
// 6. Beispiel-Risiken (nur wenn noch keine existieren)
const riskCount = await prisma.risk.count({ where: { tenantId: tenant.id } });
if (riskCount === 0) {
const isb = await prisma.user.findUniqueOrThrow({
where: { tenantId_email: { tenantId: tenant.id, email: "admin@demo.example" } },
});
const byName = async (name: string) =>
prisma.asset.findFirstOrThrow({ where: { tenantId: tenant.id, name } });
const erp = await byName("ERP-System");
const crm = await byName("Kundendatenbank");
const hoster = await byName("Cloud-Hoster (IaaS)");
const auftrag = await prisma.process.findFirstOrThrow({
where: { tenantId: tenant.id, name: "Auftragsabwicklung" },
});
const risks: {
title: string;
threat: string;
vulnerability: string;
likelihood: number;
impact: number;
residual?: [number, number];
treatment: "AVOID" | "MITIGATE" | "TRANSFER" | "ACCEPT";
status: "OPEN" | "IN_TREATMENT" | "ACCEPTED" | "CLOSED";
assets: string[];
processId?: string;
description?: string;
}[] = [
{
title: "Ransomware auf ERP",
threat: "Schadsoftware / Verschlüsselungstrojaner",
vulnerability: "Fehlende Offline-Backups, Makro-Ausführung erlaubt",
likelihood: 4,
impact: 5,
residual: [2, 4],
treatment: "MITIGATE",
status: "IN_TREATMENT",
assets: [erp.id, crm.id],
processId: auftrag.id,
description: "Verschlüsselung der ERP-Datenbank würde die Auftragsabwicklung stoppen.",
},
{
title: "Ausfall Cloud-Hoster",
threat: "Ausfall des Rechenzentrums / Insolvenz Dienstleister",
vulnerability: "Kein Ausweich-Standort, Single Provider",
likelihood: 4,
impact: 4,
treatment: "TRANSFER",
status: "OPEN",
assets: [hoster.id, erp.id],
processId: auftrag.id,
},
{
title: "Phishing / Social Engineering",
threat: "Gezielte Phishing-Kampagnen",
vulnerability: "Fehlende Awareness-Schulungen",
likelihood: 3,
impact: 3,
residual: [2, 3],
treatment: "MITIGATE",
status: "IN_TREATMENT",
assets: [crm.id],
},
{
title: "Verlust mobiler Geräte",
threat: "Diebstahl/Verlust von Notebooks",
vulnerability: "Unvollständige Festplattenverschlüsselung",
likelihood: 2,
impact: 3,
treatment: "ACCEPT",
status: "ACCEPTED",
assets: [],
},
];
let refNo = 1;
for (const r of risks) {
const risk = await prisma.risk.create({
data: {
tenantId: tenant.id,
refNo: refNo++,
title: r.title,
description: r.description,
threat: r.threat,
vulnerability: r.vulnerability,
likelihood: r.likelihood,
impact: r.impact,
score: r.likelihood * r.impact,
residualLikelihood: r.residual?.[0],
residualImpact: r.residual?.[1],
residualScore: r.residual ? r.residual[0] * r.residual[1] : undefined,
treatment: r.treatment,
status: r.status,
ownerId: isb.id,
processId: r.processId,
},
});
for (const assetId of r.assets) {
await prisma.riskAsset.create({
data: { tenantId: tenant.id, riskId: risk.id, assetId },
});
}
}
console.log(`${risks.length} Beispiel-Risiken angelegt`);
}
} }
main() main()
+18 -9
View File
@@ -6,7 +6,6 @@ import { requireSession } from "@/server/auth";
import { dbForTenant } from "@/server/db"; import { dbForTenant } from "@/server/db";
import { hasPermission, requirePermission } from "@/server/rbac"; import { hasPermission, requirePermission } from "@/server/rbac";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { ModuleTabs } from "@/components/module-tabs";
import { FilterTabs } from "@/components/filter-tabs"; import { FilterTabs } from "@/components/filter-tabs";
import { import {
CiaBadge, CiaBadge,
@@ -89,9 +88,26 @@ export default async function AssetsPage({
where: { id: modalId }, where: { id: modalId },
include: { include: {
owner: { select: { name: true } }, 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 } } } }, relationsTo: { include: { asset: { select: { id: true, name: true } } } },
processAssets: { include: { process: { 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; : 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"> <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 })} /> <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() { export default async function DashboardPage() {
const session = await requireSession(); const session = await requireSession();
const t = await getTranslations("dashboard"); const t = await getTranslations("dashboard");
const tc = await getTranslations("common");
const format = await getFormatter(); const format = await getFormatter();
const db = dbForTenant(session.user.tenantId); 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.asset.count(),
db.process.count(), db.process.count(),
db.biaEntry.count({ where: { criticality: { gte: 3 } } }), db.biaEntry.count({ where: { criticality: { gte: 3 } } }),
db.risk.count({ where: { status: { in: ["OPEN", "IN_TREATMENT"] } } }),
db.auditLog.findMany({ db.auditLog.findMany({
orderBy: { createdAt: "desc" }, orderBy: { createdAt: "desc" },
take: 6, take: 6,
@@ -34,7 +34,7 @@ export default async function DashboardPage() {
{ label: t("kpiAssets"), value: String(assetCount), hint: null }, { label: t("kpiAssets"), value: String(assetCount), hint: null },
{ label: t("kpiProcesses"), value: String(processCount), hint: null }, { label: t("kpiProcesses"), value: String(processCount), hint: null },
{ label: t("kpiCritical"), value: String(criticalCount), hint: t("kpiCriticalHint") }, { 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 ( return (
+5 -3
View File
@@ -5,6 +5,7 @@ import { getTranslations } from "next-intl/server";
import { import {
LayoutDashboard, LayoutDashboard,
Boxes, Boxes,
GitBranch,
ShieldAlert, ShieldAlert,
ClipboardCheck, ClipboardCheck,
KanbanSquare, KanbanSquare,
@@ -32,8 +33,9 @@ export default async function AppLayout({
const nav = [ const nav = [
{ href: "/dashboard", label: t("dashboard"), icon: LayoutDashboard, enabled: true }, { href: "/dashboard", label: t("dashboard"), icon: LayoutDashboard, enabled: true },
{ href: "/assets", label: t("assetsBia"), icon: Boxes, enabled: true, match: ["/assets", "/processes"] }, { href: "/assets", label: t("assets"), icon: Boxes, enabled: true },
{ href: "/risks", label: t("risks"), icon: ShieldAlert, enabled: false }, { 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: "/soa", label: t("soa"), icon: ClipboardCheck, enabled: false },
{ href: "/measures", label: t("measures"), icon: KanbanSquare, enabled: false }, { href: "/measures", label: t("measures"), icon: KanbanSquare, enabled: false },
{ href: "/incidents", label: t("incidents"), icon: Siren, 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 className="flex-1 space-y-0.5 overflow-y-auto px-2.5 py-3">
{nav.map((item) => {nav.map((item) =>
item.enabled ? ( 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.icon className="size-[18px] opacity-85" />
{item.label} {item.label}
</NavLink> </NavLink>
+22 -11
View File
@@ -5,8 +5,7 @@ import { requireSession } from "@/server/auth";
import { dbForTenant } from "@/server/db"; import { dbForTenant } from "@/server/db";
import { hasPermission, requirePermission } from "@/server/rbac"; import { hasPermission, requirePermission } from "@/server/rbac";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { ModuleTabs } from "@/components/module-tabs"; import { CriticalityPill, PageHead, SectTitle, Tag } from "@/components/mockup-ui";
import { CriticalityPill, PageHead, SectTitle } from "@/components/mockup-ui";
import { import {
ProcessCreateModal, ProcessCreateModal,
ProcessDetailModal, ProcessDetailModal,
@@ -51,6 +50,7 @@ export default async function ProcessesPage({
const t = await getTranslations("processes"); const t = await getTranslations("processes");
const tAssets = await getTranslations("assets"); const tAssets = await getTranslations("assets");
const tCrit = await getTranslations("criticality"); const tCrit = await getTranslations("criticality");
const tCat = await getTranslations("processCategory");
const tc = await getTranslations("common"); const tc = await getTranslations("common");
const params = await searchParams; const params = await searchParams;
@@ -80,6 +80,20 @@ export default async function ProcessesPage({
orderBy: { name: "asc" }, 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 = const availableAssets =
canWrite && params.edit && modalProcess canWrite && params.edit && modalProcess
? await db.asset.findMany({ ? 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="shadow-card mt-4 rounded-xl border bg-card">
<div className="p-4 pb-0"> <div className="p-4 pb-0">
@@ -127,6 +134,7 @@ export default async function ProcessesPage({
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead>{t("process")}</TableHead> <TableHead>{t("process")}</TableHead>
<TableHead>{t("category")}</TableHead>
<TableHead>{t("owner")}</TableHead> <TableHead>{t("owner")}</TableHead>
<TableHead>RTO</TableHead> <TableHead>RTO</TableHead>
<TableHead>RPO</TableHead> <TableHead>RPO</TableHead>
@@ -137,7 +145,7 @@ export default async function ProcessesPage({
<TableBody> <TableBody>
{processes.length === 0 && ( {processes.length === 0 && (
<TableRow> <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")} {t("empty")}
</TableCell> </TableCell>
</TableRow> </TableRow>
@@ -149,6 +157,9 @@ export default async function ProcessesPage({
{process.name} {process.name}
</Link> </Link>
</TableCell> </TableCell>
<TableCell>
<Tag>{tCat(process.category)}</Tag>
</TableCell>
<TableCell className="text-muted-foreground"> <TableCell className="text-muted-foreground">
{process.owner?.name ?? tc("none")} {process.owner?.name ?? tc("none")}
</TableCell> </TableCell>
@@ -174,7 +185,7 @@ export default async function ProcessesPage({
{modalProcess && params.edit && canWrite ? ( {modalProcess && params.edit && canWrite ? (
<ProcessEditModal process={modalProcess} users={users} availableAssets={availableAssets} /> <ProcessEditModal process={modalProcess} users={users} availableAssets={availableAssets} />
) : modalProcess ? ( ) : modalProcess ? (
<ProcessDetailModal process={modalProcess} canWrite={canWrite} /> <ProcessDetailModal process={modalProcess} risks={modalRisks} canWrite={canWrite} />
) : params.new && canWrite ? ( ) : params.new && canWrite ? (
<ProcessCreateModal users={users} /> <ProcessCreateModal users={users} />
) : null} ) : 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>
);
}
+90 -29
View File
@@ -13,19 +13,36 @@ import { Button } from "@/components/ui/button";
import { Modal } from "@/components/modal"; import { Modal } from "@/components/modal";
import { AssetForm } from "@/components/asset-form"; import { AssetForm } from "@/components/asset-form";
import { CiaBadge, Pill, Tag } from "@/components/mockup-ui"; import { CiaBadge, Pill, Tag } from "@/components/mockup-ui";
import { riskLevel, riskRef, RISK_PILL_TONE } from "@/lib/risk";
export type AssetWithRelations = Prisma.AssetGetPayload<{ export type AssetWithRelations = Prisma.AssetGetPayload<{
include: { include: {
owner: { select: { name: true } }; 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 } } } }; relationsTo: { include: { asset: { select: { id: true; name: true } } } };
processAssets: { include: { process: { 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; 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({ export async function AssetDetailModal({
asset, asset,
canWrite, canWrite,
@@ -37,13 +54,14 @@ export async function AssetDetailModal({
const tType = await getTranslations("assetType"); const tType = await getTranslations("assetType");
const tStatus = await getTranslations("assetStatus"); const tStatus = await getTranslations("assetStatus");
const tRole = await getTranslations("processRole"); const tRole = await getTranslations("processRole");
const tLevel = await getTranslations("riskLevel");
const tc = await getTranslations("common"); const tc = await getTranslations("common");
const tp = await getTranslations("processes"); const tp = await getTranslations("processes");
return ( return (
<Modal <Modal
title={asset.name} title={asset.name}
sub={t("detailTitle")} sub={t("detailSub")}
headerExtra={ headerExtra={
<span className="flex items-center gap-2"> <span className="flex items-center gap-2">
<Tag>{tType(asset.type)}</Tag> <Tag>{tType(asset.type)}</Tag>
@@ -70,25 +88,34 @@ export async function AssetDetailModal({
} }
> >
<div className="grid gap-5 p-5 md:grid-cols-2"> <div className="grid gap-5 p-5 md:grid-cols-2">
{/* Stammdaten — violette Karte wie das primäre Asset im Prozess-Popup */}
<div> <div>
<dl className="grid grid-cols-[8.5rem_1fr] gap-2 text-sm"> <div className="mb-2.5 flex items-center gap-2.5">
<dt className="text-muted-foreground">{t("description")}</dt> <Pill tone="violet">{t("masterPill")}</Pill>
<dd className="whitespace-pre-wrap">{asset.description ?? tc("none")}</dd> <span className="text-[12.5px] text-muted-foreground">{t("masterNote")}</span>
<dt className="text-muted-foreground">{t("owner")}</dt> </div>
<dd>{asset.owner?.name ?? tc("none")}</dd> <div className="rounded-xl border border-l-[3px] border-l-[#5d52a3] bg-[#faf9fd] p-4">
<dt className="text-muted-foreground">{t("location")}</dt> <div className="flex items-center justify-between gap-2">
<dd>{asset.location ?? tc("none")}</dd> <b>{asset.name}</b>
<dt className="text-muted-foreground">{t("protection")}</dt>
<dd>
<CiaBadge c={asset.confidentiality} i={asset.integrity} a={asset.availability} /> <CiaBadge c={asset.confidentiality} i={asset.integrity} a={asset.availability} />
</dd> </div>
<dt className="text-muted-foreground">Tags</dt> <div className="mt-1.5 text-[12.5px] leading-relaxed text-muted-foreground">
<dd className="flex flex-wrap gap-1"> {t("owner")}: {asset.owner?.name ?? tc("none")} · {t("type")}: {tType(asset.type)}
{asset.tags.length === 0 ? tc("none") : asset.tags.map((tag) => <Tag key={tag}>{tag}</Tag>)} {asset.location ? ` · ${t("location")}: ${asset.location}` : ""}
</dd> </div>
</dl> {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> <p className="text-sm font-semibold">{t("processes")}</p>
{asset.processAssets.length === 0 && ( {asset.processAssets.length === 0 && (
<p className="mt-1 text-sm text-muted-foreground">{tc("none")}</p> <p className="mt-1 text-sm text-muted-foreground">{tc("none")}</p>
@@ -106,21 +133,39 @@ export async function AssetDetailModal({
</div> </div>
</div> </div>
{/* Abhängigkeiten — Tabelle wie die sekundären Assets im Prozess-Popup */}
<div> <div>
<p className="text-sm font-semibold">{t("relations")}</p> <div className="mb-2.5 flex items-center gap-2.5">
<p className="mt-2 text-[12.5px] text-muted-foreground">{t("relationHint")}</p> <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 && ( {asset.relationsFrom.length === 0 && (
<p className="text-sm text-muted-foreground">{tc("none")}</p> <p className="text-sm text-muted-foreground">{tc("none")}</p>
)} )}
<ul className="mt-1 space-y-1 text-sm"> <table className="w-full text-sm">
<tbody>
{asset.relationsFrom.map((rel) => ( {asset.relationsFrom.map((rel) => (
<li key={rel.id}> <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"> <Link href={`/assets?detail=${rel.relatedAsset.id}`} className="hover:underline">
{rel.relatedAsset.name} {rel.relatedAsset.name}
</Link> </Link>
</li> </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>
))} ))}
</ul> </tbody>
</table>
<p className="mt-3 text-[12.5px] text-muted-foreground">{t("relationReverseHint")}</p> <p className="mt-3 text-[12.5px] text-muted-foreground">{t("relationReverseHint")}</p>
{asset.relationsTo.length === 0 && ( {asset.relationsTo.length === 0 && (
<p className="text-sm text-muted-foreground">{tc("none")}</p> <p className="text-sm text-muted-foreground">{tc("none")}</p>
@@ -134,12 +179,28 @@ export async function AssetDetailModal({
</li> </li>
))} ))}
</ul> </ul>
</div>
</div>
<div className="mt-5 rounded-xl border border-[#e6e2f3] bg-[#f2f0f9] p-4 text-[12.5px] text-[#5a4e86]"> {/* 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> <b>{t("linkedRisks")}</b>
<p className="mt-1">{t("linkedRisksPlaceholder")}</p> {asset.riskAssets.length === 0 && <p className="mt-1">{t("linkedRisksPlaceholder")}</p>}
</div> <ul className="mt-2 space-y-1.5">
</div> {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> </div>
</Modal> </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; cancelHref: string;
}) { }) {
const t = await getTranslations("processes"); const t = await getTranslations("processes");
const tCat = await getTranslations("processCategory");
const tc = await getTranslations("common"); const tc = await getTranslations("common");
return ( return (
@@ -37,6 +38,21 @@ export async function ProcessForm({
className="mt-1" className="mt-1"
/> />
</div> </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> <div>
<Label htmlFor="ownerId">{t("owner")}</Label> <Label htmlFor="ownerId">{t("owner")}</Label>
<select <select
+55 -2
View File
@@ -17,6 +17,14 @@ import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { Modal } from "@/components/modal"; import { Modal } from "@/components/modal";
import { CiaBadge, CriticalityPill, Pill, Tag } from "@/components/mockup-ui"; 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<{ export type ProcessWithDetail = Prisma.ProcessGetPayload<{
include: { include: {
@@ -62,15 +70,19 @@ export async function ProcessCreateModal({
/** Read-only-Prozess-Detail als Popup (Mockup: "Prozess-Detail · …"). */ /** Read-only-Prozess-Detail als Popup (Mockup: "Prozess-Detail · …"). */
export async function ProcessDetailModal({ export async function ProcessDetailModal({
process, process,
risks,
canWrite, canWrite,
}: { }: {
process: ProcessWithDetail; process: ProcessWithDetail;
risks: LinkedRisk[];
canWrite: boolean; canWrite: boolean;
}) { }) {
const t = await getTranslations("processes"); const t = await getTranslations("processes");
const tAssets = await getTranslations("assets"); const tAssets = await getTranslations("assets");
const tType = await getTranslations("assetType"); const tType = await getTranslations("assetType");
const tCrit = await getTranslations("criticality"); const tCrit = await getTranslations("criticality");
const tCat = await getTranslations("processCategory");
const tLevel = await getTranslations("riskLevel");
const tc = await getTranslations("common"); const tc = await getTranslations("common");
const hours = (v: number | null | undefined) => (v != null ? `${v} h` : tc("none")); 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 })} title={t("detailHeading", { name: process.name })}
sub={t("detailSub")} sub={t("detailSub")}
headerExtra={ headerExtra={
process.bia && ( <span className="flex items-center gap-2">
<Tag>{tCat(process.category)}</Tag>
{process.bia && (
<CriticalityPill <CriticalityPill
level={process.bia.criticality} level={process.bia.criticality}
label={t("critLabel", { label: tCrit(String(process.bia.criticality)) })} label={t("critLabel", { label: tCrit(String(process.bia.criticality)) })}
/> />
) )}
</span>
} }
closeHref="/processes" closeHref="/processes"
closeLabel={t("close")} 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> <p className="mt-3 border-t border-[#e6e2f3] pt-2.5">{process.bia.notes}</p>
)} )}
</div> </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> </Modal>
); );
} }
@@ -211,6 +248,7 @@ export async function ProcessEditModal({
const tAssets = await getTranslations("assets"); const tAssets = await getTranslations("assets");
const tRole = await getTranslations("processRole"); const tRole = await getTranslations("processRole");
const tCrit = await getTranslations("criticality"); const tCrit = await getTranslations("criticality");
const tCat = await getTranslations("processCategory");
const tc = await getTranslations("common"); const tc = await getTranslations("common");
const bia = process.bia; const bia = process.bia;
@@ -258,6 +296,21 @@ export async function ProcessEditModal({
className="mt-1" className="mt-1"
/> />
</div> </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> <div>
<Label htmlFor="ownerId">{t("owner")}</Label> <Label htmlFor="ownerId">{t("owner")}</Label>
<select <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({ const processSchema = z.object({
name: z.string().trim().min(1).max(200), name: z.string().trim().min(1).max(200),
description: z.string().trim().max(5000).optional(), description: z.string().trim().max(5000).optional(),
category: z.enum(["CORE", "MANAGEMENT", "SUPPORT"]),
ownerId: z.string().optional(), ownerId: z.string().optional(),
}); });
@@ -18,6 +19,7 @@ function parseProcessForm(formData: FormData) {
const parsed = processSchema.parse({ const parsed = processSchema.parse({
name: formData.get("name"), name: formData.get("name"),
description: formData.get("description") || undefined, description: formData.get("description") || undefined,
category: formData.get("category"),
ownerId: formData.get("ownerId") || undefined, ownerId: formData.get("ownerId") || undefined,
}); });
return { 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", "Process",
"ProcessAsset", "ProcessAsset",
"BiaEntry", "BiaEntry",
"Risk",
"RiskAsset",
]); ]);
/** /**