IT-Service-Detail mit RACI-Matrix (VDA-ISA 6.1.3)
- IT-Services als Assets (type IT_SERVICE) mit ITServiceProfile, Provider-Verknüpfung - Detail-Cockpit: Stammdaten, verknüpfte Assets/Prozesse, Risiken - RACI-/Shared-Responsibility-Matrix über den ISA-Control-Katalog (Control hinzufügen via datalist, Verantwortung per Klick durchschalten, PROVIDER/US/SHARED, Nachweis-Referenz, Löschen) - Lieferanten/IT-Services als Pillen-Tabs (FilterTabs) auf /suppliers - Server-Actions services.ts (create/update/delete/addRaci/cycleRaci/deleteRaci) mit Zod, RBAC (supplier:write) und Audit-Log - ISA_CONTROLS-Vorschlagsliste (isa-controls.ts) - Demo-IT-Service "Managed ERP-Hosting" mit 5 RACI-Zeilen im Seed - de/en Übersetzungen (services, raciParty) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -469,5 +469,43 @@
|
||||
"ONBOARDING": "Onboarding",
|
||||
"UNDER_REVIEW": "In Prüfung",
|
||||
"OFFBOARDED": "Beendet"
|
||||
},
|
||||
"services": {
|
||||
"tabSuppliers": "Lieferanten",
|
||||
"tabServices": "IT-Services",
|
||||
"title": "IT-Services",
|
||||
"newService": "IT-Service",
|
||||
"ref": "ID",
|
||||
"name": "Name",
|
||||
"provider": "Provider (Lieferant)",
|
||||
"internal": "Intern betrieben",
|
||||
"criticality": "Kritikalität",
|
||||
"protection": "Schutzbedarf",
|
||||
"raciCoverage": "RACI dokumentiert",
|
||||
"empty": "Keine IT-Services erfasst.",
|
||||
"createTitle": "IT-Service anlegen",
|
||||
"editTitle": "IT-Service bearbeiten",
|
||||
"detailSub": "Asset-artig · Verantwortung (RACI) · Risiken",
|
||||
"notes": "Anmerkungen",
|
||||
"linkedAssets": "Verknüpfte Assets",
|
||||
"linkedProcesses": "Prozesse",
|
||||
"risks": "Risiken",
|
||||
"noProvider": "kein Provider",
|
||||
"raci": "Verantwortungsmatrix (Shared Responsibility)",
|
||||
"raciNote": "Anwendbarkeit der ISA-Controls je Service — erfüllt 6.1.3",
|
||||
"addControl": "Control hinzufügen",
|
||||
"control": "Control",
|
||||
"controlTitle": "Titel",
|
||||
"applicable": "Anwendbar",
|
||||
"responsibility": "Verantwortung",
|
||||
"evidence": "Nachweis",
|
||||
"raciEmpty": "Noch keine Controls zugeordnet.",
|
||||
"close": "Schließen",
|
||||
"none": "—"
|
||||
},
|
||||
"raciParty": {
|
||||
"PROVIDER": "Provider",
|
||||
"US": "Wir",
|
||||
"SHARED": "Geteilt"
|
||||
}
|
||||
}
|
||||
@@ -469,5 +469,43 @@
|
||||
"ONBOARDING": "Onboarding",
|
||||
"UNDER_REVIEW": "Under review",
|
||||
"OFFBOARDED": "Offboarded"
|
||||
},
|
||||
"services": {
|
||||
"tabSuppliers": "Suppliers",
|
||||
"tabServices": "IT services",
|
||||
"title": "IT services",
|
||||
"newService": "IT service",
|
||||
"ref": "ID",
|
||||
"name": "Name",
|
||||
"provider": "Provider (supplier)",
|
||||
"internal": "Operated internally",
|
||||
"criticality": "Criticality",
|
||||
"protection": "Protection needs",
|
||||
"raciCoverage": "RACI documented",
|
||||
"empty": "No IT services recorded.",
|
||||
"createTitle": "Create IT service",
|
||||
"editTitle": "Edit IT service",
|
||||
"detailSub": "Asset-like · responsibility (RACI) · risks",
|
||||
"notes": "Notes",
|
||||
"linkedAssets": "Linked assets",
|
||||
"linkedProcesses": "Processes",
|
||||
"risks": "Risks",
|
||||
"noProvider": "no provider",
|
||||
"raci": "Responsibility matrix (shared responsibility)",
|
||||
"raciNote": "Applicability of ISA controls per service — fulfils 6.1.3",
|
||||
"addControl": "Add control",
|
||||
"control": "Control",
|
||||
"controlTitle": "Title",
|
||||
"applicable": "Applicable",
|
||||
"responsibility": "Responsibility",
|
||||
"evidence": "Evidence",
|
||||
"raciEmpty": "No controls assigned yet.",
|
||||
"close": "Close",
|
||||
"none": "—"
|
||||
},
|
||||
"raciParty": {
|
||||
"PROVIDER": "Provider",
|
||||
"US": "Us",
|
||||
"SHARED": "Shared"
|
||||
}
|
||||
}
|
||||
@@ -479,6 +479,31 @@ async function main() {
|
||||
|
||||
console.log("✔ 2 Demo-Lieferanten als Assets angelegt");
|
||||
}
|
||||
|
||||
// 9. Demo-IT-Service (type IT_SERVICE) mit RACI-Matrix über den ISA-Katalog
|
||||
const svcCount = await prisma.iTServiceProfile.count({ where: { tenantId: tenant.id } });
|
||||
if (svcCount === 0) {
|
||||
const provider = await prisma.asset.findFirst({ where: { tenantId: tenant.id, name: "Cloud-Hoster GmbH", type: "SUPPLIER" } });
|
||||
const svc = await prisma.asset.create({
|
||||
data: { tenantId: tenant.id, name: "Managed ERP-Hosting", type: "IT_SERVICE", confidentiality: 3, integrity: 3, availability: 4 },
|
||||
});
|
||||
await prisma.iTServiceProfile.create({
|
||||
data: { tenantId: tenant.id, assetId: svc.id, refNo: 1, criticality: 4, internal: false, providerAssetId: provider?.id ?? null, notes: "Betrieb & Wartung der ERP-Plattform beim Cloud-Hoster (Shared Responsibility)." },
|
||||
});
|
||||
const raci: { controlRef: string; title: string; responsibility: "PROVIDER" | "US" | "SHARED"; evidenceRef?: string }[] = [
|
||||
{ controlRef: "4.1.2", title: "Authentisierung / MFA", responsibility: "SHARED", evidenceRef: "IAM-Konzept v2" },
|
||||
{ controlRef: "5.2.4", title: "Schwachstellen-/Patch-Management", responsibility: "PROVIDER", evidenceRef: "Patch-SLA" },
|
||||
{ controlRef: "5.2.5", title: "Datensicherung / Backup", responsibility: "PROVIDER", evidenceRef: "Backup-Report Q2" },
|
||||
{ controlRef: "4.1.3", title: "Zugriffsrechte-Verwaltung", responsibility: "US" },
|
||||
{ controlRef: "1.6.1", title: "Informationssicherheits-Vorfälle", responsibility: "SHARED", evidenceRef: "IR-Runbook" },
|
||||
];
|
||||
for (const r of raci) {
|
||||
await prisma.serviceControlResponsibility.create({
|
||||
data: { tenantId: tenant.id, assetId: svc.id, controlRef: r.controlRef, title: r.title, applicable: true, responsibility: r.responsibility, evidenceRef: r.evidenceRef ?? null },
|
||||
});
|
||||
}
|
||||
console.log("✔ 1 Demo-IT-Service mit RACI-Matrix angelegt");
|
||||
}
|
||||
}
|
||||
|
||||
main()
|
||||
|
||||
@@ -5,15 +5,23 @@ import { requireSession } from "@/server/auth";
|
||||
import { dbForTenant } from "@/server/db";
|
||||
import { hasPermission, requirePermission } from "@/server/rbac";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { PageHead, Pill } from "@/components/mockup-ui";
|
||||
import { PageHead, Pill, CriticalityPill, CiaBadge } from "@/components/mockup-ui";
|
||||
import { FilterTabs } from "@/components/filter-tabs";
|
||||
import {
|
||||
SupplierCreateModal,
|
||||
SupplierDetailModal,
|
||||
SupplierEditModal,
|
||||
type SupplierAssetDetail,
|
||||
} from "@/components/supplier-modals";
|
||||
import {
|
||||
ServiceCreateModal,
|
||||
ServiceDetailModal,
|
||||
ServiceEditModal,
|
||||
type ServiceAssetDetail,
|
||||
} from "@/components/service-modals";
|
||||
import {
|
||||
supplierRef,
|
||||
serviceRef,
|
||||
protectionLevel,
|
||||
PROTECTION_LABEL,
|
||||
buildReqContext,
|
||||
@@ -32,7 +40,7 @@ import {
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
|
||||
const DETAIL_INCLUDE = {
|
||||
const SUPPLIER_INCLUDE = {
|
||||
supplierProfile: true,
|
||||
contracts: true,
|
||||
ndas: true,
|
||||
@@ -46,23 +54,125 @@ const DETAIL_INCLUDE = {
|
||||
relationsTo: { include: { asset: { select: { name: true, confidentiality: true, integrity: true, availability: true } } } },
|
||||
} as const;
|
||||
|
||||
const SERVICE_INCLUDE = {
|
||||
serviceProfile: { include: { provider: { select: { id: true, name: true } } } },
|
||||
raci: true,
|
||||
riskAssets: { include: { risk: { select: { id: true, refNo: true, title: true } } } },
|
||||
relationsFrom: { include: { relatedAsset: { select: { id: true, name: true } } } },
|
||||
relationsTo: { include: { asset: { select: { id: true, name: true } } } },
|
||||
processAssets: { include: { process: { select: { id: true, name: true } } } },
|
||||
} as const;
|
||||
|
||||
export default async function SuppliersPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ detail?: string; edit?: string; new?: string }>;
|
||||
searchParams: Promise<{ tab?: string; detail?: string; edit?: string; new?: string }>;
|
||||
}) {
|
||||
const session = await requireSession();
|
||||
requirePermission(session, "supplier:read");
|
||||
const t = await getTranslations("suppliers");
|
||||
const ts = await getTranslations("services");
|
||||
const tCrit = await getTranslations("criticality");
|
||||
const tc = await getTranslations("common");
|
||||
|
||||
const params = await searchParams;
|
||||
const isServices = params.tab === "services";
|
||||
const db = dbForTenant(session.user.tenantId);
|
||||
const canWrite = hasPermission(session, "supplier:write");
|
||||
|
||||
const tabs = [
|
||||
{ href: "/suppliers", label: ts("tabSuppliers"), active: !isServices },
|
||||
{ href: "/suppliers?tab=services", label: ts("tabServices"), active: isServices },
|
||||
];
|
||||
|
||||
const head = (
|
||||
<PageHead
|
||||
crumb={t("crumb")}
|
||||
title={t("title")}
|
||||
sub={t("sub")}
|
||||
actions={
|
||||
canWrite && (
|
||||
<Button nativeButton={false} render={<Link href={isServices ? "/suppliers?tab=services&new=1" : "/suppliers?new=1"} />}>
|
||||
<Plus className="size-4" /> {isServices ? ts("newService") : t("newSupplier")}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
/>
|
||||
);
|
||||
|
||||
/* ── IT-Services ── */
|
||||
if (isServices) {
|
||||
const services = (await db.asset.findMany({
|
||||
where: { type: "IT_SERVICE", serviceProfile: { isNot: null } },
|
||||
include: SERVICE_INCLUDE,
|
||||
orderBy: { serviceProfile: { refNo: "asc" } },
|
||||
take: 300,
|
||||
})) as ServiceAssetDetail[];
|
||||
|
||||
const providers = await db.asset.findMany({
|
||||
where: { type: "SUPPLIER", supplierProfile: { isNot: null } },
|
||||
select: { id: true, name: true },
|
||||
orderBy: { name: "asc" },
|
||||
});
|
||||
|
||||
const modalId = params.edit && canWrite ? params.edit : params.detail;
|
||||
const modalService = modalId
|
||||
? ((await db.asset.findUnique({ where: { id: modalId }, include: SERVICE_INCLUDE })) as ServiceAssetDetail | null)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<main className="flex-1 p-6">
|
||||
{head}
|
||||
<div className="mt-3"><FilterTabs tabs={tabs} /></div>
|
||||
<div className="shadow-card mt-4 rounded-xl border bg-card">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{ts("ref")}</TableHead>
|
||||
<TableHead>{ts("name")}</TableHead>
|
||||
<TableHead>{ts("provider")}</TableHead>
|
||||
<TableHead>{ts("protection")}</TableHead>
|
||||
<TableHead>{ts("criticality")}</TableHead>
|
||||
<TableHead>{ts("raciCoverage")}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{services.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="py-8 text-center text-muted-foreground">{ts("empty")}</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{services.map((s) => (
|
||||
<TableRow key={s.id}>
|
||||
<TableCell className="text-muted-foreground">{serviceRef(s.serviceProfile!.refNo)}</TableCell>
|
||||
<TableCell>
|
||||
<Link href={`/suppliers?tab=services&detail=${s.id}`} className="font-bold hover:underline">{s.name}</Link>
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground">{s.serviceProfile?.provider?.name ?? (s.serviceProfile?.internal ? ts("internal") : tc("none"))}</TableCell>
|
||||
<TableCell><CiaBadge c={s.confidentiality} i={s.integrity} a={s.availability} /></TableCell>
|
||||
<TableCell><CriticalityPill level={s.serviceProfile!.criticality} label={tCrit(String(s.serviceProfile!.criticality))} /></TableCell>
|
||||
<TableCell className="text-muted-foreground">{s.raci.length}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{modalService && params.edit && canWrite ? (
|
||||
<ServiceEditModal service={modalService} providers={providers} />
|
||||
) : modalService ? (
|
||||
<ServiceDetailModal service={modalService} canWrite={canWrite} />
|
||||
) : params.new && canWrite ? (
|
||||
<ServiceCreateModal providers={providers} />
|
||||
) : null}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Lieferanten ── */
|
||||
const suppliers = await db.asset.findMany({
|
||||
where: { type: "SUPPLIER", supplierProfile: { isNot: null } },
|
||||
include: DETAIL_INCLUDE,
|
||||
include: SUPPLIER_INCLUDE,
|
||||
orderBy: { supplierProfile: { refNo: "asc" } },
|
||||
take: 300,
|
||||
});
|
||||
@@ -96,24 +206,13 @@ export default async function SuppliersPage({
|
||||
|
||||
const modalId = params.edit && canWrite ? params.edit : params.detail;
|
||||
const modalSupplier = modalId
|
||||
? ((await db.asset.findUnique({ where: { id: modalId }, include: DETAIL_INCLUDE })) as SupplierAssetDetail | null)
|
||||
? ((await db.asset.findUnique({ where: { id: modalId }, include: SUPPLIER_INCLUDE })) as SupplierAssetDetail | null)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<main className="flex-1 p-6">
|
||||
<PageHead
|
||||
crumb={t("crumb")}
|
||||
title={t("title")}
|
||||
sub={t("sub")}
|
||||
actions={
|
||||
canWrite && (
|
||||
<Button nativeButton={false} render={<Link href="/suppliers?new=1" />}>
|
||||
<Plus className="size-4" /> {t("newSupplier")}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
{head}
|
||||
<div className="mt-3"><FilterTabs tabs={tabs} /></div>
|
||||
<div className="shadow-card mt-4 rounded-xl border bg-card">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
|
||||
@@ -0,0 +1,281 @@
|
||||
import Link from "next/link";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { Pencil, Plus, Trash2 } from "lucide-react";
|
||||
import type { Prisma } from "@prisma/client";
|
||||
import {
|
||||
addRaci,
|
||||
createService,
|
||||
cycleRaci,
|
||||
deleteRaci,
|
||||
deleteService,
|
||||
updateService,
|
||||
} from "@/server/actions/services";
|
||||
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, CriticalityPill, Pill } from "@/components/mockup-ui";
|
||||
import { serviceRef } from "@/lib/supplier";
|
||||
import { ISA_CONTROLS } from "@/lib/isa-controls";
|
||||
|
||||
export type ServiceAssetDetail = Prisma.AssetGetPayload<{
|
||||
include: {
|
||||
serviceProfile: { include: { provider: { select: { id: true; name: true } } } };
|
||||
raci: true;
|
||||
riskAssets: { include: { risk: { select: { id: true; refNo: true; title: true } } } };
|
||||
relationsFrom: { include: { relatedAsset: { select: { id: true; name: true } } } };
|
||||
relationsTo: { include: { asset: { select: { id: true; name: true } } } };
|
||||
processAssets: { include: { process: { select: { id: true; name: true } } } };
|
||||
};
|
||||
}>;
|
||||
|
||||
const inputCls = "h-9 w-full rounded-md border border-input bg-transparent px-3 text-sm";
|
||||
const RACI_TONE = { PROVIDER: "info", US: "violet", SHARED: "warn" } as const;
|
||||
|
||||
/* ─────────────────────── Detail-Cockpit ─────────────────────── */
|
||||
|
||||
export async function ServiceDetailModal({ service, canWrite }: { service: ServiceAssetDetail; canWrite: boolean }) {
|
||||
const t = await getTranslations("services");
|
||||
const tParty = await getTranslations("raciParty");
|
||||
const tCrit = await getTranslations("criticality");
|
||||
const tc = await getTranslations("common");
|
||||
const p = service.serviceProfile!;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={`${serviceRef(p.refNo)} · ${service.name}`}
|
||||
sub={t("detailSub")}
|
||||
headerExtra={
|
||||
<span className="flex items-center gap-2">
|
||||
<CiaBadge c={service.confidentiality} i={service.integrity} a={service.availability} />
|
||||
<CriticalityPill level={p.criticality} label={tCrit(String(p.criticality))} />
|
||||
</span>
|
||||
}
|
||||
closeHref="/suppliers?tab=services"
|
||||
closeLabel={t("close")}
|
||||
footer={
|
||||
<>
|
||||
{canWrite && (
|
||||
<Button variant="outline" nativeButton={false} render={<Link href={`/suppliers?tab=services&edit=${service.id}`} />}>
|
||||
<Pencil className="size-4" /> {tc("edit")}
|
||||
</Button>
|
||||
)}
|
||||
<Button nativeButton={false} render={<Link href="/suppliers?tab=services" />}>{t("close")}</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="space-y-5 p-5">
|
||||
{/* Stammdaten + Verknüpfungen */}
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<div className="rounded-xl border border-l-[3px] border-l-[var(--primary)] bg-[var(--surface-soft)] p-4 text-[12.5px] md:col-span-2">
|
||||
<dl className="grid grid-cols-[8rem_1fr] gap-1.5">
|
||||
<dt className="text-muted-foreground">{t("provider")}</dt>
|
||||
<dd>
|
||||
{p.provider ? (
|
||||
<Link href={`/suppliers?detail=${p.provider.id}`} className="hover:underline">{p.provider.name}</Link>
|
||||
) : (
|
||||
<span className="text-muted-foreground">{p.internal ? t("internal") : t("noProvider")}</span>
|
||||
)}
|
||||
</dd>
|
||||
<dt className="text-muted-foreground">{t("linkedAssets")}</dt>
|
||||
<dd className="flex flex-wrap gap-1">
|
||||
{[...service.relationsFrom.map((r) => r.relatedAsset), ...service.relationsTo.map((r) => r.asset)].map((a) => (
|
||||
<Link key={a.id} href={`/assets?detail=${a.id}`} className="rounded bg-[var(--elevated)] px-1.5 py-0.5 hover:underline">{a.name}</Link>
|
||||
))}
|
||||
{service.relationsFrom.length + service.relationsTo.length === 0 && tc("none")}
|
||||
</dd>
|
||||
<dt className="text-muted-foreground">{t("linkedProcesses")}</dt>
|
||||
<dd className="flex flex-wrap gap-1">
|
||||
{service.processAssets.map((pa) => (
|
||||
<Link key={pa.id} href={`/processes?detail=${pa.process.id}`} className="rounded bg-[var(--elevated)] px-1.5 py-0.5 hover:underline">{pa.process.name}</Link>
|
||||
))}
|
||||
{service.processAssets.length === 0 && tc("none")}
|
||||
</dd>
|
||||
</dl>
|
||||
{p.notes && <p className="mt-2 text-muted-foreground">{p.notes}</p>}
|
||||
</div>
|
||||
<div className="rounded-xl border p-4">
|
||||
<p className="text-sm font-semibold">{t("risks")}</p>
|
||||
{service.riskAssets.length === 0 && <p className="mt-1 text-sm text-muted-foreground">{tc("none")}</p>}
|
||||
<ul className="mt-1.5 space-y-1 text-sm">
|
||||
{service.riskAssets.map((ra) => (
|
||||
<li key={ra.id}>
|
||||
<Link href={`/risks?detail=${ra.risk.id}`} className="hover:underline">R-{String(ra.risk.refNo).padStart(3, "0")} · {ra.risk.title}</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* RACI-Matrix */}
|
||||
<section className="rounded-xl border">
|
||||
<div className="flex items-center justify-between gap-2 p-4 pb-2">
|
||||
<div>
|
||||
<p className="font-heading text-[15px] font-semibold">{t("raci")}</p>
|
||||
<p className="text-[12px] text-muted-foreground">{t("raciNote")}</p>
|
||||
</div>
|
||||
{canWrite && (
|
||||
<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("addControl")}
|
||||
</summary>
|
||||
<form action={addRaci.bind(null, service.id)} className="shadow-card absolute right-0 z-10 mt-2 w-80 space-y-2 rounded-xl border bg-card p-3 text-sm">
|
||||
<Input name="controlRef" required list="isa-controls" placeholder={t("control")} />
|
||||
<datalist id="isa-controls">
|
||||
{ISA_CONTROLS.map((c) => (
|
||||
<option key={c.ref} value={c.ref}>{c.title}</option>
|
||||
))}
|
||||
</datalist>
|
||||
<Input name="title" placeholder={t("controlTitle")} />
|
||||
<select name="responsibility" className={inputCls}>
|
||||
{(["PROVIDER", "US", "SHARED"] as const).map((r) => (
|
||||
<option key={r} value={r}>{tParty(r)}</option>
|
||||
))}
|
||||
</select>
|
||||
<Input name="evidenceRef" placeholder={t("evidence")} />
|
||||
<Button type="submit" variant="secondary" size="sm">{tc("add")}</Button>
|
||||
</form>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
{service.raci.length === 0 ? (
|
||||
<p className="p-4 pt-0 text-sm text-muted-foreground">{t("raciEmpty")}</p>
|
||||
) : (
|
||||
<div className="overflow-hidden">
|
||||
<div className="grid grid-cols-[5rem_1fr_8rem_1fr_auto] items-center gap-x-3 border-y bg-muted/40 px-4 py-2 text-[10.5px] font-bold tracking-[.04em] text-muted-foreground uppercase">
|
||||
<span>{t("control")}</span>
|
||||
<span>{t("controlTitle")}</span>
|
||||
<span>{t("responsibility")}</span>
|
||||
<span>{t("evidence")}</span>
|
||||
<span />
|
||||
</div>
|
||||
{service.raci.map((r) => {
|
||||
const ctrl = ISA_CONTROLS.find((c) => c.ref === r.controlRef);
|
||||
return (
|
||||
<div key={r.id} className="grid grid-cols-[5rem_1fr_8rem_1fr_auto] items-center gap-x-3 border-b px-4 py-2.5 text-sm last:border-0">
|
||||
<b className={r.applicable ? "" : "opacity-40"}>{r.controlRef}</b>
|
||||
<span className={r.applicable ? "" : "opacity-40"}>{r.title ?? ctrl?.title ?? ""}</span>
|
||||
<span>
|
||||
{canWrite ? (
|
||||
<form action={cycleRaci.bind(null, service.id, r.id)}>
|
||||
<button type="submit" title="Verantwortung wechseln">
|
||||
<Pill tone={RACI_TONE[r.responsibility]}>{tParty(r.responsibility)}</Pill>
|
||||
</button>
|
||||
</form>
|
||||
) : (
|
||||
<Pill tone={RACI_TONE[r.responsibility]}>{tParty(r.responsibility)}</Pill>
|
||||
)}
|
||||
</span>
|
||||
<span className="truncate text-muted-foreground">{r.evidenceRef ?? tc("none")}</span>
|
||||
{canWrite && (
|
||||
<form action={deleteRaci.bind(null, service.id, r.id)}>
|
||||
<button type="submit" title={tc("remove")} className="text-muted-foreground hover:text-destructive"><Trash2 className="size-3.5" /></button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
/* ─────────────────────── Formular ─────────────────────── */
|
||||
|
||||
async function ServiceFields({ service, providers, formId }: { service?: ServiceAssetDetail; providers: { id: string; name: string }[]; formId?: string }) {
|
||||
const t = await getTranslations("services");
|
||||
const f = formId ? { form: formId } : {};
|
||||
const p = service?.serviceProfile;
|
||||
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={service?.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>{t("criticality")}</Label>
|
||||
<div className="mt-1"><SegmentedRating name="criticality" defaultValue={p?.criticality ?? 1} form={formId} /></div>
|
||||
</div>
|
||||
<div className="md:col-span-2">
|
||||
<Label>{t("protection")} (C/I/A)</Label>
|
||||
<div className="mt-1 flex gap-2">
|
||||
{(["confidentiality", "integrity", "availability"] as const).map((n) => (
|
||||
<SegmentedRating key={n} name={n} defaultValue={(service?.[n] as number) ?? 1} form={formId} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-sm md:col-span-2">
|
||||
<input type="checkbox" name="internal" defaultChecked={p?.internal} {...f} /> {t("internal")}
|
||||
</label>
|
||||
<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 ServiceCreateModal({ providers }: { providers: { id: string; name: string }[] }) {
|
||||
const t = await getTranslations("services");
|
||||
const tc = await getTranslations("common");
|
||||
return (
|
||||
<Modal title={t("createTitle")} closeHref="/suppliers?tab=services" closeLabel={t("close")}>
|
||||
<form action={createService} className="space-y-4 p-5">
|
||||
<ServiceFields 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=services" />}>{tc("cancel")}</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export async function ServiceEditModal({ service, providers }: { service: ServiceAssetDetail; providers: { id: string; name: string }[] }) {
|
||||
const t = await getTranslations("services");
|
||||
const tc = await getTranslations("common");
|
||||
const FORM = "service-edit";
|
||||
return (
|
||||
<Modal
|
||||
title={t("editTitle")}
|
||||
sub={service.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={deleteService.bind(null, service.id)}>
|
||||
<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={`/suppliers?tab=services&detail=${service.id}`}
|
||||
closeLabel={t("close")}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="outline" nativeButton={false} render={<Link href={`/suppliers?tab=services&detail=${service.id}`} />}>{tc("cancel")}</Button>
|
||||
<Button type="submit" form={FORM}>{tc("save")}</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<form id={FORM} action={updateService.bind(null, service.id)} className="hidden" />
|
||||
<div className="p-5">
|
||||
<ServiceFields service={service} providers={providers} formId={FORM} />
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* Repräsentative VDA-ISA-2027-Controls (7 Kapitel) für die Shared-Responsibility-
|
||||
* Matrix (6.1.3). Vollständiger Katalog kommt per Import aus ISA2027-EN.xlsx;
|
||||
* diese Auswahl dient als Vorschlagsliste (datalist), Freitext bleibt möglich.
|
||||
*/
|
||||
export const ISA_CONTROLS: { ref: string; title: string }[] = [
|
||||
{ ref: "1.6.1", title: "Informationssicherheits-Vorfälle" },
|
||||
{ ref: "1.6.2", title: "Business Continuity / Notfallmanagement" },
|
||||
{ ref: "2.1.1", title: "Qualifikation & Sensibilisierung des Personals" },
|
||||
{ ref: "3.1.1", title: "Physische Sicherheitszonen" },
|
||||
{ ref: "4.1.1", title: "Identitätsverwaltung" },
|
||||
{ ref: "4.1.2", title: "Authentisierung / MFA" },
|
||||
{ ref: "4.1.3", title: "Zugriffsrechte-Verwaltung" },
|
||||
{ ref: "5.1.1", title: "Änderungsmanagement" },
|
||||
{ ref: "5.2.1", title: "Netzwerksicherheit / Segmentierung" },
|
||||
{ ref: "5.2.2", title: "Schutz vor Schadsoftware" },
|
||||
{ ref: "5.2.3", title: "Protokollierung & Monitoring" },
|
||||
{ ref: "5.2.4", title: "Schwachstellen-/Patch-Management" },
|
||||
{ ref: "5.2.5", title: "Datensicherung / Backup" },
|
||||
{ ref: "5.3.1", title: "Kryptografie / Verschlüsselung" },
|
||||
{ ref: "7.1.1", title: "Einhaltung gesetzlicher/regulatorischer Vorgaben" },
|
||||
];
|
||||
@@ -0,0 +1,132 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { redirect } from "next/navigation";
|
||||
import { z } from "zod";
|
||||
import { requireSession } from "@/server/auth";
|
||||
import { dbForTenant, prisma } from "@/server/db";
|
||||
import { requirePermission } from "@/server/rbac";
|
||||
import { writeAuditLog } from "@/server/audit";
|
||||
|
||||
const level = z.coerce.number().int().min(1).max(4);
|
||||
|
||||
const serviceSchema = z.object({
|
||||
name: z.string().trim().min(1).max(200),
|
||||
confidentiality: level,
|
||||
integrity: level,
|
||||
availability: level,
|
||||
criticality: level,
|
||||
providerAssetId: z.string().optional(),
|
||||
internal: z.string().optional(),
|
||||
notes: z.string().trim().max(5000).optional(),
|
||||
});
|
||||
|
||||
function parse(formData: FormData) {
|
||||
const p = serviceSchema.parse({
|
||||
name: formData.get("name"),
|
||||
confidentiality: formData.get("confidentiality"),
|
||||
integrity: formData.get("integrity"),
|
||||
availability: formData.get("availability"),
|
||||
criticality: formData.get("criticality"),
|
||||
providerAssetId: formData.get("providerAssetId") || undefined,
|
||||
internal: formData.get("internal") || undefined,
|
||||
notes: formData.get("notes") || undefined,
|
||||
});
|
||||
return {
|
||||
asset: { name: p.name, confidentiality: p.confidentiality, integrity: p.integrity, availability: p.availability },
|
||||
profile: {
|
||||
criticality: p.criticality,
|
||||
providerAssetId: p.providerAssetId || null,
|
||||
internal: p.internal === "on",
|
||||
notes: p.notes || null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function createService(formData: FormData) {
|
||||
const session = await requireSession();
|
||||
requirePermission(session, "supplier:write");
|
||||
const db = dbForTenant(session.user.tenantId);
|
||||
const { asset, profile } = parse(formData);
|
||||
const created = await db.asset.create({
|
||||
data: { ...asset, type: "IT_SERVICE", tenantId: session.user.tenantId, createdBy: session.user.id },
|
||||
});
|
||||
const last = await prisma.iTServiceProfile.aggregate({ where: { tenantId: session.user.tenantId }, _max: { refNo: true } });
|
||||
await db.iTServiceProfile.create({
|
||||
data: { ...profile, assetId: created.id, refNo: (last._max.refNo ?? 0) + 1, tenantId: session.user.tenantId, createdBy: session.user.id },
|
||||
});
|
||||
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "create", entity: "it_service", entityId: created.id, after: { ...asset, ...profile } });
|
||||
revalidatePath("/suppliers");
|
||||
revalidatePath("/assets");
|
||||
redirect(`/suppliers?tab=services&edit=${created.id}`);
|
||||
}
|
||||
|
||||
export async function updateService(assetId: string, formData: FormData) {
|
||||
const session = await requireSession();
|
||||
requirePermission(session, "supplier:write");
|
||||
const db = dbForTenant(session.user.tenantId);
|
||||
const before = await db.asset.findUnique({ where: { id: assetId }, include: { serviceProfile: true } });
|
||||
if (!before) throw new Error("Service nicht gefunden");
|
||||
const { asset, profile } = parse(formData);
|
||||
await db.asset.update({ where: { id: assetId }, data: asset });
|
||||
await db.iTServiceProfile.update({ where: { assetId }, data: profile });
|
||||
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "update", entity: "it_service", entityId: assetId, before, after: { ...asset, ...profile } });
|
||||
revalidatePath("/suppliers");
|
||||
revalidatePath("/assets");
|
||||
redirect(`/suppliers?tab=services&detail=${assetId}`);
|
||||
}
|
||||
|
||||
export async function deleteService(assetId: string) {
|
||||
const session = await requireSession();
|
||||
requirePermission(session, "supplier:write");
|
||||
const db = dbForTenant(session.user.tenantId);
|
||||
const before = await db.asset.findUnique({ where: { id: assetId } });
|
||||
if (!before) throw new Error("Service nicht gefunden");
|
||||
await db.asset.delete({ where: { id: assetId } });
|
||||
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "delete", entity: "it_service", entityId: assetId, before });
|
||||
revalidatePath("/suppliers");
|
||||
revalidatePath("/assets");
|
||||
redirect("/suppliers?tab=services");
|
||||
}
|
||||
|
||||
export async function addRaci(assetId: string, formData: FormData) {
|
||||
const session = await requireSession();
|
||||
requirePermission(session, "supplier:write");
|
||||
const db = dbForTenant(session.user.tenantId);
|
||||
if ((await db.asset.count({ where: { id: assetId } })) !== 1) throw new Error("Nicht gefunden");
|
||||
await db.serviceControlResponsibility.create({
|
||||
data: {
|
||||
assetId,
|
||||
tenantId: session.user.tenantId,
|
||||
controlRef: z.string().trim().min(1).parse(formData.get("controlRef")),
|
||||
title: (formData.get("title") as string)?.trim() || null,
|
||||
applicable: formData.get("applicable") !== "off",
|
||||
responsibility: z.enum(["PROVIDER", "US", "SHARED"]).parse(formData.get("responsibility") ?? "SHARED"),
|
||||
evidenceRef: (formData.get("evidenceRef") as string)?.trim() || null,
|
||||
},
|
||||
});
|
||||
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "create", entity: "service_raci", entityId: assetId });
|
||||
revalidatePath("/suppliers");
|
||||
}
|
||||
|
||||
/** Verantwortung einer RACI-Zeile per Klick ändern (PROVIDER/US/SHARED durchschalten). */
|
||||
export async function cycleRaci(assetId: string, raciId: string) {
|
||||
const session = await requireSession();
|
||||
requirePermission(session, "supplier:write");
|
||||
const db = dbForTenant(session.user.tenantId);
|
||||
const row = await db.serviceControlResponsibility.findUnique({ where: { id: raciId } });
|
||||
if (!row) return;
|
||||
const next = { PROVIDER: "US", US: "SHARED", SHARED: "PROVIDER" } as const;
|
||||
await db.serviceControlResponsibility.update({ where: { id: raciId }, data: { responsibility: next[row.responsibility] } });
|
||||
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "update", entity: "service_raci", entityId: assetId, after: { responsibility: next[row.responsibility] } });
|
||||
revalidatePath("/suppliers");
|
||||
}
|
||||
|
||||
export async function deleteRaci(assetId: string, raciId: string) {
|
||||
const session = await requireSession();
|
||||
requirePermission(session, "supplier:write");
|
||||
const db = dbForTenant(session.user.tenantId);
|
||||
await db.serviceControlResponsibility.delete({ where: { id: raciId } });
|
||||
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "delete", entity: "service_raci", entityId: assetId });
|
||||
revalidatePath("/suppliers");
|
||||
}
|
||||
Reference in New Issue
Block a user