Risiko-Minderung dezimal + editierbar, Bedrohungs-/Schwachstellen-Kataloge
- Minderung je Maßnahmen-Verknüpfung jetzt dezimal (0,00–4,00 statt ganzer Punkte): mehrere Maßnahmen summieren sich, bis eine Dimension einen vollen Punkt sinkt; Rest-Risiko entsprechend dezimal (z. B. 2,5 × 4 = 10), gerundet auf 2 Nachkommastellen, lokalisierte Anzeige - Minderung bereits verknüpfter Maßnahmen direkt im Risiko-Bearbeiten editierbar (Eingabefelder je Verknüpfung + Speichern-Button); Komma-Eingaben werden akzeptiert - Globale Kataloge Bedrohungen/Schwachstellen (je 15 Einträge, Seed): Eingabefelder schlagen per datalist Katalogwerte vor, Freitext bleibt weiterhin möglich (SPEC §5 Threat/Vulnerability) - Schema: reduction*/residual* auf Float, Threat-/Vulnerability-Modelle Verifiziert: −1 + −0,5 Minderung → Rest 2,5 × 4 = 10 · Erhöht (DB + Anzeige), Neuberechnung auch beim Entfernen einer Verknüpfung. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
+4
-3
@@ -252,10 +252,11 @@
|
|||||||
"linkMeasure": "Maßnahme verknüpfen",
|
"linkMeasure": "Maßnahme verknüpfen",
|
||||||
"newMeasure": "Neue Maßnahme anlegen & verknüpfen",
|
"newMeasure": "Neue Maßnahme anlegen & verknüpfen",
|
||||||
"measureTitle": "Titel der Maßnahme",
|
"measureTitle": "Titel der Maßnahme",
|
||||||
"reductionL": "Minderung Wahrscheinlichkeit (0–4)",
|
"reductionL": "Minderung Wahrscheinlichkeit (0,00–4,00)",
|
||||||
"reductionI": "Minderung Schaden (0–4)",
|
"reductionI": "Minderung Schaden (0,00–4,00)",
|
||||||
"reduction": "Minderung",
|
"reduction": "Minderung",
|
||||||
"createFromAsset": "Risiko erstellen"
|
"createFromAsset": "Risiko erstellen",
|
||||||
|
"catalogHint": "Aus Katalog wählen oder frei eingeben …"
|
||||||
},
|
},
|
||||||
"riskTreatment": {
|
"riskTreatment": {
|
||||||
"AVOID": "Vermeiden",
|
"AVOID": "Vermeiden",
|
||||||
|
|||||||
+4
-3
@@ -252,10 +252,11 @@
|
|||||||
"linkMeasure": "Link measure",
|
"linkMeasure": "Link measure",
|
||||||
"newMeasure": "Create & link new measure",
|
"newMeasure": "Create & link new measure",
|
||||||
"measureTitle": "Measure title",
|
"measureTitle": "Measure title",
|
||||||
"reductionL": "Reduction likelihood (0–4)",
|
"reductionL": "Reduction likelihood (0.00–4.00)",
|
||||||
"reductionI": "Reduction damage (0–4)",
|
"reductionI": "Reduction damage (0.00–4.00)",
|
||||||
"reduction": "Reduction",
|
"reduction": "Reduction",
|
||||||
"createFromAsset": "Create risk"
|
"createFromAsset": "Create risk",
|
||||||
|
"catalogHint": "Pick from catalog or type freely …"
|
||||||
},
|
},
|
||||||
"riskTreatment": {
|
"riskTreatment": {
|
||||||
"AVOID": "Avoid",
|
"AVOID": "Avoid",
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "risk_measures" ALTER COLUMN "reduction_likelihood" SET DEFAULT 0,
|
||||||
|
ALTER COLUMN "reduction_likelihood" SET DATA TYPE DOUBLE PRECISION,
|
||||||
|
ALTER COLUMN "reduction_impact" SET DEFAULT 0,
|
||||||
|
ALTER COLUMN "reduction_impact" SET DATA TYPE DOUBLE PRECISION;
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "risks" ALTER COLUMN "residual_likelihood" SET DATA TYPE DOUBLE PRECISION,
|
||||||
|
ALTER COLUMN "residual_impact" SET DATA TYPE DOUBLE PRECISION,
|
||||||
|
ALTER COLUMN "residual_score" SET DATA TYPE DOUBLE PRECISION;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "threats" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "threats_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "vulnerabilities" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "vulnerabilities_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "threats_name_key" ON "threats"("name");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "vulnerabilities_name_key" ON "vulnerabilities"("name");
|
||||||
+25
-7
@@ -279,10 +279,10 @@ model Risk {
|
|||||||
impact Int @default(1)
|
impact Int @default(1)
|
||||||
score Int @default(1) // redundant für Listen/Heatmap
|
score Int @default(1) // redundant für Listen/Heatmap
|
||||||
|
|
||||||
// Rest-Risiko nach Maßnahmen
|
// Rest-Risiko nach Maßnahmen (berechnet, dezimal wegen Teil-Minderungen)
|
||||||
residualLikelihood Int? @map("residual_likelihood")
|
residualLikelihood Float? @map("residual_likelihood")
|
||||||
residualImpact Int? @map("residual_impact")
|
residualImpact Float? @map("residual_impact")
|
||||||
residualScore Int? @map("residual_score")
|
residualScore Float? @map("residual_score")
|
||||||
|
|
||||||
treatment RiskTreatment @default(MITIGATE)
|
treatment RiskTreatment @default(MITIGATE)
|
||||||
status RiskStatus @default(OPEN)
|
status RiskStatus @default(OPEN)
|
||||||
@@ -366,9 +366,10 @@ model RiskMeasure {
|
|||||||
riskId String @map("risk_id")
|
riskId String @map("risk_id")
|
||||||
measureId String @map("measure_id")
|
measureId String @map("measure_id")
|
||||||
|
|
||||||
// Minderung in Stufen (0–4) je Dimension
|
// Minderung je Dimension (0,00–4,00) — mehrere Maßnahmen summieren sich,
|
||||||
reductionLikelihood Int @default(0) @map("reduction_likelihood")
|
// bis ein voller Punkt (oder mehr) erreicht ist
|
||||||
reductionImpact Int @default(0) @map("reduction_impact")
|
reductionLikelihood Float @default(0) @map("reduction_likelihood")
|
||||||
|
reductionImpact Float @default(0) @map("reduction_impact")
|
||||||
|
|
||||||
risk Risk @relation(fields: [riskId], references: [id], onDelete: Cascade)
|
risk Risk @relation(fields: [riskId], references: [id], onDelete: Cascade)
|
||||||
measure Measure @relation(fields: [measureId], references: [id], onDelete: Cascade)
|
measure Measure @relation(fields: [measureId], references: [id], onDelete: Cascade)
|
||||||
@@ -378,6 +379,23 @@ model RiskMeasure {
|
|||||||
@@map("risk_measures")
|
@@map("risk_measures")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Globale Kataloge (SPEC §5: Threat/Vulnerability, mandantenübergreifend).
|
||||||
|
// Risiken speichern weiterhin Freitext — der Katalog liefert Vorschläge (datalist),
|
||||||
|
// eigene Formulierungen bleiben möglich.
|
||||||
|
model Threat {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
name String @unique
|
||||||
|
|
||||||
|
@@map("threats")
|
||||||
|
}
|
||||||
|
|
||||||
|
model Vulnerability {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
name String @unique
|
||||||
|
|
||||||
|
@@map("vulnerabilities")
|
||||||
|
}
|
||||||
|
|
||||||
model AuditLog {
|
model AuditLog {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
tenantId String @map("tenant_id")
|
tenantId String @map("tenant_id")
|
||||||
|
|||||||
@@ -15,7 +15,52 @@ const prisma = new PrismaClient({
|
|||||||
|
|
||||||
const DEMO_PASSWORD = "Demo1234!";
|
const DEMO_PASSWORD = "Demo1234!";
|
||||||
|
|
||||||
|
const THREATS = [
|
||||||
|
"Schadsoftware / Ransomware",
|
||||||
|
"Phishing / Social Engineering",
|
||||||
|
"Innentäter / Missbrauch von Berechtigungen",
|
||||||
|
"Diebstahl oder Verlust von Geräten",
|
||||||
|
"Ausfall von IT-Systemen oder Diensten",
|
||||||
|
"Ausfall eines Dienstleisters / Lieferanten",
|
||||||
|
"Stromausfall / Infrastrukturausfall",
|
||||||
|
"Feuer / Wasser / Elementarschäden",
|
||||||
|
"Unbefugter physischer Zutritt",
|
||||||
|
"Denial-of-Service-Angriff",
|
||||||
|
"Datenabfluss / Industriespionage",
|
||||||
|
"Fehlbedienung durch Mitarbeitende",
|
||||||
|
"Softwarefehler / fehlerhafte Updates",
|
||||||
|
"Kompromittierte Zugangsdaten",
|
||||||
|
"Rechtliche / regulatorische Verstöße",
|
||||||
|
];
|
||||||
|
|
||||||
|
const VULNERABILITIES = [
|
||||||
|
"Fehlende oder veraltete Backups",
|
||||||
|
"Fehlende Netzwerksegmentierung",
|
||||||
|
"Unzureichendes Patch-Management",
|
||||||
|
"Schwache oder wiederverwendete Passwörter",
|
||||||
|
"Fehlende Multi-Faktor-Authentifizierung",
|
||||||
|
"Übermäßige Berechtigungen / fehlendes Least-Privilege",
|
||||||
|
"Fehlende Awareness / Schulungen",
|
||||||
|
"Unverschlüsselte Datenträger oder Übertragungen",
|
||||||
|
"Kein Notfallkonzept / ungetesteter Wiederanlauf",
|
||||||
|
"Single Point of Failure (Technik oder Person)",
|
||||||
|
"Unzureichende Protokollierung / Überwachung",
|
||||||
|
"Veraltete oder nicht mehr unterstützte Software",
|
||||||
|
"Fehlende Vertrags-/AV-Regelungen mit Dienstleistern",
|
||||||
|
"Offene, ungenutzte Dienste und Ports",
|
||||||
|
"Unklare Verantwortlichkeiten",
|
||||||
|
];
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
|
// 0. Globale Kataloge Bedrohungen/Schwachstellen (Vorschläge, Freitext bleibt möglich)
|
||||||
|
for (const name of THREATS) {
|
||||||
|
await prisma.threat.upsert({ where: { name }, update: {}, create: { name } });
|
||||||
|
}
|
||||||
|
for (const name of VULNERABILITIES) {
|
||||||
|
await prisma.vulnerability.upsert({ where: { name }, update: {}, create: { name } });
|
||||||
|
}
|
||||||
|
console.log(`✔ Kataloge: ${THREATS.length} Bedrohungen, ${VULNERABILITIES.length} Schwachstellen`);
|
||||||
|
|
||||||
// 1. Global permission catalog
|
// 1. Global permission catalog
|
||||||
for (const key of PERMISSIONS) {
|
for (const key of PERMISSIONS) {
|
||||||
await prisma.permission.upsert({ where: { key }, update: {}, create: { key } });
|
await prisma.permission.upsert({ where: { key }, update: {}, create: { key } });
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import Link from "next/link";
|
|||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
import { Plus } from "lucide-react";
|
import { Plus } from "lucide-react";
|
||||||
import { requireSession } from "@/server/auth";
|
import { requireSession } from "@/server/auth";
|
||||||
import { dbForTenant } from "@/server/db";
|
import { dbForTenant, prisma } 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 { PageHead, Pill, SectTitle, Tag } from "@/components/mockup-ui";
|
import { PageHead, Pill, SectTitle, Tag } from "@/components/mockup-ui";
|
||||||
@@ -82,7 +82,7 @@ export default async function RisksPage({
|
|||||||
: null;
|
: null;
|
||||||
|
|
||||||
const needsFormData = canWrite && (params.edit || params.new);
|
const needsFormData = canWrite && (params.edit || params.new);
|
||||||
const [users, processes] = needsFormData
|
const [users, processes, threats, vulnerabilities] = needsFormData
|
||||||
? await Promise.all([
|
? await Promise.all([
|
||||||
db.user.findMany({
|
db.user.findMany({
|
||||||
where: { status: "ACTIVE" },
|
where: { status: "ACTIVE" },
|
||||||
@@ -90,8 +90,13 @@ export default async function RisksPage({
|
|||||||
orderBy: { name: "asc" },
|
orderBy: { name: "asc" },
|
||||||
}),
|
}),
|
||||||
db.process.findMany({ select: { id: true, name: true }, orderBy: { name: "asc" } }),
|
db.process.findMany({ select: { id: true, name: true }, orderBy: { name: "asc" } }),
|
||||||
|
// Globale Kataloge (kein Mandanten-Bezug)
|
||||||
|
prisma.threat.findMany({ orderBy: { name: "asc" } }).then((r) => r.map((x) => x.name)),
|
||||||
|
prisma.vulnerability
|
||||||
|
.findMany({ orderBy: { name: "asc" } })
|
||||||
|
.then((r) => r.map((x) => x.name)),
|
||||||
])
|
])
|
||||||
: [[], []];
|
: [[], [], [], []];
|
||||||
const availableAssets =
|
const availableAssets =
|
||||||
canWrite && params.edit && modalRisk
|
canWrite && params.edit && modalRisk
|
||||||
? await db.asset.findMany({
|
? await db.asset.findMany({
|
||||||
@@ -263,11 +268,19 @@ export default async function RisksPage({
|
|||||||
processes={processes}
|
processes={processes}
|
||||||
availableAssets={availableAssets}
|
availableAssets={availableAssets}
|
||||||
availableMeasures={availableMeasures}
|
availableMeasures={availableMeasures}
|
||||||
|
threats={threats}
|
||||||
|
vulnerabilities={vulnerabilities}
|
||||||
/>
|
/>
|
||||||
) : modalRisk ? (
|
) : modalRisk ? (
|
||||||
<RiskDetailModal risk={modalRisk} canWrite={canWrite} />
|
<RiskDetailModal risk={modalRisk} canWrite={canWrite} />
|
||||||
) : params.new && canWrite ? (
|
) : params.new && canWrite ? (
|
||||||
<RiskCreateModal users={users} processes={processes} preselectedAsset={preselectedAsset} />
|
<RiskCreateModal
|
||||||
|
users={users}
|
||||||
|
processes={processes}
|
||||||
|
preselectedAsset={preselectedAsset}
|
||||||
|
threats={threats}
|
||||||
|
vulnerabilities={vulnerabilities}
|
||||||
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -108,7 +108,8 @@ export async function MeasureDetailModal({
|
|||||||
{rm.risk.score} · {tLevel(riskLevel(rm.risk.score))}
|
{rm.risk.score} · {tLevel(riskLevel(rm.risk.score))}
|
||||||
</Pill>
|
</Pill>
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">
|
||||||
{tr("reduction")}: −{rm.reductionLikelihood} / −{rm.reductionImpact}
|
{tr("reduction")}: −{format.number(rm.reductionLikelihood, { maximumFractionDigits: 2 })} /
|
||||||
|
−{format.number(rm.reductionImpact, { maximumFractionDigits: 2 })}
|
||||||
</span>
|
</span>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
|
|||||||
+127
-45
@@ -1,6 +1,6 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { getTranslations } from "next-intl/server";
|
import { getFormatter, getTranslations } from "next-intl/server";
|
||||||
import { Pencil, Trash2, X } from "lucide-react";
|
import { Check, Pencil, Trash2, X } from "lucide-react";
|
||||||
import type { Prisma } from "@prisma/client";
|
import type { Prisma } from "@prisma/client";
|
||||||
import {
|
import {
|
||||||
addRiskAsset,
|
addRiskAsset,
|
||||||
@@ -50,7 +50,6 @@ export type RiskWithDetail = Prisma.RiskGetPayload<{
|
|||||||
}>;
|
}>;
|
||||||
|
|
||||||
const SCALE = [1, 2, 3, 4, 5] as const;
|
const SCALE = [1, 2, 3, 4, 5] as const;
|
||||||
const REDUCTIONS = [0, 1, 2, 3, 4] as const;
|
|
||||||
const TREATMENTS = ["AVOID", "MITIGATE", "TRANSFER", "ACCEPT"] as const;
|
const TREATMENTS = ["AVOID", "MITIGATE", "TRANSFER", "ACCEPT"] as const;
|
||||||
const STATUSES = ["OPEN", "IN_TREATMENT", "ACCEPTED", "CLOSED"] as const;
|
const STATUSES = ["OPEN", "IN_TREATMENT", "ACCEPTED", "CLOSED"] as const;
|
||||||
|
|
||||||
@@ -63,10 +62,11 @@ const STATUS_TONE = {
|
|||||||
|
|
||||||
async function ScorePill({ score }: { score: number }) {
|
async function ScorePill({ score }: { score: number }) {
|
||||||
const tLevel = await getTranslations("riskLevel");
|
const tLevel = await getTranslations("riskLevel");
|
||||||
|
const format = await getFormatter();
|
||||||
const level = riskLevel(score);
|
const level = riskLevel(score);
|
||||||
return (
|
return (
|
||||||
<Pill tone={RISK_PILL_TONE[level]}>
|
<Pill tone={RISK_PILL_TONE[level]}>
|
||||||
{score} · {tLevel(level)}
|
{format.number(score, { maximumFractionDigits: 2 })} · {tLevel(level)}
|
||||||
</Pill>
|
</Pill>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -86,6 +86,8 @@ async function RatingBlock({
|
|||||||
hint?: string;
|
hint?: string;
|
||||||
}) {
|
}) {
|
||||||
const t = await getTranslations("risks");
|
const t = await getTranslations("risks");
|
||||||
|
const format = await getFormatter();
|
||||||
|
const num = (v: number) => format.number(v, { maximumFractionDigits: 2 });
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<b>{title}</b>
|
<b>{title}</b>
|
||||||
@@ -95,14 +97,14 @@ async function RatingBlock({
|
|||||||
<div className="text-[10.5px] leading-tight text-muted-foreground uppercase tracking-wide">
|
<div className="text-[10.5px] leading-tight text-muted-foreground uppercase tracking-wide">
|
||||||
{t("likelihoodShort")}
|
{t("likelihoodShort")}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-0.5 font-heading text-xl font-bold leading-none">{likelihood}</div>
|
<div className="mt-0.5 font-heading text-xl font-bold leading-none">{num(likelihood)}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="pb-0.5 text-muted-foreground">×</div>
|
<div className="pb-0.5 text-muted-foreground">×</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="text-[10.5px] leading-tight text-muted-foreground uppercase tracking-wide">
|
<div className="text-[10.5px] leading-tight text-muted-foreground uppercase tracking-wide">
|
||||||
{t("damageShort")}
|
{t("damageShort")}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-0.5 font-heading text-xl font-bold leading-none">{impact}</div>
|
<div className="mt-0.5 font-heading text-xl font-bold leading-none">{num(impact)}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="pb-0.5 text-muted-foreground">=</div>
|
<div className="pb-0.5 text-muted-foreground">=</div>
|
||||||
<div className="pb-0.5">
|
<div className="pb-0.5">
|
||||||
@@ -265,10 +267,7 @@ export async function RiskDetailModal({
|
|||||||
{tMStatus(rm.measure.status)}
|
{tMStatus(rm.measure.status)}
|
||||||
</Pill>
|
</Pill>
|
||||||
{(rm.reductionLikelihood > 0 || rm.reductionImpact > 0) && (
|
{(rm.reductionLikelihood > 0 || rm.reductionImpact > 0) && (
|
||||||
<span className="text-xs text-muted-foreground">
|
<ReductionLabel l={rm.reductionLikelihood} i={rm.reductionImpact} />
|
||||||
{t("reduction")}: −{rm.reductionLikelihood} {t("likelihoodShort")} / −
|
|
||||||
{rm.reductionImpact} {t("damageShort")}
|
|
||||||
</span>
|
|
||||||
)}
|
)}
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
@@ -285,12 +284,16 @@ export async function RiskEditModal({
|
|||||||
processes,
|
processes,
|
||||||
availableAssets,
|
availableAssets,
|
||||||
availableMeasures,
|
availableMeasures,
|
||||||
|
threats,
|
||||||
|
vulnerabilities,
|
||||||
}: {
|
}: {
|
||||||
risk: RiskWithDetail;
|
risk: RiskWithDetail;
|
||||||
users: { id: string; name: string }[];
|
users: { id: string; name: string }[];
|
||||||
processes: { id: string; name: string }[];
|
processes: { id: string; name: string }[];
|
||||||
availableAssets: { id: string; name: string }[];
|
availableAssets: { id: string; name: string }[];
|
||||||
availableMeasures: { id: string; refNo: number; title: string }[];
|
availableMeasures: { id: string; refNo: number; title: string }[];
|
||||||
|
threats: string[];
|
||||||
|
vulnerabilities: string[];
|
||||||
}) {
|
}) {
|
||||||
const t = await getTranslations("risks");
|
const t = await getTranslations("risks");
|
||||||
const tc = await getTranslations("common");
|
const tc = await getTranslations("common");
|
||||||
@@ -310,7 +313,14 @@ export async function RiskEditModal({
|
|||||||
action={updateRisk.bind(null, risk.id)}
|
action={updateRisk.bind(null, risk.id)}
|
||||||
className="space-y-4"
|
className="space-y-4"
|
||||||
>
|
>
|
||||||
<RiskFields risk={risk} users={users} processes={processes} selectClass={selectClass} />
|
<RiskFields
|
||||||
|
risk={risk}
|
||||||
|
users={users}
|
||||||
|
processes={processes}
|
||||||
|
threats={threats}
|
||||||
|
vulnerabilities={vulnerabilities}
|
||||||
|
selectClass={selectClass}
|
||||||
|
/>
|
||||||
<Button type="submit">{tc("save")}</Button>
|
<Button type="submit">{tc("save")}</Button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
@@ -357,21 +367,38 @@ export async function RiskEditModal({
|
|||||||
<p className="font-medium">{t("measures")}</p>
|
<p className="font-medium">{t("measures")}</p>
|
||||||
<p className="mb-1 text-xs text-muted-foreground">{t("residualAuto")}</p>
|
<p className="mb-1 text-xs text-muted-foreground">{t("residualAuto")}</p>
|
||||||
{risk.riskMeasures.length === 0 && <p>{tc("none")}</p>}
|
{risk.riskMeasures.length === 0 && <p>{tc("none")}</p>}
|
||||||
<ul className="space-y-1.5">
|
<ul className="space-y-2">
|
||||||
{risk.riskMeasures.map((rm) => (
|
{risk.riskMeasures.map((rm) => (
|
||||||
<li key={rm.id} className="flex flex-wrap items-center gap-2">
|
<li key={rm.id} className="rounded-lg border p-2.5">
|
||||||
<b>{measureRef(rm.measure.refNo)}</b> {rm.measure.title}
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-xs text-muted-foreground">
|
<b>{measureRef(rm.measure.refNo)}</b>
|
||||||
(−{rm.reductionLikelihood} / −{rm.reductionImpact})
|
<span className="min-w-0 flex-1 truncate">{rm.measure.title}</span>
|
||||||
</span>
|
<form action={unlinkMeasureFromRisk.bind(null, risk.id, rm.id)}>
|
||||||
<form action={unlinkMeasureFromRisk.bind(null, risk.id, rm.id)}>
|
<button
|
||||||
<button
|
type="submit"
|
||||||
type="submit"
|
title={tc("remove")}
|
||||||
title={tc("remove")}
|
className="text-muted-foreground hover:text-destructive"
|
||||||
className="text-muted-foreground hover:text-destructive"
|
>
|
||||||
>
|
<X className="size-3.5" />
|
||||||
<X className="size-3.5" />
|
</button>
|
||||||
</button>
|
</form>
|
||||||
|
</div>
|
||||||
|
{/* Minderung nachträglich anpassen (upsert über linkMeasureToRisk) */}
|
||||||
|
<form
|
||||||
|
key={rm.reductionLikelihood + "-" + rm.reductionImpact}
|
||||||
|
action={linkMeasureToRisk.bind(null, risk.id)}
|
||||||
|
className="mt-2 flex items-end gap-2"
|
||||||
|
>
|
||||||
|
<input type="hidden" name="measureId" value={rm.measureId} />
|
||||||
|
<div className="flex-1">
|
||||||
|
<ReductionInputs
|
||||||
|
defaultL={rm.reductionLikelihood}
|
||||||
|
defaultI={rm.reductionImpact}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button type="submit" variant="secondary" size="icon-sm" title={tc("save")}>
|
||||||
|
<Check className="size-3.5" />
|
||||||
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
@@ -390,7 +417,7 @@ export async function RiskEditModal({
|
|||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
<ReductionSelects selectClass={selectClass} />
|
<ReductionInputs />
|
||||||
<Button type="submit" variant="secondary" size="sm">
|
<Button type="submit" variant="secondary" size="sm">
|
||||||
{t("linkMeasure")}
|
{t("linkMeasure")}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -403,7 +430,7 @@ export async function RiskEditModal({
|
|||||||
>
|
>
|
||||||
<p className="text-xs font-semibold">{t("newMeasure")}</p>
|
<p className="text-xs font-semibold">{t("newMeasure")}</p>
|
||||||
<Input name="title" required placeholder={t("measureTitle")} />
|
<Input name="title" required placeholder={t("measureTitle")} />
|
||||||
<ReductionSelects selectClass={selectClass} />
|
<ReductionInputs />
|
||||||
<Button type="submit" variant="secondary" size="sm">
|
<Button type="submit" variant="secondary" size="sm">
|
||||||
{tc("add")}
|
{tc("add")}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -423,30 +450,52 @@ export async function RiskEditModal({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Minderungs-Auswahl (Wahrscheinlichkeit/Schaden) für Maßnahmen-Verknüpfungen. */
|
/** Anzeige einer Minderung mit lokalisierten Dezimalzahlen. */
|
||||||
async function ReductionSelects({ selectClass }: { selectClass: string }) {
|
async function ReductionLabel({ l, i }: { l: number; i: number }) {
|
||||||
|
const t = await getTranslations("risks");
|
||||||
|
const format = await getFormatter();
|
||||||
|
const num = (v: number) => format.number(v, { maximumFractionDigits: 2 });
|
||||||
|
return (
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{t("reduction")}: −{num(l)} {t("likelihoodShort")} / −{num(i)} {t("damageShort")}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Minderungs-Eingaben (dezimal, 0,00–4,00) für Maßnahmen-Verknüpfungen. */
|
||||||
|
async function ReductionInputs({
|
||||||
|
defaultL = 0,
|
||||||
|
defaultI = 0,
|
||||||
|
}: {
|
||||||
|
defaultL?: number;
|
||||||
|
defaultI?: number;
|
||||||
|
}) {
|
||||||
const t = await getTranslations("risks");
|
const t = await getTranslations("risks");
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-2 gap-2">
|
<div className="grid grid-cols-2 gap-2">
|
||||||
<div>
|
<div>
|
||||||
<label className="text-[11px] text-muted-foreground">{t("reductionL")}</label>
|
<label className="text-[11px] text-muted-foreground">{t("reductionL")}</label>
|
||||||
<select name="reductionLikelihood" defaultValue={0} className={`${selectClass} w-full`}>
|
<Input
|
||||||
{REDUCTIONS.map((v) => (
|
name="reductionLikelihood"
|
||||||
<option key={v} value={v}>
|
type="number"
|
||||||
−{v}
|
min={0}
|
||||||
</option>
|
max={4}
|
||||||
))}
|
step={0.05}
|
||||||
</select>
|
defaultValue={defaultL}
|
||||||
|
className="h-9"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="text-[11px] text-muted-foreground">{t("reductionI")}</label>
|
<label className="text-[11px] text-muted-foreground">{t("reductionI")}</label>
|
||||||
<select name="reductionImpact" defaultValue={0} className={`${selectClass} w-full`}>
|
<Input
|
||||||
{REDUCTIONS.map((v) => (
|
name="reductionImpact"
|
||||||
<option key={v} value={v}>
|
type="number"
|
||||||
−{v}
|
min={0}
|
||||||
</option>
|
max={4}
|
||||||
))}
|
step={0.05}
|
||||||
</select>
|
defaultValue={defaultI}
|
||||||
|
className="h-9"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -457,11 +506,15 @@ async function RiskFields({
|
|||||||
risk,
|
risk,
|
||||||
users,
|
users,
|
||||||
processes,
|
processes,
|
||||||
|
threats,
|
||||||
|
vulnerabilities,
|
||||||
selectClass,
|
selectClass,
|
||||||
}: {
|
}: {
|
||||||
risk?: RiskWithDetail;
|
risk?: RiskWithDetail;
|
||||||
users: { id: string; name: string }[];
|
users: { id: string; name: string }[];
|
||||||
processes: { id: string; name: string }[];
|
processes: { id: string; name: string }[];
|
||||||
|
threats: string[];
|
||||||
|
vulnerabilities: string[];
|
||||||
selectClass: string;
|
selectClass: string;
|
||||||
}) {
|
}) {
|
||||||
const t = await getTranslations("risks");
|
const t = await getTranslations("risks");
|
||||||
@@ -488,16 +541,35 @@ async function RiskFields({
|
|||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="threat">{t("threat")}</Label>
|
<Label htmlFor="threat">{t("threat")}</Label>
|
||||||
<Input id="threat" name="threat" defaultValue={risk?.threat ?? ""} className="mt-1" />
|
<Input
|
||||||
|
id="threat"
|
||||||
|
name="threat"
|
||||||
|
list="threat-catalog"
|
||||||
|
placeholder={t("catalogHint")}
|
||||||
|
defaultValue={risk?.threat ?? ""}
|
||||||
|
className="mt-1"
|
||||||
|
/>
|
||||||
|
<datalist id="threat-catalog">
|
||||||
|
{threats.map((name) => (
|
||||||
|
<option key={name} value={name} />
|
||||||
|
))}
|
||||||
|
</datalist>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="vulnerability">{t("vulnerability")}</Label>
|
<Label htmlFor="vulnerability">{t("vulnerability")}</Label>
|
||||||
<Input
|
<Input
|
||||||
id="vulnerability"
|
id="vulnerability"
|
||||||
name="vulnerability"
|
name="vulnerability"
|
||||||
|
list="vulnerability-catalog"
|
||||||
|
placeholder={t("catalogHint")}
|
||||||
defaultValue={risk?.vulnerability ?? ""}
|
defaultValue={risk?.vulnerability ?? ""}
|
||||||
className="mt-1"
|
className="mt-1"
|
||||||
/>
|
/>
|
||||||
|
<datalist id="vulnerability-catalog">
|
||||||
|
{vulnerabilities.map((name) => (
|
||||||
|
<option key={name} value={name} />
|
||||||
|
))}
|
||||||
|
</datalist>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="likelihood">{t("likelihood")}</Label>
|
<Label htmlFor="likelihood">{t("likelihood")}</Label>
|
||||||
@@ -601,10 +673,14 @@ export async function RiskCreateModal({
|
|||||||
users,
|
users,
|
||||||
processes,
|
processes,
|
||||||
preselectedAsset,
|
preselectedAsset,
|
||||||
|
threats,
|
||||||
|
vulnerabilities,
|
||||||
}: {
|
}: {
|
||||||
users: { id: string; name: string }[];
|
users: { id: string; name: string }[];
|
||||||
processes: { id: string; name: string }[];
|
processes: { id: string; name: string }[];
|
||||||
preselectedAsset?: { id: string; name: string } | null;
|
preselectedAsset?: { id: string; name: string } | null;
|
||||||
|
threats: string[];
|
||||||
|
vulnerabilities: string[];
|
||||||
}) {
|
}) {
|
||||||
const t = await getTranslations("risks");
|
const t = await getTranslations("risks");
|
||||||
const tc = await getTranslations("common");
|
const tc = await getTranslations("common");
|
||||||
@@ -619,7 +695,13 @@ export async function RiskCreateModal({
|
|||||||
>
|
>
|
||||||
<form action={createRisk} className="space-y-4 p-5">
|
<form action={createRisk} className="space-y-4 p-5">
|
||||||
{preselectedAsset && <input type="hidden" name="assetId" value={preselectedAsset.id} />}
|
{preselectedAsset && <input type="hidden" name="assetId" value={preselectedAsset.id} />}
|
||||||
<RiskFields users={users} processes={processes} selectClass={selectClass} />
|
<RiskFields
|
||||||
|
users={users}
|
||||||
|
processes={processes}
|
||||||
|
threats={threats}
|
||||||
|
vulnerabilities={vulnerabilities}
|
||||||
|
selectClass={selectClass}
|
||||||
|
/>
|
||||||
<div className="flex gap-2 pt-1">
|
<div className="flex gap-2 pt-1">
|
||||||
<Button type="submit">{tc("save")}</Button>
|
<Button type="submit">{tc("save")}</Button>
|
||||||
<Button variant="outline" nativeButton={false} render={<Link href="/risks" />}>
|
<Button variant="outline" nativeButton={false} render={<Link href="/risks" />}>
|
||||||
|
|||||||
@@ -18,7 +18,13 @@ const measureSchema = z.object({
|
|||||||
dueDate: z.string().optional(),
|
dueDate: z.string().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const reduction = z.coerce.number().int().min(0).max(4);
|
// Minderung dezimal (0,00–4,00) — Komma-Eingaben werden akzeptiert
|
||||||
|
const reduction = z
|
||||||
|
.preprocess(
|
||||||
|
(v) => (typeof v === "string" ? v.replace(",", ".") : v),
|
||||||
|
z.coerce.number().min(0).max(4)
|
||||||
|
)
|
||||||
|
.transform((v) => Math.round(v * 100) / 100);
|
||||||
|
|
||||||
function parseMeasureForm(formData: FormData) {
|
function parseMeasureForm(formData: FormData) {
|
||||||
const parsed = measureSchema.parse({
|
const parsed = measureSchema.parse({
|
||||||
|
|||||||
+11
-6
@@ -3,10 +3,15 @@ import type { TenantDb } from "./db";
|
|||||||
/**
|
/**
|
||||||
* Rest-Risiko-Berechnung (SPEC §4.2): Das Rest-Risiko wird NICHT manuell
|
* Rest-Risiko-Berechnung (SPEC §4.2): Das Rest-Risiko wird NICHT manuell
|
||||||
* gepflegt, sondern ergibt sich aus den verknüpften Maßnahmen. Jede
|
* gepflegt, sondern ergibt sich aus den verknüpften Maßnahmen. Jede
|
||||||
* Verknüpfung trägt eine erwartete Minderung (Stufen 0–4) je Dimension;
|
* Verknüpfung trägt eine erwartete Minderung (0,00–4,00) je Dimension —
|
||||||
* die Minderungen werden summiert und von der Brutto-Bewertung abgezogen
|
* bewusst dezimal: oft senkt erst die Summe mehrerer Maßnahmen eine
|
||||||
* (Untergrenze 1). Ohne Maßnahmen gibt es kein Rest-Risiko (null).
|
* Dimension um einen vollen Punkt. Die Minderungen werden summiert und
|
||||||
|
* von der Brutto-Bewertung abgezogen (Untergrenze 1). Ohne Maßnahmen
|
||||||
|
* gibt es kein Rest-Risiko (null).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
const round2 = (v: number) => Math.round(v * 100) / 100;
|
||||||
|
|
||||||
export async function recomputeResidualRisk(db: TenantDb, riskId: string) {
|
export async function recomputeResidualRisk(db: TenantDb, riskId: string) {
|
||||||
const risk = await db.risk.findUnique({
|
const risk = await db.risk.findUnique({
|
||||||
where: { id: riskId },
|
where: { id: riskId },
|
||||||
@@ -24,15 +29,15 @@ export async function recomputeResidualRisk(db: TenantDb, riskId: string) {
|
|||||||
|
|
||||||
const reductionL = risk.riskMeasures.reduce((s, m) => s + m.reductionLikelihood, 0);
|
const reductionL = risk.riskMeasures.reduce((s, m) => s + m.reductionLikelihood, 0);
|
||||||
const reductionI = risk.riskMeasures.reduce((s, m) => s + m.reductionImpact, 0);
|
const reductionI = risk.riskMeasures.reduce((s, m) => s + m.reductionImpact, 0);
|
||||||
const residualLikelihood = Math.max(1, risk.likelihood - reductionL);
|
const residualLikelihood = round2(Math.max(1, risk.likelihood - reductionL));
|
||||||
const residualImpact = Math.max(1, risk.impact - reductionI);
|
const residualImpact = round2(Math.max(1, risk.impact - reductionI));
|
||||||
|
|
||||||
await db.risk.update({
|
await db.risk.update({
|
||||||
where: { id: riskId },
|
where: { id: riskId },
|
||||||
data: {
|
data: {
|
||||||
residualLikelihood,
|
residualLikelihood,
|
||||||
residualImpact,
|
residualImpact,
|
||||||
residualScore: residualLikelihood * residualImpact,
|
residualScore: round2(residualLikelihood * residualImpact),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user