Basis: Certvia dev@a48c5fb als Fundament für Craftvia
CI / build-and-check (push) Canceled after 0s
CI / audit (push) Canceled after 0s
CI / sbom (push) Canceled after 0s

Unveränderter Stand von certvia/dev (a48c5fb) plus Craftvia-Spezifikation
und Brandbook unter docs/craftvia/. ISMS-Module werden im Folgecommit entfernt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-14 11:05:39 +02:00
co-authored by Claude Opus 5
commit c8e6f30a27
720 changed files with 140143 additions and 0 deletions
+280
View File
@@ -0,0 +1,280 @@
import Link from "next/link";
import { getTranslations } from "next-intl/server";
import { Network, Pencil, Plus, Trash2 } from "lucide-react";
import type { Prisma } from "@prisma/client";
import { createSoftware, deleteSoftware, updateSoftware } from "@/server/actions/software";
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 { SegmentedRating } from "@/components/segmented-rating";
import { CiaBadge, CiaLegend, CriticalityPill, Pill, Tag } from "@/components/mockup-ui";
import { softwareRef, withQuery } from "@/lib/supplier";
import { riskLevel, riskRef, RISK_PILL_TONE } from "@/lib/risk";
export type SoftwareAssetDetail = Prisma.AssetGetPayload<{
include: {
softwareProfile: { include: { provider: { select: { id: true; name: true } } } };
riskAssets: { include: { risk: { select: { id: true; refNo: true; title: true; score: 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 } } } };
};
}>;
const inputCls = "h-9 w-full rounded-md border border-input bg-transparent px-3 text-sm";
const APPROVAL_TONE = { BEANTRAGT: "warn", FREIGEGEBEN: "ok", GESPERRT: "risk" } as const;
const APPROVAL_VALUES = ["BEANTRAGT", "FREIGEGEBEN", "GESPERRT"] as const;
/* ─────────────────────── Detail-Cockpit ─────────────────────── */
export async function SoftwareDetailModal({ software, canWrite, backHref = "/suppliers?tab=software" }: { software: SoftwareAssetDetail; canWrite: boolean; backHref?: string }) {
const t = await getTranslations("software");
const ta = await getTranslations("assets");
const tCrit = await getTranslations("criticality");
const tType = await getTranslations("assetType");
const tStatus = await getTranslations("softwareStatus");
const tLevel = await getTranslations("riskLevel");
const tRisks = await getTranslations("risks");
const tDep = await getTranslations("dependencies");
const tc = await getTranslations("common");
const p = software.softwareProfile!;
return (
<Modal
title={software.name}
sub={t("detailSub")}
headerExtra={
<span className="flex items-center gap-2">
<Tag>{tType(software.type)}</Tag>
<Pill tone={APPROVAL_TONE[p.approvalStatus]}>{tStatus(p.approvalStatus)}</Pill>
<CriticalityPill level={p.criticality} label={tCrit(String(p.criticality))} />
</span>
}
closeHref={backHref}
closeLabel={t("close")}
footer={
<>
{canWrite && (
<Button variant="outline" nativeButton={false} render={<Link href={withQuery(backHref, "edit", software.id)} />}>
<Pencil className="size-4" /> {tc("edit")}
</Button>
)}
<Button nativeButton={false} render={<Link href={backHref} />}>{t("close")}</Button>
</>
}
>
<div className="grid gap-5 p-5 md:grid-cols-2">
{/* Stammdaten */}
<div>
<div className="mb-2.5 flex items-center gap-2.5">
<Pill tone="violet">{t("masterPill")}</Pill>
</div>
<div className="rounded-xl border border-l-[3px] border-l-[var(--primary)] bg-[var(--surface-soft)] p-4">
<div className="flex items-center justify-between gap-2">
<b>{softwareRef(p.refNo)} · {software.name}</b>
<CiaBadge c={software.confidentiality} i={software.integrity} a={software.availability} labels />
</div>
<div className="mt-1.5 space-y-1 text-[12.5px] leading-relaxed text-muted-foreground">
<div>
{t("provider")}:{" "}
{p.provider ? (
<Link href={`/suppliers?detail=${p.provider.id}`} className="hover:underline">{p.provider.name}</Link>
) : t("noProvider")}
</div>
{p.version && <div>{t("version")}: {p.version}</div>}
<div>{t("approvalStatus")}: {tStatus(p.approvalStatus)}{p.approvedBy ? ` · ${t("approvedBy")}: ${p.approvedBy}` : ""}</div>
<div>{t("criticality")}: {tCrit(String(p.criticality))}</div>
{p.nextReview && <div>{t("nextReview")}: {p.nextReview.toISOString().slice(0, 10)}</div>}
</div>
{p.notes && <p className="mt-2 text-[12.5px] leading-relaxed">{p.notes}</p>}
</div>
</div>
{/* Verknüpfte Assets */}
<div>
<div className="mb-2.5 flex items-center gap-2.5">
<Pill tone="info">{ta("depPill")}</Pill>
</div>
{software.relationsFrom.length === 0 && software.relationsTo.length === 0 && (
<p className="text-sm text-muted-foreground">{tc("none")}</p>
)}
{software.relationsFrom.length > 0 && (
<table className="w-full text-sm">
<thead>
<tr><th /><th /><th className="pb-1 text-right font-normal"><CiaLegend /></th></tr>
</thead>
<tbody>
{software.relationsFrom.map((rel) => (
<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">{rel.relatedAsset.name}</Link>
</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>
))}
</tbody>
</table>
)}
<Link href="/dependencies" className="mt-3 inline-flex items-center gap-1.5 text-[12.5px] font-semibold text-[var(--info)] hover:underline">
<Network className="size-3.5" /> {tDep("openGraph")} →
</Link>
</div>
</div>
{/* Zugeordnete Risiken */}
<div className="mx-5 mb-5 rounded-xl border border-[var(--band-brd)] bg-[var(--band)] p-4 text-[12.5px] text-[var(--band-text)]">
<div className="flex items-center justify-between gap-2">
<b>{t("linkedRisks")}</b>
{canWrite && (
<Button variant="outline" size="sm" nativeButton={false} render={<Link href={`/risks?new=1&asset=${software.id}`} />}>
<Plus className="size-3.5" /> {tRisks("createFromAsset")}
</Button>
)}
</div>
{software.riskAssets.length === 0 && <p className="mt-1">{tc("none")}</p>}
<ul className="mt-2 space-y-1.5">
{software.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>
</Modal>
);
}
/* ─────────────────────── Formular ─────────────────────── */
async function SoftwareFields({ software, providers, formId }: { software?: SoftwareAssetDetail; providers: { id: string; name: string }[]; formId?: string }) {
const t = await getTranslations("software");
const tA = await getTranslations("assets");
const tLevel = await getTranslations("protectionLevel");
const tStatus = await getTranslations("softwareStatus");
const f = formId ? { form: formId } : {};
const p = software?.softwareProfile;
return (
<div className="grid gap-4 md:grid-cols-2">
<div className="md:col-span-2">
<Label htmlFor="name">{t("name")}</Label>
<Input id="name" name="name" required defaultValue={software?.name} className="mt-1" {...f} />
</div>
<div>
<Label htmlFor="providerAssetId">{t("provider")}</Label>
<select id="providerAssetId" name="providerAssetId" defaultValue={p?.providerAssetId ?? ""} className={`${inputCls} mt-1`} {...f}>
<option value="">{t("noProvider")}</option>
{providers.map((pr) => (
<option key={pr.id} value={pr.id}>{pr.name}</option>
))}
</select>
</div>
<div>
<Label htmlFor="version">{t("version")}</Label>
<Input id="version" name="version" defaultValue={p?.version ?? ""} className="mt-1" {...f} />
</div>
<div>
<Label htmlFor="approvalStatus">{t("approvalStatus")}</Label>
<select id="approvalStatus" name="approvalStatus" defaultValue={p?.approvalStatus ?? "BEANTRAGT"} className={`${inputCls} mt-1`} {...f}>
{APPROVAL_VALUES.map((s) => (
<option key={s} value={s}>{tStatus(s)}</option>
))}
</select>
</div>
<div>
<Label htmlFor="approvedBy">{t("approvedBy")}</Label>
<Input id="approvedBy" name="approvedBy" defaultValue={p?.approvedBy ?? ""} className="mt-1" {...f} />
</div>
<div>
<Label>{t("criticality")}</Label>
<div className="mt-1"><SegmentedRating name="criticality" defaultValue={p?.criticality ?? 1} form={formId} /></div>
</div>
<div>
<Label htmlFor="nextReview">{t("nextReview")}</Label>
<Input id="nextReview" name="nextReview" type="date" defaultValue={p?.nextReview ? p.nextReview.toISOString().slice(0, 10) : ""} className="mt-1" {...f} />
</div>
<fieldset className="md:col-span-2">
<legend className="text-sm font-medium">{t("protection")} (1–4)</legend>
<div className="mt-2 grid grid-cols-3 gap-4">
{(
[
["confidentiality", tA("confidentiality")],
["integrity", tA("integrity")],
["availability", tA("availability")],
] as const
).map(([n, label]) => (
<div key={n}>
<Label>{label}</Label>
<div className="mt-1">
<SegmentedRating name={n} defaultValue={(software?.[n] as number) ?? 1} low={tLevel("1")} high={tLevel("4")} form={formId} />
</div>
</div>
))}
</div>
</fieldset>
<div className="md:col-span-2">
<Label htmlFor="notes">{t("notes")}</Label>
<Textarea id="notes" name="notes" rows={2} defaultValue={p?.notes ?? ""} className="mt-1" {...f} />
</div>
</div>
);
}
export async function SoftwareCreateModal({ providers }: { providers: { id: string; name: string }[] }) {
const t = await getTranslations("software");
const tc = await getTranslations("common");
return (
<Modal title={t("createTitle")} closeHref="/suppliers?tab=software" closeLabel={t("close")}>
<form action={createSoftware} className="space-y-4 p-5">
<SoftwareFields providers={providers} />
<div className="flex gap-2 pt-1">
<Button type="submit">{tc("save")}</Button>
<Button variant="outline" nativeButton={false} render={<Link href="/suppliers?tab=software" />}>{tc("cancel")}</Button>
</div>
</form>
</Modal>
);
}
export async function SoftwareEditModal({ software, providers, backHref = "/suppliers?tab=software" }: { software: SoftwareAssetDetail; providers: { id: string; name: string }[]; backHref?: string }) {
const t = await getTranslations("software");
const tc = await getTranslations("common");
const FORM = "software-edit";
const detailHref = withQuery(backHref, "detail", software.id);
return (
<Modal
title={t("editTitle")}
sub={software.name}
headerExtra={
<details className="relative">
<summary className="grid size-8 cursor-pointer list-none place-items-center rounded-md text-muted-foreground hover:bg-muted [&::-webkit-details-marker]:hidden"><Trash2 className="size-4" /></summary>
<div className="shadow-card absolute right-0 z-20 mt-1 w-48 rounded-xl border bg-card p-1.5">
<form action={deleteSoftware.bind(null, software.id, backHref)}>
<button type="submit" className="flex w-full items-center gap-2 rounded-lg px-2.5 py-2 text-left text-sm text-destructive hover:bg-destructive/10"><Trash2 className="size-4" /> {tc("delete")}</button>
</form>
</div>
</details>
}
closeHref={detailHref}
closeLabel={t("close")}
footer={
<>
<Button variant="outline" nativeButton={false} render={<Link href={detailHref} />}>{tc("cancel")}</Button>
<Button type="submit" form={FORM}>{tc("save")}</Button>
</>
}
>
<form id={FORM} action={updateSoftware.bind(null, software.id)} className="hidden">
<input type="hidden" name="returnTo" value={backHref} />
</form>
<div className="p-5">
<SoftwareFields software={software} providers={providers} formId={FORM} />
</div>
</Modal>
);
}