diff --git a/messages/de.json b/messages/de.json index 8930aaa..1986730 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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" } } \ No newline at end of file diff --git a/messages/en.json b/messages/en.json index 35d1ebd..51684ab 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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" } } \ No newline at end of file diff --git a/prisma/seed.ts b/prisma/seed.ts index 0d48c1c..ce73ecc 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -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() diff --git a/src/app/(app)/suppliers/page.tsx b/src/app/(app)/suppliers/page.tsx index 20019fc..102abfc 100644 --- a/src/app/(app)/suppliers/page.tsx +++ b/src/app/(app)/suppliers/page.tsx @@ -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 = ( + }> + {isServices ? ts("newService") : t("newSupplier")} + + ) + } + /> + ); + + /* ── 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 ( +
+ {head} +
+
+ + + + {ts("ref")} + {ts("name")} + {ts("provider")} + {ts("protection")} + {ts("criticality")} + {ts("raciCoverage")} + + + + {services.length === 0 && ( + + {ts("empty")} + + )} + {services.map((s) => ( + + {serviceRef(s.serviceProfile!.refNo)} + + {s.name} + + {s.serviceProfile?.provider?.name ?? (s.serviceProfile?.internal ? ts("internal") : tc("none"))} + + + {s.raci.length} + + ))} + +
+
+ + {modalService && params.edit && canWrite ? ( + + ) : modalService ? ( + + ) : params.new && canWrite ? ( + + ) : null} +
+ ); + } + + /* ── 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 (
- }> - {t("newSupplier")} - - ) - } - /> - + {head} +
diff --git a/src/components/service-modals.tsx b/src/components/service-modals.tsx new file mode 100644 index 0000000..ded158d --- /dev/null +++ b/src/components/service-modals.tsx @@ -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 ( + + + + + } + closeHref="/suppliers?tab=services" + closeLabel={t("close")} + footer={ + <> + {canWrite && ( + + )} + + + } + > +
+ {/* Stammdaten + Verknüpfungen */} +
+
+
+
{t("provider")}
+
+ {p.provider ? ( + {p.provider.name} + ) : ( + {p.internal ? t("internal") : t("noProvider")} + )} +
+
{t("linkedAssets")}
+
+ {[...service.relationsFrom.map((r) => r.relatedAsset), ...service.relationsTo.map((r) => r.asset)].map((a) => ( + {a.name} + ))} + {service.relationsFrom.length + service.relationsTo.length === 0 && tc("none")} +
+
{t("linkedProcesses")}
+
+ {service.processAssets.map((pa) => ( + {pa.process.name} + ))} + {service.processAssets.length === 0 && tc("none")} +
+
+ {p.notes &&

{p.notes}

} +
+
+

{t("risks")}

+ {service.riskAssets.length === 0 &&

{tc("none")}

} +
    + {service.riskAssets.map((ra) => ( +
  • + R-{String(ra.risk.refNo).padStart(3, "0")} · {ra.risk.title} +
  • + ))} +
+
+
+ + {/* RACI-Matrix */} +
+
+
+

{t("raci")}

+

{t("raciNote")}

+
+ {canWrite && ( +
+ + {t("addControl")} + +
+ + + {ISA_CONTROLS.map((c) => ( + + ))} + + + + + + +
+ )} +
+ {service.raci.length === 0 ? ( +

{t("raciEmpty")}

+ ) : ( +
+
+ {t("control")} + {t("controlTitle")} + {t("responsibility")} + {t("evidence")} + +
+ {service.raci.map((r) => { + const ctrl = ISA_CONTROLS.find((c) => c.ref === r.controlRef); + return ( +
+ {r.controlRef} + {r.title ?? ctrl?.title ?? ""} + + {canWrite ? ( +
+ + + ) : ( + {tParty(r.responsibility)} + )} +
+ {r.evidenceRef ?? tc("none")} + {canWrite && ( +
+ + + )} +
+ ); + })} +
+ )} +
+
+
+ ); +} + +/* ─────────────────────── 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 ( +
+
+ + +
+
+ + +
+
+ +
+
+
+ +
+ {(["confidentiality", "integrity", "availability"] as const).map((n) => ( + + ))} +
+
+ +
+ +