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:
+83
-3
@@ -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 (1–5)",
|
||||||
|
"impact": "Auswirkung (1–5)",
|
||||||
|
"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
@@ -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 (1–5)",
|
||||||
|
"impact": "Impact (1–5)",
|
||||||
|
"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));
|
||||||
@@ -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 1–5)
|
||||||
|
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
@@ -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()
|
||||||
|
|||||||
@@ -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 })} />
|
||||||
|
|||||||
@@ -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,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>
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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")}`;
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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");
|
||||||
|
}
|
||||||
@@ -33,6 +33,8 @@ const TENANT_MODELS = new Set<string>([
|
|||||||
"Process",
|
"Process",
|
||||||
"ProcessAsset",
|
"ProcessAsset",
|
||||||
"BiaEntry",
|
"BiaEntry",
|
||||||
|
"Risk",
|
||||||
|
"RiskAsset",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user