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:
+127
-45
@@ -1,6 +1,6 @@
|
||||
import Link from "next/link";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { Pencil, Trash2, X } from "lucide-react";
|
||||
import { getFormatter, getTranslations } from "next-intl/server";
|
||||
import { Check, Pencil, Trash2, X } from "lucide-react";
|
||||
import type { Prisma } from "@prisma/client";
|
||||
import {
|
||||
addRiskAsset,
|
||||
@@ -50,7 +50,6 @@ export type RiskWithDetail = Prisma.RiskGetPayload<{
|
||||
}>;
|
||||
|
||||
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 STATUSES = ["OPEN", "IN_TREATMENT", "ACCEPTED", "CLOSED"] as const;
|
||||
|
||||
@@ -63,10 +62,11 @@ const STATUS_TONE = {
|
||||
|
||||
async function ScorePill({ score }: { score: number }) {
|
||||
const tLevel = await getTranslations("riskLevel");
|
||||
const format = await getFormatter();
|
||||
const level = riskLevel(score);
|
||||
return (
|
||||
<Pill tone={RISK_PILL_TONE[level]}>
|
||||
{score} · {tLevel(level)}
|
||||
{format.number(score, { maximumFractionDigits: 2 })} · {tLevel(level)}
|
||||
</Pill>
|
||||
);
|
||||
}
|
||||
@@ -86,6 +86,8 @@ async function RatingBlock({
|
||||
hint?: string;
|
||||
}) {
|
||||
const t = await getTranslations("risks");
|
||||
const format = await getFormatter();
|
||||
const num = (v: number) => format.number(v, { maximumFractionDigits: 2 });
|
||||
return (
|
||||
<div>
|
||||
<b>{title}</b>
|
||||
@@ -95,14 +97,14 @@ async function RatingBlock({
|
||||
<div className="text-[10.5px] leading-tight text-muted-foreground uppercase tracking-wide">
|
||||
{t("likelihoodShort")}
|
||||
</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 className="pb-0.5 text-muted-foreground">×</div>
|
||||
<div>
|
||||
<div className="text-[10.5px] leading-tight text-muted-foreground uppercase tracking-wide">
|
||||
{t("damageShort")}
|
||||
</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 className="pb-0.5 text-muted-foreground">=</div>
|
||||
<div className="pb-0.5">
|
||||
@@ -265,10 +267,7 @@ export async function RiskDetailModal({
|
||||
{tMStatus(rm.measure.status)}
|
||||
</Pill>
|
||||
{(rm.reductionLikelihood > 0 || rm.reductionImpact > 0) && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t("reduction")}: −{rm.reductionLikelihood} {t("likelihoodShort")} / −
|
||||
{rm.reductionImpact} {t("damageShort")}
|
||||
</span>
|
||||
<ReductionLabel l={rm.reductionLikelihood} i={rm.reductionImpact} />
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
@@ -285,12 +284,16 @@ export async function RiskEditModal({
|
||||
processes,
|
||||
availableAssets,
|
||||
availableMeasures,
|
||||
threats,
|
||||
vulnerabilities,
|
||||
}: {
|
||||
risk: RiskWithDetail;
|
||||
users: { id: string; name: string }[];
|
||||
processes: { id: string; name: string }[];
|
||||
availableAssets: { id: string; name: string }[];
|
||||
availableMeasures: { id: string; refNo: number; title: string }[];
|
||||
threats: string[];
|
||||
vulnerabilities: string[];
|
||||
}) {
|
||||
const t = await getTranslations("risks");
|
||||
const tc = await getTranslations("common");
|
||||
@@ -310,7 +313,14 @@ export async function RiskEditModal({
|
||||
action={updateRisk.bind(null, risk.id)}
|
||||
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>
|
||||
</form>
|
||||
|
||||
@@ -357,21 +367,38 @@ export async function RiskEditModal({
|
||||
<p className="font-medium">{t("measures")}</p>
|
||||
<p className="mb-1 text-xs text-muted-foreground">{t("residualAuto")}</p>
|
||||
{risk.riskMeasures.length === 0 && <p>{tc("none")}</p>}
|
||||
<ul className="space-y-1.5">
|
||||
<ul className="space-y-2">
|
||||
{risk.riskMeasures.map((rm) => (
|
||||
<li key={rm.id} className="flex flex-wrap items-center gap-2">
|
||||
<b>{measureRef(rm.measure.refNo)}</b> {rm.measure.title}
|
||||
<span className="text-xs text-muted-foreground">
|
||||
(−{rm.reductionLikelihood} / −{rm.reductionImpact})
|
||||
</span>
|
||||
<form action={unlinkMeasureFromRisk.bind(null, risk.id, rm.id)}>
|
||||
<button
|
||||
type="submit"
|
||||
title={tc("remove")}
|
||||
className="text-muted-foreground hover:text-destructive"
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
</button>
|
||||
<li key={rm.id} className="rounded-lg border p-2.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<b>{measureRef(rm.measure.refNo)}</b>
|
||||
<span className="min-w-0 flex-1 truncate">{rm.measure.title}</span>
|
||||
<form action={unlinkMeasureFromRisk.bind(null, risk.id, rm.id)}>
|
||||
<button
|
||||
type="submit"
|
||||
title={tc("remove")}
|
||||
className="text-muted-foreground hover:text-destructive"
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
</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>
|
||||
</li>
|
||||
))}
|
||||
@@ -390,7 +417,7 @@ export async function RiskEditModal({
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<ReductionSelects selectClass={selectClass} />
|
||||
<ReductionInputs />
|
||||
<Button type="submit" variant="secondary" size="sm">
|
||||
{t("linkMeasure")}
|
||||
</Button>
|
||||
@@ -403,7 +430,7 @@ export async function RiskEditModal({
|
||||
>
|
||||
<p className="text-xs font-semibold">{t("newMeasure")}</p>
|
||||
<Input name="title" required placeholder={t("measureTitle")} />
|
||||
<ReductionSelects selectClass={selectClass} />
|
||||
<ReductionInputs />
|
||||
<Button type="submit" variant="secondary" size="sm">
|
||||
{tc("add")}
|
||||
</Button>
|
||||
@@ -423,30 +450,52 @@ export async function RiskEditModal({
|
||||
);
|
||||
}
|
||||
|
||||
/** Minderungs-Auswahl (Wahrscheinlichkeit/Schaden) für Maßnahmen-Verknüpfungen. */
|
||||
async function ReductionSelects({ selectClass }: { selectClass: string }) {
|
||||
/** Anzeige einer Minderung mit lokalisierten Dezimalzahlen. */
|
||||
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");
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<label className="text-[11px] text-muted-foreground">{t("reductionL")}</label>
|
||||
<select name="reductionLikelihood" defaultValue={0} className={`${selectClass} w-full`}>
|
||||
{REDUCTIONS.map((v) => (
|
||||
<option key={v} value={v}>
|
||||
−{v}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<Input
|
||||
name="reductionLikelihood"
|
||||
type="number"
|
||||
min={0}
|
||||
max={4}
|
||||
step={0.05}
|
||||
defaultValue={defaultL}
|
||||
className="h-9"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[11px] text-muted-foreground">{t("reductionI")}</label>
|
||||
<select name="reductionImpact" defaultValue={0} className={`${selectClass} w-full`}>
|
||||
{REDUCTIONS.map((v) => (
|
||||
<option key={v} value={v}>
|
||||
−{v}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<Input
|
||||
name="reductionImpact"
|
||||
type="number"
|
||||
min={0}
|
||||
max={4}
|
||||
step={0.05}
|
||||
defaultValue={defaultI}
|
||||
className="h-9"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -457,11 +506,15 @@ async function RiskFields({
|
||||
risk,
|
||||
users,
|
||||
processes,
|
||||
threats,
|
||||
vulnerabilities,
|
||||
selectClass,
|
||||
}: {
|
||||
risk?: RiskWithDetail;
|
||||
users: { id: string; name: string }[];
|
||||
processes: { id: string; name: string }[];
|
||||
threats: string[];
|
||||
vulnerabilities: string[];
|
||||
selectClass: string;
|
||||
}) {
|
||||
const t = await getTranslations("risks");
|
||||
@@ -488,16 +541,35 @@ async function RiskFields({
|
||||
<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" />
|
||||
<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>
|
||||
<Label htmlFor="vulnerability">{t("vulnerability")}</Label>
|
||||
<Input
|
||||
id="vulnerability"
|
||||
name="vulnerability"
|
||||
list="vulnerability-catalog"
|
||||
placeholder={t("catalogHint")}
|
||||
defaultValue={risk?.vulnerability ?? ""}
|
||||
className="mt-1"
|
||||
/>
|
||||
<datalist id="vulnerability-catalog">
|
||||
{vulnerabilities.map((name) => (
|
||||
<option key={name} value={name} />
|
||||
))}
|
||||
</datalist>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="likelihood">{t("likelihood")}</Label>
|
||||
@@ -601,10 +673,14 @@ export async function RiskCreateModal({
|
||||
users,
|
||||
processes,
|
||||
preselectedAsset,
|
||||
threats,
|
||||
vulnerabilities,
|
||||
}: {
|
||||
users: { id: string; name: string }[];
|
||||
processes: { id: string; name: string }[];
|
||||
preselectedAsset?: { id: string; name: string } | null;
|
||||
threats: string[];
|
||||
vulnerabilities: string[];
|
||||
}) {
|
||||
const t = await getTranslations("risks");
|
||||
const tc = await getTranslations("common");
|
||||
@@ -619,7 +695,13 @@ export async function RiskCreateModal({
|
||||
>
|
||||
<form action={createRisk} className="space-y-4 p-5">
|
||||
{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">
|
||||
<Button type="submit">{tc("save")}</Button>
|
||||
<Button variant="outline" nativeButton={false} render={<Link href="/risks" />}>
|
||||
|
||||
Reference in New Issue
Block a user