- Maßnahmen-Bereich unterhalb der Stammdaten über die komplette Breite (Tabelle mit ID, Titel, editierbarer Minderung je Dimension) - Zwei Aktionen als aufklappbare Buttons: "Maßnahme hinzufügen" (bestehende auswählen) und "Neue Maßnahme erstellen" (legt an und verknüpft direkt mit dem Risiko, Rest-Risiko wird neu berechnet) - Risiko-Detail: Brutto→Rest als zwei farbig abgesetzte Karten (rot/ orange) mit Pfeil, großer Score-Zahl und Level-Chip; darunter Farbskala (grün→rot) mit Markern für Brutto und Rest (Referenz: docs/ISMS-GUI-Verbesserungen-GEFIM.html) Verifiziert: neue Maßnahme -0,75 → Rest 1,75 × 4 = 7; Skala/Karten korrekt gerendert. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
796 lines
27 KiB
TypeScript
796 lines
27 KiB
TypeScript
import Link from "next/link";
|
||
import { getFormatter, getTranslations } from "next-intl/server";
|
||
import { ArrowRight, Check, Pencil, Plus, Trash2, X } from "lucide-react";
|
||
import type { Prisma } from "@prisma/client";
|
||
import {
|
||
addRiskAsset,
|
||
createRisk,
|
||
deleteRisk,
|
||
removeRiskAsset,
|
||
updateRisk,
|
||
} from "@/server/actions/risks";
|
||
import {
|
||
createMeasureForRisk,
|
||
linkMeasureToRisk,
|
||
unlinkMeasureFromRisk,
|
||
} from "@/server/actions/measures";
|
||
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";
|
||
import { measureRef, MEASURE_STATUS_TONE } from "@/lib/measure";
|
||
|
||
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;
|
||
};
|
||
};
|
||
};
|
||
};
|
||
riskMeasures: {
|
||
include: {
|
||
measure: { select: { id: true; refNo: true; title: true; status: 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 }: { score: number }) {
|
||
const tLevel = await getTranslations("riskLevel");
|
||
const format = await getFormatter();
|
||
const level = riskLevel(score);
|
||
return (
|
||
<Pill tone={RISK_PILL_TONE[level]}>
|
||
{format.number(score, { maximumFractionDigits: 2 })} · {tLevel(level)}
|
||
</Pill>
|
||
);
|
||
}
|
||
|
||
/** Bewertungs-Karte: große Score-Zahl, darunter Wahrscheinlichkeit × Schaden + Level-Chip. */
|
||
async function RatingCard({
|
||
title,
|
||
likelihood,
|
||
impact,
|
||
score,
|
||
accent,
|
||
}: {
|
||
title: string;
|
||
likelihood: number | null;
|
||
impact: number | null;
|
||
score: number | null;
|
||
accent: "gross" | "residual";
|
||
}) {
|
||
const t = await getTranslations("risks");
|
||
const format = await getFormatter();
|
||
const num = (v: number) => format.number(v, { maximumFractionDigits: 2 });
|
||
|
||
const border = accent === "gross" ? "border-l-[#d64c4c]" : "border-l-[#e0982e]";
|
||
|
||
return (
|
||
<div className={`rounded-xl border border-l-[3px] bg-white p-4 ${border}`}>
|
||
<div className="text-[11px] font-bold tracking-wide text-muted-foreground uppercase">
|
||
{title}
|
||
</div>
|
||
{score != null && likelihood != null && impact != null ? (
|
||
<>
|
||
<div className="mt-1 flex items-baseline gap-2">
|
||
<span className="font-heading text-3xl font-bold leading-none text-foreground">
|
||
{num(score)}
|
||
</span>
|
||
<ScorePill score={score} />
|
||
</div>
|
||
<div className="mt-1.5 text-[12px] text-muted-foreground">
|
||
{t("likelihoodShort")} {num(likelihood)} × {t("damageShort")} {num(impact)}
|
||
</div>
|
||
</>
|
||
) : (
|
||
<p className="mt-1.5 text-[12px] text-muted-foreground">{t("noMeasures")}</p>
|
||
)}
|
||
{accent === "residual" && (
|
||
<p className="mt-2 text-[11.5px] text-muted-foreground">{t("residualAuto")}</p>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/** Farbskala grün→rot mit Markern für Brutto (dunkel) und Rest (orange). SPEC §4.2. */
|
||
function RiskScoreScale({ gross, residual }: { gross: number; residual: number | null }) {
|
||
// 5×5 → Score 1..25; Position linear auf der Skala
|
||
const pos = (v: number) => `${((Math.min(25, Math.max(1, v)) - 1) / 24) * 100}%`;
|
||
return (
|
||
<div className="mt-4">
|
||
<div
|
||
className="relative h-3 rounded-lg"
|
||
style={{
|
||
background:
|
||
"linear-gradient(90deg,#dff3e5,#fdf0cf,#fbdcc0,#f6c3c1,#ef9a98)",
|
||
}}
|
||
>
|
||
<span
|
||
className="absolute -top-1 h-5 w-[3px] -translate-x-1/2 rounded-sm bg-[#2b2646]"
|
||
style={{ left: pos(gross) }}
|
||
title={`Brutto ${gross}`}
|
||
/>
|
||
{residual != null && (
|
||
<span
|
||
className="absolute -top-1 h-5 w-[3px] -translate-x-1/2 rounded-sm bg-[#e0982e]"
|
||
style={{ left: pos(residual) }}
|
||
title={`Rest ${residual}`}
|
||
/>
|
||
)}
|
||
</div>
|
||
<div className="mt-1.5 flex items-center gap-4 text-[11px] text-muted-foreground">
|
||
<span className="inline-flex items-center gap-1.5">
|
||
<i className="inline-block h-3 w-[3px] rounded-sm bg-[#2b2646]" /> Brutto
|
||
</span>
|
||
<span className="inline-flex items-center gap-1.5">
|
||
<i className="inline-block h-3 w-[3px] rounded-sm bg-[#e0982e]" /> Rest
|
||
</span>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/** 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 tMStatus = await getTranslations("measureStatus");
|
||
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>
|
||
<ScorePill score={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 → Rest-Risiko: zwei Karten mit Pfeil + Farbskala */}
|
||
<div className="mx-5 rounded-xl border border-[#e6e2f3] bg-[#f2f0f9] p-4">
|
||
<div className="grid items-center gap-3 sm:grid-cols-[1fr_auto_1fr]">
|
||
<RatingCard
|
||
title={t("gross")}
|
||
likelihood={risk.likelihood}
|
||
impact={risk.impact}
|
||
score={risk.score}
|
||
accent="gross"
|
||
/>
|
||
<ArrowRight className="mx-auto size-5 rotate-90 text-muted-foreground sm:rotate-0" />
|
||
<RatingCard
|
||
title={t("residual")}
|
||
likelihood={risk.residualLikelihood}
|
||
impact={risk.residualImpact}
|
||
score={risk.residualScore}
|
||
accent="residual"
|
||
/>
|
||
</div>
|
||
<RiskScoreScale gross={risk.score} residual={risk.residualScore} />
|
||
</div>
|
||
|
||
{/* Notwendige Maßnahmen — echt verknüpft */}
|
||
<div className="mx-5 mb-5 mt-4">
|
||
<p className="text-sm font-semibold">{t("measures")}</p>
|
||
{risk.riskMeasures.length === 0 && (
|
||
<p className="mt-1 text-sm text-muted-foreground">{t("noMeasures")}</p>
|
||
)}
|
||
<ul className="mt-1.5 space-y-1.5 text-sm">
|
||
{risk.riskMeasures.map((rm) => (
|
||
<li key={rm.id} className="flex flex-wrap items-center gap-2">
|
||
<Link href={`/measures?detail=${rm.measure.id}`} className="font-bold hover:underline">
|
||
{measureRef(rm.measure.refNo)}
|
||
</Link>
|
||
<Link href={`/measures?detail=${rm.measure.id}`} className="hover:underline">
|
||
{rm.measure.title}
|
||
</Link>
|
||
<Pill tone={MEASURE_STATUS_TONE[rm.measure.status]}>
|
||
{tMStatus(rm.measure.status)}
|
||
</Pill>
|
||
{(rm.reductionLikelihood > 0 || rm.reductionImpact > 0) && (
|
||
<ReductionLabel l={rm.reductionLikelihood} i={rm.reductionImpact} />
|
||
)}
|
||
</li>
|
||
))}
|
||
</ul>
|
||
</div>
|
||
</Modal>
|
||
);
|
||
}
|
||
|
||
/** Bearbeiten als Popup: Bewertung, betroffene Assets, Maßnahmen, Löschen. */
|
||
export async function RiskEditModal({
|
||
risk,
|
||
users,
|
||
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");
|
||
|
||
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}
|
||
threats={threats}
|
||
vulnerabilities={vulnerabilities}
|
||
selectClass={selectClass}
|
||
/>
|
||
<Button type="submit">{tc("save")}</Button>
|
||
</form>
|
||
|
||
<div className="space-y-5 text-sm">
|
||
{/* Betroffene Assets */}
|
||
<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>
|
||
{availableAssets.length > 0 && (
|
||
<form action={addRiskAsset.bind(null, risk.id)} className="mt-2 flex gap-2">
|
||
<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>
|
||
|
||
</div>
|
||
</div>
|
||
|
||
{/* Maßnahmen → bestimmen das Rest-Risiko (volle Breite unter den Stammdaten) */}
|
||
<div className="border-t p-5 text-sm">
|
||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||
<div>
|
||
<p className="font-heading text-[15px] font-semibold">{t("measures")}</p>
|
||
<p className="text-xs text-muted-foreground">{t("residualAuto")}</p>
|
||
</div>
|
||
<div className="flex gap-2">
|
||
{/* Aufklappbare Formulare per <details> — serverseitig, kein Client-State */}
|
||
{availableMeasures.length > 0 && (
|
||
<details className="relative">
|
||
<summary className="bg-grad-soft inline-flex h-8 cursor-pointer list-none items-center gap-1.5 rounded-lg px-2.5 font-heading text-sm font-semibold text-white select-none hover:opacity-90 [&::-webkit-details-marker]:hidden">
|
||
<Plus className="size-4" /> {t("addExistingBtn")}
|
||
</summary>
|
||
<form
|
||
action={linkMeasureToRisk.bind(null, risk.id)}
|
||
className="shadow-card absolute right-0 z-10 mt-2 w-80 space-y-2 rounded-xl border bg-card p-3"
|
||
>
|
||
<p className="text-xs font-semibold">{t("linkMeasure")}</p>
|
||
<select name="measureId" required className={`${selectClass} w-full`}>
|
||
{availableMeasures.map((m) => (
|
||
<option key={m.id} value={m.id}>
|
||
{measureRef(m.refNo)} · {m.title}
|
||
</option>
|
||
))}
|
||
</select>
|
||
<ReductionInputs />
|
||
<Button type="submit" variant="secondary" size="sm">
|
||
{t("linkMeasure")}
|
||
</Button>
|
||
</form>
|
||
</details>
|
||
)}
|
||
<details className="relative">
|
||
<summary className="inline-flex h-8 cursor-pointer list-none items-center gap-1.5 rounded-lg border border-border bg-background px-2.5 font-heading text-sm font-semibold select-none hover:bg-muted [&::-webkit-details-marker]:hidden">
|
||
<Plus className="size-4" /> {t("createNewBtn")}
|
||
</summary>
|
||
<form
|
||
action={createMeasureForRisk.bind(null, risk.id)}
|
||
className="shadow-card absolute right-0 z-10 mt-2 w-80 space-y-2 rounded-xl border bg-card p-3"
|
||
>
|
||
<p className="text-xs font-semibold">{t("newMeasure")}</p>
|
||
<Input name="title" required placeholder={t("measureTitle")} />
|
||
<ReductionInputs />
|
||
<Button type="submit" variant="secondary" size="sm">
|
||
{tc("add")}
|
||
</Button>
|
||
</form>
|
||
</details>
|
||
</div>
|
||
</div>
|
||
|
||
{risk.riskMeasures.length === 0 && (
|
||
<p className="mt-3 text-muted-foreground">{tc("none")}</p>
|
||
)}
|
||
{risk.riskMeasures.length > 0 && (
|
||
<div className="mt-3 overflow-hidden rounded-xl border">
|
||
<div className="grid grid-cols-[7rem_1fr_11rem_11rem_auto_auto] items-center gap-x-3 border-b bg-muted/60 px-3 py-2 text-[11px] font-bold tracking-[.04em] text-muted-foreground uppercase max-md:hidden">
|
||
<span>{t("id")}</span>
|
||
<span>{t("measureCol")}</span>
|
||
<span>{t("reductionL")}</span>
|
||
<span>{t("reductionI")}</span>
|
||
<span />
|
||
<span />
|
||
</div>
|
||
{risk.riskMeasures.map((rm) => (
|
||
<form
|
||
key={rm.id + "-" + rm.reductionLikelihood + "-" + rm.reductionImpact}
|
||
action={linkMeasureToRisk.bind(null, risk.id)}
|
||
className="grid grid-cols-1 items-center gap-2 border-b px-3 py-2.5 last:border-0 md:grid-cols-[7rem_1fr_11rem_11rem_auto_auto] md:gap-x-3"
|
||
>
|
||
<input type="hidden" name="measureId" value={rm.measureId} />
|
||
<b>{measureRef(rm.measure.refNo)}</b>
|
||
<span className="min-w-0 truncate" title={rm.measure.title}>
|
||
{rm.measure.title}
|
||
</span>
|
||
<Input
|
||
name="reductionLikelihood"
|
||
type="number"
|
||
min={0}
|
||
max={4}
|
||
step={0.05}
|
||
defaultValue={rm.reductionLikelihood}
|
||
aria-label={t("reductionL")}
|
||
className="h-8"
|
||
/>
|
||
<Input
|
||
name="reductionImpact"
|
||
type="number"
|
||
min={0}
|
||
max={4}
|
||
step={0.05}
|
||
defaultValue={rm.reductionImpact}
|
||
aria-label={t("reductionI")}
|
||
className="h-8"
|
||
/>
|
||
<Button type="submit" variant="secondary" size="icon-sm" title={tc("save")}>
|
||
<Check className="size-3.5" />
|
||
</Button>
|
||
<Button
|
||
type="submit"
|
||
variant="ghost"
|
||
size="icon-sm"
|
||
title={tc("remove")}
|
||
formAction={unlinkMeasureFromRisk.bind(null, risk.id, rm.id)}
|
||
className="text-muted-foreground hover:text-destructive"
|
||
>
|
||
<X className="size-3.5" />
|
||
</Button>
|
||
</form>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<div className="border-t p-5">
|
||
<form action={deleteRisk.bind(null, risk.id)}>
|
||
<Button type="submit" variant="destructive" size="sm">
|
||
<Trash2 className="size-4" /> {tc("delete")}
|
||
</Button>
|
||
</form>
|
||
</div>
|
||
</Modal>
|
||
);
|
||
}
|
||
|
||
/** 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>
|
||
<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>
|
||
<Input
|
||
name="reductionImpact"
|
||
type="number"
|
||
min={0}
|
||
max={4}
|
||
step={0.05}
|
||
defaultValue={defaultI}
|
||
className="h-9"
|
||
/>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/** Formularfelder (Anlegen + Bearbeiten teilen sich das Markup). */
|
||
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");
|
||
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"
|
||
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>
|
||
<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="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 — optional mit vorverknüpftem Asset (aus dem Asset-Popup). */
|
||
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");
|
||
const selectClass = "h-9 rounded-md border border-input bg-transparent px-3 text-sm";
|
||
|
||
return (
|
||
<Modal
|
||
title={t("createTitle")}
|
||
sub={preselectedAsset ? `${t("affectedAssets")}: ${preselectedAsset.name}` : undefined}
|
||
closeHref="/risks"
|
||
closeLabel={t("close")}
|
||
>
|
||
<form action={createRisk} className="space-y-4 p-5">
|
||
{preselectedAsset && <input type="hidden" name="assetId" value={preselectedAsset.id} />}
|
||
<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" />}>
|
||
{tc("cancel")}
|
||
</Button>
|
||
</div>
|
||
</form>
|
||
</Modal>
|
||
);
|
||
}
|