Iteration 2: Assets & BIA als gemeinsames Modul

- App-Shell mit Seitennavigation (alle 12 Module, kommende ausgegraut),
  shadcn/ui-Setup (Base-UI-Variante) mit hellem Theme
- Asset-Inventar: filterbare Liste (Suche/Typ/Status), Anlegen/Bearbeiten/
  Löschen, Detailansicht mit Schutzbedarf (C/I/A 1–4), Abhängigkeiten
  (beide Richtungen, hinzufügen/entfernen), zugeordneten Prozessen mit
  Primär-/Sekundär-Rolle und Platzhalter für zugeordnete Risiken
- Prozesse & BIA: Liste mit Kritikalität/RTO/MTD, Prozess-Detail mit
  Asset-Zuordnung nach Rolle (primär/sekundär), BIA-Formular
  (RTO/RPO/MTD, Schadenshöhe je Schutzziel, Kritikalität nach Max-Prinzip)
- Server-Actions mit Zod-Validierung, requirePermission und Audit-Log
  für jede schreibende Aktion; Tenant-Guard um upsert-Injektion erweitert
- Prisma: Asset, AssetRelation, Process, ProcessAsset, BiaEntry
  inkl. RLS-Policies; Seed mit Beispiel-Assets, Prozessen und BIA
- Im Browser verifiziert: CRUD, Relationen, BIA-Speichern, Audit-Einträge

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Martin
2026-07-02 12:35:19 +02:00
co-authored by Claude Fable 5
parent 880afed391
commit 392bb246cf
41 changed files with 6085 additions and 149 deletions
+161
View File
@@ -0,0 +1,161 @@
import Link from "next/link";
import { getTranslations } from "next-intl/server";
import { Plus } from "lucide-react";
import type { AssetStatus, AssetType } from "@prisma/client";
import { requireSession } from "@/server/auth";
import { dbForTenant } from "@/server/db";
import { hasPermission, requirePermission } from "@/server/rbac";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { LevelBadge } from "@/components/level-badge";
import { ModuleTabs } from "@/components/module-tabs";
import { Badge } from "@/components/ui/badge";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
const ASSET_TYPES = ["INFORMATION", "SYSTEM", "APPLICATION", "LOCATION", "SUPPLIER", "PERSON", "DATA"] as const;
const ASSET_STATUS = ["ACTIVE", "PLANNED", "RETIRED"] as const;
export default async function AssetsPage({
searchParams,
}: {
searchParams: Promise<{ q?: string; type?: string; status?: string }>;
}) {
const session = await requireSession();
requirePermission(session, "asset:read");
const t = await getTranslations("assets");
const tType = await getTranslations("assetType");
const tStatus = await getTranslations("assetStatus");
const tc = await getTranslations("common");
const { q, type, status } = await searchParams;
const db = dbForTenant(session.user.tenantId);
const assets = await db.asset.findMany({
where: {
...(q ? { name: { contains: q, mode: "insensitive" } } : {}),
...(type && ASSET_TYPES.includes(type as AssetType) ? { type: type as AssetType } : {}),
...(status && ASSET_STATUS.includes(status as AssetStatus)
? { status: status as AssetStatus }
: {}),
},
include: { owner: { select: { name: true } } },
orderBy: { name: "asc" },
take: 200,
});
const canWrite = hasPermission(session, "asset:write");
return (
<main className="flex-1 p-6">
<div className="flex items-center justify-between">
<h1 className="text-xl font-semibold">{t("title")}</h1>
{canWrite && (
<Button nativeButton={false} render={<Link href="/assets/new" />}>
<Plus className="size-4" /> {t("newAsset")}
</Button>
)}
</div>
<div className="mt-4">
<ModuleTabs
active="/assets"
tabs={[
{ href: "/assets", label: t("tabAssets") },
{ href: "/processes", label: t("tabProcesses") },
]}
/>
</div>
<form method="GET" className="mt-4 flex flex-wrap gap-2">
<Input
name="q"
defaultValue={q}
placeholder={tc("search")}
className="w-56"
/>
<select
name="type"
defaultValue={type ?? ""}
className="h-9 rounded-md border border-input bg-transparent px-3 text-sm"
>
<option value="">{t("allTypes")}</option>
{ASSET_TYPES.map((v) => (
<option key={v} value={v}>
{tType(v)}
</option>
))}
</select>
<select
name="status"
defaultValue={status ?? ""}
className="h-9 rounded-md border border-input bg-transparent px-3 text-sm"
>
<option value="">{t("allStatus")}</option>
{ASSET_STATUS.map((v) => (
<option key={v} value={v}>
{tStatus(v)}
</option>
))}
</select>
<Button type="submit" variant="secondary">
{tc("search").replace("…", "")}
</Button>
</form>
<div className="mt-4 rounded-lg border bg-card">
<Table>
<TableHeader>
<TableRow>
<TableHead>{t("name")}</TableHead>
<TableHead>{t("type")}</TableHead>
<TableHead>{t("protection")} (C/I/A)</TableHead>
<TableHead>{t("owner")}</TableHead>
<TableHead>{t("status")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{assets.length === 0 && (
<TableRow>
<TableCell colSpan={5} className="py-8 text-center text-muted-foreground">
{t("empty")}
</TableCell>
</TableRow>
)}
{assets.map((asset) => (
<TableRow key={asset.id}>
<TableCell>
<Link href={`/assets/${asset.id}`} className="font-medium hover:underline">
{asset.name}
</Link>
</TableCell>
<TableCell>
<Badge variant="secondary">{tType(asset.type)}</Badge>
</TableCell>
<TableCell>
<span className="flex gap-1">
<LevelBadge level={asset.confidentiality} prefix="C" />
<LevelBadge level={asset.integrity} prefix="I" />
<LevelBadge level={asset.availability} prefix="A" />
</span>
</TableCell>
<TableCell className="text-muted-foreground">
{asset.owner?.name ?? tc("none")}
</TableCell>
<TableCell className="text-muted-foreground">
{tStatus(asset.status)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</main>
);
}