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:
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "base-nova",
|
||||
"rsc": true,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "src/app/globals.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"iconLibrary": "lucide",
|
||||
"rtl": false,
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"menuColor": "default",
|
||||
"menuAccent": "subtle",
|
||||
"registries": {}
|
||||
}
|
||||
+121
-1
@@ -1,7 +1,34 @@
|
||||
{
|
||||
"common": {
|
||||
"appName": "ISMS-Tool",
|
||||
"logout": "Abmelden"
|
||||
"logout": "Abmelden",
|
||||
"create": "Anlegen",
|
||||
"save": "Speichern",
|
||||
"cancel": "Abbrechen",
|
||||
"edit": "Bearbeiten",
|
||||
"delete": "Löschen",
|
||||
"back": "Zurück",
|
||||
"search": "Suchen…",
|
||||
"actions": "Aktionen",
|
||||
"add": "Hinzufügen",
|
||||
"remove": "Entfernen",
|
||||
"none": "—",
|
||||
"comingSoon": "Bald verfügbar",
|
||||
"confirmDelete": "Wirklich löschen?"
|
||||
},
|
||||
"nav": {
|
||||
"dashboard": "Dashboard",
|
||||
"assetsBia": "Assets & BIA",
|
||||
"risks": "Risikoanalyse",
|
||||
"soa": "SoA & Controls",
|
||||
"measures": "Maßnahmen",
|
||||
"incidents": "Vorfälle",
|
||||
"policies": "Richtlinien",
|
||||
"chat": "ISMS-Chat",
|
||||
"dependencies": "Abhängigkeiten",
|
||||
"evidence": "Nachweise",
|
||||
"suppliers": "Lieferanten",
|
||||
"review": "Management-Review"
|
||||
},
|
||||
"login": {
|
||||
"title": "Anmelden",
|
||||
@@ -19,5 +46,98 @@
|
||||
"tenant": "Mandant",
|
||||
"roles": "Rollen",
|
||||
"placeholder": "Die Modul-Dashboards (Risiken, Aufgaben, SoA-Erfüllungsgrad) folgen in den nächsten Iterationen."
|
||||
},
|
||||
"assets": {
|
||||
"title": "Assets & BIA",
|
||||
"tabAssets": "Assets",
|
||||
"tabProcesses": "Prozesse & BIA",
|
||||
"newAsset": "Neues Asset",
|
||||
"name": "Name",
|
||||
"description": "Beschreibung",
|
||||
"type": "Typ",
|
||||
"status": "Status",
|
||||
"owner": "Owner",
|
||||
"location": "Standort",
|
||||
"tags": "Tags (kommagetrennt)",
|
||||
"protection": "Schutzbedarf",
|
||||
"confidentiality": "Vertraulichkeit",
|
||||
"integrity": "Integrität",
|
||||
"availability": "Verfügbarkeit",
|
||||
"allTypes": "Alle Typen",
|
||||
"allStatus": "Alle Status",
|
||||
"empty": "Keine Assets gefunden.",
|
||||
"detailTitle": "Asset-Details",
|
||||
"relations": "Abhängigkeiten",
|
||||
"relationHint": "Dieses Asset hängt ab von:",
|
||||
"relationReverseHint": "Von diesem Asset hängen ab:",
|
||||
"addRelation": "Abhängigkeit hinzufügen",
|
||||
"processes": "Zugeordnete Prozesse",
|
||||
"linkedRisks": "Zugeordnete Risiken",
|
||||
"linkedRisksPlaceholder": "Risiken werden mit dem Risiko-Modul (Iteration 3) verknüpft und erscheinen dann hier.",
|
||||
"editTitle": "Asset bearbeiten",
|
||||
"createTitle": "Asset anlegen",
|
||||
"deleted": "Asset gelöscht",
|
||||
"inheritedNote": "Vererbter Schutzbedarf aus Prozessen (Max-Prinzip) wird bei der BIA berücksichtigt."
|
||||
},
|
||||
"assetType": {
|
||||
"INFORMATION": "Information",
|
||||
"SYSTEM": "System",
|
||||
"APPLICATION": "Anwendung",
|
||||
"LOCATION": "Standort",
|
||||
"SUPPLIER": "Lieferant",
|
||||
"PERSON": "Person/Rolle",
|
||||
"DATA": "Datenkategorie"
|
||||
},
|
||||
"assetStatus": {
|
||||
"ACTIVE": "Aktiv",
|
||||
"PLANNED": "Geplant",
|
||||
"RETIRED": "Ausgemustert"
|
||||
},
|
||||
"processes": {
|
||||
"newProcess": "Neuer Prozess",
|
||||
"name": "Name",
|
||||
"description": "Beschreibung",
|
||||
"owner": "Owner",
|
||||
"criticality": "Kritikalität",
|
||||
"assets": "Zugeordnete Assets",
|
||||
"empty": "Keine Prozesse gefunden.",
|
||||
"detailTitle": "Prozess-Details",
|
||||
"createTitle": "Prozess anlegen",
|
||||
"editTitle": "Prozess bearbeiten",
|
||||
"primaryAssets": "Primäre Assets",
|
||||
"primaryHint": "Das im Prozess erzeugte/verantwortete Ergebnis-Asset.",
|
||||
"secondaryAssets": "Sekundäre Assets",
|
||||
"secondaryHint": "Unterstützende Assets (Systeme, Anwendungen, Personen, Lieferanten).",
|
||||
"assignAsset": "Asset zuordnen",
|
||||
"role": "Rolle",
|
||||
"linkedRisks": "Zugeordnete Risiken",
|
||||
"linkedRisksPlaceholder": "Risiken werden mit dem Risiko-Modul (Iteration 3) verknüpft und erscheinen dann hier.",
|
||||
"bia": "Business Impact Analyse",
|
||||
"rto": "RTO (Stunden)",
|
||||
"rpo": "RPO (Stunden)",
|
||||
"mtd": "MTD/MTPD (Stunden)",
|
||||
"rtoLong": "Recovery Time Objective — max. Zeit bis zur Wiederherstellung",
|
||||
"rpoLong": "Recovery Point Objective — max. tolerierbarer Datenverlust",
|
||||
"mtdLong": "Maximum Tolerable Downtime — max. tolerierbarer Ausfall",
|
||||
"impact": "Schadenshöhe je Schutzziel (1–4)",
|
||||
"notes": "Anmerkungen / Schadensszenarien",
|
||||
"biaSaved": "BIA gespeichert",
|
||||
"noBia": "Noch keine BIA erfasst."
|
||||
},
|
||||
"processRole": {
|
||||
"PRIMARY": "Primär",
|
||||
"SECONDARY": "Sekundär"
|
||||
},
|
||||
"criticality": {
|
||||
"1": "Niedrig",
|
||||
"2": "Mittel",
|
||||
"3": "Hoch",
|
||||
"4": "Sehr hoch"
|
||||
},
|
||||
"protectionLevel": {
|
||||
"1": "Normal",
|
||||
"2": "Erhöht",
|
||||
"3": "Hoch",
|
||||
"4": "Sehr hoch"
|
||||
}
|
||||
}
|
||||
|
||||
+121
-1
@@ -1,7 +1,34 @@
|
||||
{
|
||||
"common": {
|
||||
"appName": "ISMS Tool",
|
||||
"logout": "Sign out"
|
||||
"logout": "Sign out",
|
||||
"create": "Create",
|
||||
"save": "Save",
|
||||
"cancel": "Cancel",
|
||||
"edit": "Edit",
|
||||
"delete": "Delete",
|
||||
"back": "Back",
|
||||
"search": "Search…",
|
||||
"actions": "Actions",
|
||||
"add": "Add",
|
||||
"remove": "Remove",
|
||||
"none": "—",
|
||||
"comingSoon": "Coming soon",
|
||||
"confirmDelete": "Really delete?"
|
||||
},
|
||||
"nav": {
|
||||
"dashboard": "Dashboard",
|
||||
"assetsBia": "Assets & BIA",
|
||||
"risks": "Risk analysis",
|
||||
"soa": "SoA & controls",
|
||||
"measures": "Measures",
|
||||
"incidents": "Incidents",
|
||||
"policies": "Policies",
|
||||
"chat": "ISMS chat",
|
||||
"dependencies": "Dependencies",
|
||||
"evidence": "Evidence",
|
||||
"suppliers": "Suppliers",
|
||||
"review": "Management review"
|
||||
},
|
||||
"login": {
|
||||
"title": "Sign in",
|
||||
@@ -19,5 +46,98 @@
|
||||
"tenant": "Tenant",
|
||||
"roles": "Roles",
|
||||
"placeholder": "Module dashboards (risks, tasks, SoA coverage) follow in upcoming iterations."
|
||||
},
|
||||
"assets": {
|
||||
"title": "Assets & BIA",
|
||||
"tabAssets": "Assets",
|
||||
"tabProcesses": "Processes & BIA",
|
||||
"newAsset": "New asset",
|
||||
"name": "Name",
|
||||
"description": "Description",
|
||||
"type": "Type",
|
||||
"status": "Status",
|
||||
"owner": "Owner",
|
||||
"location": "Location",
|
||||
"tags": "Tags (comma-separated)",
|
||||
"protection": "Protection needs",
|
||||
"confidentiality": "Confidentiality",
|
||||
"integrity": "Integrity",
|
||||
"availability": "Availability",
|
||||
"allTypes": "All types",
|
||||
"allStatus": "All statuses",
|
||||
"empty": "No assets found.",
|
||||
"detailTitle": "Asset details",
|
||||
"relations": "Dependencies",
|
||||
"relationHint": "This asset depends on:",
|
||||
"relationReverseHint": "Depending on this asset:",
|
||||
"addRelation": "Add dependency",
|
||||
"processes": "Assigned processes",
|
||||
"linkedRisks": "Assigned risks",
|
||||
"linkedRisksPlaceholder": "Risks will be linked with the risk module (iteration 3) and appear here.",
|
||||
"editTitle": "Edit asset",
|
||||
"createTitle": "Create asset",
|
||||
"deleted": "Asset deleted",
|
||||
"inheritedNote": "Inherited protection needs from processes (max principle) are considered in the BIA."
|
||||
},
|
||||
"assetType": {
|
||||
"INFORMATION": "Information",
|
||||
"SYSTEM": "System",
|
||||
"APPLICATION": "Application",
|
||||
"LOCATION": "Location",
|
||||
"SUPPLIER": "Supplier",
|
||||
"PERSON": "Person/role",
|
||||
"DATA": "Data category"
|
||||
},
|
||||
"assetStatus": {
|
||||
"ACTIVE": "Active",
|
||||
"PLANNED": "Planned",
|
||||
"RETIRED": "Retired"
|
||||
},
|
||||
"processes": {
|
||||
"newProcess": "New process",
|
||||
"name": "Name",
|
||||
"description": "Description",
|
||||
"owner": "Owner",
|
||||
"criticality": "Criticality",
|
||||
"assets": "Assigned assets",
|
||||
"empty": "No processes found.",
|
||||
"detailTitle": "Process details",
|
||||
"createTitle": "Create process",
|
||||
"editTitle": "Edit process",
|
||||
"primaryAssets": "Primary assets",
|
||||
"primaryHint": "The result asset produced/owned by this process.",
|
||||
"secondaryAssets": "Secondary assets",
|
||||
"secondaryHint": "Supporting assets (systems, applications, people, suppliers).",
|
||||
"assignAsset": "Assign asset",
|
||||
"role": "Role",
|
||||
"linkedRisks": "Assigned risks",
|
||||
"linkedRisksPlaceholder": "Risks will be linked with the risk module (iteration 3) and appear here.",
|
||||
"bia": "Business impact analysis",
|
||||
"rto": "RTO (hours)",
|
||||
"rpo": "RPO (hours)",
|
||||
"mtd": "MTD/MTPD (hours)",
|
||||
"rtoLong": "Recovery Time Objective — max. time to recovery",
|
||||
"rpoLong": "Recovery Point Objective — max. tolerable data loss",
|
||||
"mtdLong": "Maximum Tolerable Downtime — max. tolerable outage",
|
||||
"impact": "Damage level per protection goal (1–4)",
|
||||
"notes": "Notes / damage scenarios",
|
||||
"biaSaved": "BIA saved",
|
||||
"noBia": "No BIA recorded yet."
|
||||
},
|
||||
"processRole": {
|
||||
"PRIMARY": "Primary",
|
||||
"SECONDARY": "Secondary"
|
||||
},
|
||||
"criticality": {
|
||||
"1": "Low",
|
||||
"2": "Medium",
|
||||
"3": "High",
|
||||
"4": "Very high"
|
||||
},
|
||||
"protectionLevel": {
|
||||
"1": "Normal",
|
||||
"2": "Elevated",
|
||||
"3": "High",
|
||||
"4": "Very high"
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+2889
-94
File diff suppressed because it is too large
Load Diff
@@ -9,15 +9,22 @@
|
||||
"lint": "eslint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@base-ui/react": "^1.6.0",
|
||||
"@node-rs/argon2": "^2.0.2",
|
||||
"@prisma/adapter-pg": "^7.8.0",
|
||||
"@prisma/client": "^7.8.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"dotenv": "^17.4.2",
|
||||
"lucide-react": "^1.23.0",
|
||||
"next": "16.2.10",
|
||||
"next-auth": "^5.0.0-beta.31",
|
||||
"next-intl": "^4.13.1",
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4",
|
||||
"shadcn": "^4.12.0",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "AssetType" AS ENUM ('INFORMATION', 'SYSTEM', 'APPLICATION', 'LOCATION', 'SUPPLIER', 'PERSON', 'DATA');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "AssetStatus" AS ENUM ('ACTIVE', 'PLANNED', 'RETIRED');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "ProcessAssetRole" AS ENUM ('PRIMARY', 'SECONDARY');
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "assets" (
|
||||
"id" TEXT NOT NULL,
|
||||
"tenant_id" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"description" TEXT,
|
||||
"type" "AssetType" NOT NULL,
|
||||
"status" "AssetStatus" NOT NULL DEFAULT 'ACTIVE',
|
||||
"owner_id" TEXT,
|
||||
"location" TEXT,
|
||||
"tags" TEXT[] DEFAULT ARRAY[]::TEXT[],
|
||||
"confidentiality" INTEGER NOT NULL DEFAULT 1,
|
||||
"integrity" INTEGER NOT NULL DEFAULT 1,
|
||||
"availability" INTEGER NOT NULL DEFAULT 1,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
"created_by" TEXT,
|
||||
|
||||
CONSTRAINT "assets_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "asset_relations" (
|
||||
"id" TEXT NOT NULL,
|
||||
"tenant_id" TEXT NOT NULL,
|
||||
"asset_id" TEXT NOT NULL,
|
||||
"related_asset_id" TEXT NOT NULL,
|
||||
"type" TEXT NOT NULL DEFAULT 'depends_on',
|
||||
|
||||
CONSTRAINT "asset_relations_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "processes" (
|
||||
"id" TEXT NOT NULL,
|
||||
"tenant_id" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"description" TEXT,
|
||||
"owner_id" TEXT,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
"created_by" TEXT,
|
||||
|
||||
CONSTRAINT "processes_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "process_assets" (
|
||||
"id" TEXT NOT NULL,
|
||||
"tenant_id" TEXT NOT NULL,
|
||||
"process_id" TEXT NOT NULL,
|
||||
"asset_id" TEXT NOT NULL,
|
||||
"role" "ProcessAssetRole" NOT NULL DEFAULT 'SECONDARY',
|
||||
|
||||
CONSTRAINT "process_assets_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "bia_entries" (
|
||||
"id" TEXT NOT NULL,
|
||||
"tenant_id" TEXT NOT NULL,
|
||||
"process_id" TEXT NOT NULL,
|
||||
"rto_hours" INTEGER,
|
||||
"rpo_hours" INTEGER,
|
||||
"mtd_hours" INTEGER,
|
||||
"impact_c" INTEGER NOT NULL DEFAULT 1,
|
||||
"impact_i" INTEGER NOT NULL DEFAULT 1,
|
||||
"impact_a" INTEGER NOT NULL DEFAULT 1,
|
||||
"criticality" INTEGER NOT NULL DEFAULT 1,
|
||||
"notes" TEXT,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "bia_entries_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "assets_tenant_id_idx" ON "assets"("tenant_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "assets_tenant_id_type_idx" ON "assets"("tenant_id", "type");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "asset_relations_tenant_id_idx" ON "asset_relations"("tenant_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "asset_relations_asset_id_related_asset_id_type_key" ON "asset_relations"("asset_id", "related_asset_id", "type");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "processes_tenant_id_idx" ON "processes"("tenant_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "process_assets_tenant_id_idx" ON "process_assets"("tenant_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "process_assets_process_id_asset_id_key" ON "process_assets"("process_id", "asset_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "bia_entries_process_id_key" ON "bia_entries"("process_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "bia_entries_tenant_id_idx" ON "bia_entries"("tenant_id");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "assets" ADD CONSTRAINT "assets_owner_id_fkey" FOREIGN KEY ("owner_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "asset_relations" ADD CONSTRAINT "asset_relations_asset_id_fkey" FOREIGN KEY ("asset_id") REFERENCES "assets"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "asset_relations" ADD CONSTRAINT "asset_relations_related_asset_id_fkey" FOREIGN KEY ("related_asset_id") REFERENCES "assets"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "processes" ADD CONSTRAINT "processes_owner_id_fkey" FOREIGN KEY ("owner_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "process_assets" ADD CONSTRAINT "process_assets_process_id_fkey" FOREIGN KEY ("process_id") REFERENCES "processes"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "process_assets" ADD CONSTRAINT "process_assets_asset_id_fkey" FOREIGN KEY ("asset_id") REFERENCES "assets"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "bia_entries" ADD CONSTRAINT "bia_entries_process_id_fkey" FOREIGN KEY ("process_id") REFERENCES "processes"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,18 @@
|
||||
-- RLS-Policies für die Assets-&-BIA-Tabellen (analog zur Migration row_level_security)
|
||||
|
||||
ALTER TABLE "assets" ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE "asset_relations" ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE "processes" ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE "process_assets" ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE "bia_entries" ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
CREATE POLICY tenant_isolation ON "assets"
|
||||
USING ("tenant_id" = current_setting('app.tenant_id', true));
|
||||
CREATE POLICY tenant_isolation ON "asset_relations"
|
||||
USING ("tenant_id" = current_setting('app.tenant_id', true));
|
||||
CREATE POLICY tenant_isolation ON "processes"
|
||||
USING ("tenant_id" = current_setting('app.tenant_id', true));
|
||||
CREATE POLICY tenant_isolation ON "process_assets"
|
||||
USING ("tenant_id" = current_setting('app.tenant_id', true));
|
||||
CREATE POLICY tenant_isolation ON "bia_entries"
|
||||
USING ("tenant_id" = current_setting('app.tenant_id', true));
|
||||
+128
-2
@@ -56,8 +56,10 @@ model User {
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
tenant Tenant @relation(fields: [tenantId], references: [id])
|
||||
userRoles UserRole[]
|
||||
tenant Tenant @relation(fields: [tenantId], references: [id])
|
||||
userRoles UserRole[]
|
||||
ownedAssets Asset[] @relation("assetOwner")
|
||||
ownedProcesses Process[] @relation("processOwner")
|
||||
|
||||
@@unique([tenantId, email])
|
||||
@@index([tenantId])
|
||||
@@ -110,6 +112,130 @@ model RolePermission {
|
||||
@@map("role_permissions")
|
||||
}
|
||||
|
||||
// ── Assets & BIA (ein Modul, SPEC §4.1) ─────────────────────────────────────
|
||||
|
||||
enum AssetType {
|
||||
INFORMATION
|
||||
SYSTEM
|
||||
APPLICATION
|
||||
LOCATION
|
||||
SUPPLIER
|
||||
PERSON
|
||||
DATA
|
||||
}
|
||||
|
||||
enum AssetStatus {
|
||||
ACTIVE
|
||||
PLANNED
|
||||
RETIRED
|
||||
}
|
||||
|
||||
enum ProcessAssetRole {
|
||||
PRIMARY
|
||||
SECONDARY
|
||||
}
|
||||
|
||||
model Asset {
|
||||
id String @id @default(cuid())
|
||||
tenantId String @map("tenant_id")
|
||||
name String
|
||||
description String?
|
||||
type AssetType
|
||||
status AssetStatus @default(ACTIVE)
|
||||
ownerId String? @map("owner_id")
|
||||
location String?
|
||||
tags String[] @default([])
|
||||
// Schutzbedarf 1–4 je Schutzziel (C/I/A)
|
||||
confidentiality Int @default(1)
|
||||
integrity Int @default(1)
|
||||
availability Int @default(1)
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
createdBy String? @map("created_by")
|
||||
|
||||
owner User? @relation("assetOwner", fields: [ownerId], references: [id])
|
||||
relationsFrom AssetRelation[] @relation("relationFrom")
|
||||
relationsTo AssetRelation[] @relation("relationTo")
|
||||
processAssets ProcessAsset[]
|
||||
|
||||
@@index([tenantId])
|
||||
@@index([tenantId, type])
|
||||
@@map("assets")
|
||||
}
|
||||
|
||||
model AssetRelation {
|
||||
id String @id @default(cuid())
|
||||
tenantId String @map("tenant_id")
|
||||
assetId String @map("asset_id")
|
||||
relatedAssetId String @map("related_asset_id")
|
||||
// z. B. depends_on, hosts, processes_data_of
|
||||
type String @default("depends_on")
|
||||
|
||||
asset Asset @relation("relationFrom", fields: [assetId], references: [id], onDelete: Cascade)
|
||||
relatedAsset Asset @relation("relationTo", fields: [relatedAssetId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([assetId, relatedAssetId, type])
|
||||
@@index([tenantId])
|
||||
@@map("asset_relations")
|
||||
}
|
||||
|
||||
model Process {
|
||||
id String @id @default(cuid())
|
||||
tenantId String @map("tenant_id")
|
||||
name String
|
||||
description String?
|
||||
ownerId String? @map("owner_id")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
createdBy String? @map("created_by")
|
||||
|
||||
owner User? @relation("processOwner", fields: [ownerId], references: [id])
|
||||
processAssets ProcessAsset[]
|
||||
bia BiaEntry?
|
||||
|
||||
@@index([tenantId])
|
||||
@@map("processes")
|
||||
}
|
||||
|
||||
model ProcessAsset {
|
||||
id String @id @default(cuid())
|
||||
tenantId String @map("tenant_id")
|
||||
processId String @map("process_id")
|
||||
assetId String @map("asset_id")
|
||||
role ProcessAssetRole @default(SECONDARY)
|
||||
|
||||
process Process @relation(fields: [processId], references: [id], onDelete: Cascade)
|
||||
asset Asset @relation(fields: [assetId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([processId, assetId])
|
||||
@@index([tenantId])
|
||||
@@map("process_assets")
|
||||
}
|
||||
|
||||
model BiaEntry {
|
||||
id String @id @default(cuid())
|
||||
tenantId String @map("tenant_id")
|
||||
processId String @unique @map("process_id")
|
||||
// Wiederanlaufparameter in Stunden (SPEC §4.1.2)
|
||||
rtoHours Int? @map("rto_hours")
|
||||
rpoHours Int? @map("rpo_hours")
|
||||
mtdHours Int? @map("mtd_hours")
|
||||
// Schadenshöhe 1–4 je Schutzziel
|
||||
impactC Int @default(1) @map("impact_c")
|
||||
impactI Int @default(1) @map("impact_i")
|
||||
impactA Int @default(1) @map("impact_a")
|
||||
// Kritikalität 1–4, abgeleitet (Max-Prinzip), redundant gespeichert für Listen/Filter
|
||||
criticality Int @default(1)
|
||||
notes String?
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
process Process @relation(fields: [processId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([tenantId])
|
||||
@@map("bia_entries")
|
||||
}
|
||||
|
||||
model AuditLog {
|
||||
id String @id @default(cuid())
|
||||
tenantId String @map("tenant_id")
|
||||
|
||||
+114
@@ -81,6 +81,120 @@ async function main() {
|
||||
}
|
||||
}
|
||||
console.log(`✔ ${demoUsers.length} Demo-Nutzer (Passwort: ${DEMO_PASSWORD})`);
|
||||
|
||||
// 5. Beispiel-Assets, -Prozesse und BIA (nur wenn noch keine Assets existieren)
|
||||
const assetCount = await prisma.asset.count({ where: { tenantId: tenant.id } });
|
||||
if (assetCount === 0) {
|
||||
const isb = await prisma.user.findUniqueOrThrow({
|
||||
where: { tenantId_email: { tenantId: tenant.id, email: "admin@demo.example" } },
|
||||
});
|
||||
const owner = await prisma.user.findUniqueOrThrow({
|
||||
where: { tenantId_email: { tenantId: tenant.id, email: "owner@demo.example" } },
|
||||
});
|
||||
|
||||
const mk = (data: {
|
||||
name: string;
|
||||
type: "INFORMATION" | "SYSTEM" | "APPLICATION" | "LOCATION" | "SUPPLIER" | "PERSON" | "DATA";
|
||||
c: number;
|
||||
i: number;
|
||||
a: number;
|
||||
ownerId?: string;
|
||||
location?: string;
|
||||
tags?: string[];
|
||||
description?: string;
|
||||
}) =>
|
||||
prisma.asset.create({
|
||||
data: {
|
||||
tenantId: tenant.id,
|
||||
name: data.name,
|
||||
type: data.type,
|
||||
confidentiality: data.c,
|
||||
integrity: data.i,
|
||||
availability: data.a,
|
||||
ownerId: data.ownerId,
|
||||
location: data.location,
|
||||
tags: data.tags ?? [],
|
||||
description: data.description,
|
||||
},
|
||||
});
|
||||
|
||||
const erp = await mk({ name: "ERP-System", type: "SYSTEM", c: 3, i: 4, a: 3, ownerId: owner.id, location: "RZ Frankfurt", tags: ["kritisch", "kern"] });
|
||||
const crm = await mk({ name: "Kundendatenbank", type: "DATA", c: 4, i: 3, a: 2, ownerId: isb.id, tags: ["dsgvo"] });
|
||||
const auftraege = await mk({ name: "Auftragsdaten", type: "INFORMATION", c: 3, i: 4, a: 3, ownerId: owner.id });
|
||||
const hoster = await mk({ name: "Cloud-Hoster (IaaS)", type: "SUPPLIER", c: 2, i: 3, a: 4, description: "Betreibt das Rechenzentrum für ERP und CRM." });
|
||||
const buero = await mk({ name: "Bürostandort München", type: "LOCATION", c: 2, i: 2, a: 2 });
|
||||
const itTeam = await mk({ name: "IT-Administration", type: "PERSON", c: 3, i: 3, a: 3 });
|
||||
|
||||
await prisma.assetRelation.createMany({
|
||||
data: [
|
||||
{ tenantId: tenant.id, assetId: erp.id, relatedAssetId: hoster.id, type: "depends_on" },
|
||||
{ tenantId: tenant.id, assetId: crm.id, relatedAssetId: hoster.id, type: "depends_on" },
|
||||
{ tenantId: tenant.id, assetId: erp.id, relatedAssetId: itTeam.id, type: "depends_on" },
|
||||
],
|
||||
});
|
||||
|
||||
const auftrag = await prisma.process.create({
|
||||
data: {
|
||||
tenantId: tenant.id,
|
||||
name: "Auftragsabwicklung",
|
||||
description: "Vom Kundenauftrag bis zur Auslieferung.",
|
||||
ownerId: owner.id,
|
||||
},
|
||||
});
|
||||
await prisma.processAsset.createMany({
|
||||
data: [
|
||||
{ tenantId: tenant.id, processId: auftrag.id, assetId: auftraege.id, role: "PRIMARY" },
|
||||
{ tenantId: tenant.id, processId: auftrag.id, assetId: erp.id, role: "SECONDARY" },
|
||||
{ tenantId: tenant.id, processId: auftrag.id, assetId: crm.id, role: "SECONDARY" },
|
||||
{ tenantId: tenant.id, processId: auftrag.id, assetId: itTeam.id, role: "SECONDARY" },
|
||||
],
|
||||
});
|
||||
await prisma.biaEntry.create({
|
||||
data: {
|
||||
tenantId: tenant.id,
|
||||
processId: auftrag.id,
|
||||
rtoHours: 8,
|
||||
rpoHours: 4,
|
||||
mtdHours: 24,
|
||||
impactC: 2,
|
||||
impactI: 4,
|
||||
impactA: 3,
|
||||
criticality: 4,
|
||||
notes: "Ausfall > 1 Tag führt zu Lieferverzug und Vertragsstrafen.",
|
||||
},
|
||||
});
|
||||
|
||||
const vertrieb = await prisma.process.create({
|
||||
data: {
|
||||
tenantId: tenant.id,
|
||||
name: "Kundenbetreuung",
|
||||
description: "Anfragen, Angebote, Support.",
|
||||
ownerId: isb.id,
|
||||
},
|
||||
});
|
||||
await prisma.processAsset.createMany({
|
||||
data: [
|
||||
{ tenantId: tenant.id, processId: vertrieb.id, assetId: crm.id, role: "PRIMARY" },
|
||||
{ tenantId: tenant.id, processId: vertrieb.id, assetId: buero.id, role: "SECONDARY" },
|
||||
],
|
||||
});
|
||||
await prisma.biaEntry.create({
|
||||
data: {
|
||||
tenantId: tenant.id,
|
||||
processId: vertrieb.id,
|
||||
rtoHours: 24,
|
||||
rpoHours: 24,
|
||||
mtdHours: 72,
|
||||
impactC: 3,
|
||||
impactI: 2,
|
||||
impactA: 2,
|
||||
criticality: 3,
|
||||
notes: "Kundenkommunikation kann kurzfristig über Ausweichkanäle laufen.",
|
||||
},
|
||||
});
|
||||
|
||||
console.log("✔ Beispiel-Assets, -Prozesse und BIA angelegt");
|
||||
}
|
||||
}
|
||||
|
||||
main()
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { requireSession } from "@/server/auth";
|
||||
import { dbForTenant } from "@/server/db";
|
||||
import { requirePermission } from "@/server/rbac";
|
||||
import { updateAsset } from "@/server/actions/assets";
|
||||
import { AssetForm } from "@/components/asset-form";
|
||||
|
||||
export default async function EditAssetPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
const session = await requireSession();
|
||||
requirePermission(session, "asset:write");
|
||||
const t = await getTranslations("assets");
|
||||
|
||||
const { id } = await params;
|
||||
const db = dbForTenant(session.user.tenantId);
|
||||
const asset = await db.asset.findUnique({ where: { id } });
|
||||
if (!asset) notFound();
|
||||
|
||||
const users = await db.user.findMany({
|
||||
where: { status: "ACTIVE" },
|
||||
select: { id: true, name: true },
|
||||
orderBy: { name: "asc" },
|
||||
});
|
||||
|
||||
const action = updateAsset.bind(null, asset.id);
|
||||
|
||||
return (
|
||||
<main className="flex-1 p-6">
|
||||
<h1 className="text-xl font-semibold">{t("editTitle")}</h1>
|
||||
<div className="mt-6">
|
||||
<AssetForm action={action} asset={asset} users={users} cancelHref={`/assets/${asset.id}`} />
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { ArrowLeft, Pencil, Trash2, X } from "lucide-react";
|
||||
import { requireSession } from "@/server/auth";
|
||||
import { dbForTenant } from "@/server/db";
|
||||
import { hasPermission, requirePermission } from "@/server/rbac";
|
||||
import {
|
||||
addAssetRelation,
|
||||
deleteAsset,
|
||||
removeAssetRelation,
|
||||
} from "@/server/actions/assets";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { LevelBadge } from "@/components/level-badge";
|
||||
|
||||
export default async function AssetDetailPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: 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 tRole = await getTranslations("processRole");
|
||||
const tc = await getTranslations("common");
|
||||
|
||||
const { id } = await params;
|
||||
const db = dbForTenant(session.user.tenantId);
|
||||
|
||||
const asset = await db.asset.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
owner: { select: { name: 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 } } } },
|
||||
},
|
||||
});
|
||||
if (!asset) notFound();
|
||||
|
||||
const canWrite = hasPermission(session, "asset:write");
|
||||
const otherAssets = canWrite
|
||||
? await db.asset.findMany({
|
||||
where: { id: { not: asset.id } },
|
||||
select: { id: true, name: true },
|
||||
orderBy: { name: "asc" },
|
||||
})
|
||||
: [];
|
||||
|
||||
const removeAction = deleteAsset.bind(null, asset.id);
|
||||
const addRelationAction = addAssetRelation.bind(null, asset.id);
|
||||
|
||||
return (
|
||||
<main className="flex-1 p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Button variant="ghost" size="icon" nativeButton={false} render={<Link href="/assets" />}>
|
||||
<ArrowLeft className="size-4" />
|
||||
</Button>
|
||||
<h1 className="text-xl font-semibold">{asset.name}</h1>
|
||||
<Badge variant="secondary">{tType(asset.type)}</Badge>
|
||||
<Badge variant="outline">{tStatus(asset.status)}</Badge>
|
||||
</div>
|
||||
{canWrite && (
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" nativeButton={false} render={<Link href={`/assets/${asset.id}/edit`} />}>
|
||||
<Pencil className="size-4" /> {tc("edit")}
|
||||
</Button>
|
||||
<form action={removeAction}>
|
||||
<Button type="submit" variant="destructive">
|
||||
<Trash2 className="size-4" /> {tc("delete")}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-6 grid gap-4 lg:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t("detailTitle")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<dl className="grid grid-cols-[10rem_1fr] gap-2 text-sm">
|
||||
<dt className="text-muted-foreground">{t("description")}</dt>
|
||||
<dd className="whitespace-pre-wrap">{asset.description ?? tc("none")}</dd>
|
||||
<dt className="text-muted-foreground">{t("owner")}</dt>
|
||||
<dd>{asset.owner?.name ?? tc("none")}</dd>
|
||||
<dt className="text-muted-foreground">{t("location")}</dt>
|
||||
<dd>{asset.location ?? tc("none")}</dd>
|
||||
<dt className="text-muted-foreground">{t("protection")}</dt>
|
||||
<dd className="flex gap-1">
|
||||
<LevelBadge level={asset.confidentiality} prefix="C" />
|
||||
<LevelBadge level={asset.integrity} prefix="I" />
|
||||
<LevelBadge level={asset.availability} prefix="A" />
|
||||
</dd>
|
||||
<dt className="text-muted-foreground">{t("tags")}</dt>
|
||||
<dd className="flex flex-wrap gap-1">
|
||||
{asset.tags.length === 0
|
||||
? tc("none")
|
||||
: asset.tags.map((tag) => (
|
||||
<Badge key={tag} variant="secondary">
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
</dd>
|
||||
</dl>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t("linkedRisks")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground">{t("linkedRisksPlaceholder")}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t("relations")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4 text-sm">
|
||||
<div>
|
||||
<p className="mb-1 text-muted-foreground">{t("relationHint")}</p>
|
||||
{asset.relationsFrom.length === 0 && <p>{tc("none")}</p>}
|
||||
<ul className="space-y-1">
|
||||
{asset.relationsFrom.map((rel) => (
|
||||
<li key={rel.id} className="flex items-center gap-2">
|
||||
<Link href={`/assets/${rel.relatedAsset.id}`} className="hover:underline">
|
||||
{rel.relatedAsset.name}
|
||||
</Link>
|
||||
{canWrite && (
|
||||
<form action={removeAssetRelation.bind(null, asset.id, rel.id)}>
|
||||
<button
|
||||
type="submit"
|
||||
title={tc("remove")}
|
||||
className="text-muted-foreground hover:text-destructive"
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<p className="mb-1 text-muted-foreground">{t("relationReverseHint")}</p>
|
||||
{asset.relationsTo.length === 0 && <p>{tc("none")}</p>}
|
||||
<ul className="space-y-1">
|
||||
{asset.relationsTo.map((rel) => (
|
||||
<li key={rel.id}>
|
||||
<Link href={`/assets/${rel.asset.id}`} className="hover:underline">
|
||||
{rel.asset.name}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
{canWrite && otherAssets.length > 0 && (
|
||||
<form action={addRelationAction} className="flex gap-2 border-t pt-3">
|
||||
<select
|
||||
name="relatedAssetId"
|
||||
required
|
||||
className="h-9 flex-1 rounded-md border border-input bg-transparent px-3 text-sm"
|
||||
>
|
||||
{otherAssets.map((a) => (
|
||||
<option key={a.id} value={a.id}>
|
||||
{a.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<Button type="submit" variant="secondary" size="sm">
|
||||
{t("addRelation")}
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t("processes")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="text-sm">
|
||||
{asset.processAssets.length === 0 && <p>{tc("none")}</p>}
|
||||
<ul className="space-y-1">
|
||||
{asset.processAssets.map((pa) => (
|
||||
<li key={pa.id} className="flex items-center gap-2">
|
||||
<Link href={`/processes/${pa.process.id}`} className="hover:underline">
|
||||
{pa.process.name}
|
||||
</Link>
|
||||
<Badge variant={pa.role === "PRIMARY" ? "default" : "secondary"}>
|
||||
{tRole(pa.role)}
|
||||
</Badge>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<p className="mt-3 text-xs text-muted-foreground">{t("inheritedNote")}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { requireSession } from "@/server/auth";
|
||||
import { dbForTenant } from "@/server/db";
|
||||
import { requirePermission } from "@/server/rbac";
|
||||
import { createAsset } from "@/server/actions/assets";
|
||||
import { AssetForm } from "@/components/asset-form";
|
||||
|
||||
export default async function NewAssetPage() {
|
||||
const session = await requireSession();
|
||||
requirePermission(session, "asset:write");
|
||||
const t = await getTranslations("assets");
|
||||
|
||||
const db = dbForTenant(session.user.tenantId);
|
||||
const users = await db.user.findMany({
|
||||
where: { status: "ACTIVE" },
|
||||
select: { id: true, name: true },
|
||||
orderBy: { name: "asc" },
|
||||
});
|
||||
|
||||
return (
|
||||
<main className="flex-1 p-6">
|
||||
<h1 className="text-xl font-semibold">{t("createTitle")}</h1>
|
||||
<div className="mt-6">
|
||||
<AssetForm action={createAsset} users={users} cancelHref="/assets" />
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { requireSession } from "@/server/auth";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
|
||||
export default async function DashboardPage() {
|
||||
const session = await requireSession();
|
||||
const t = await getTranslations("dashboard");
|
||||
|
||||
return (
|
||||
<main className="flex-1 p-6">
|
||||
<h1 className="text-xl font-semibold">{t("title")}</h1>
|
||||
<Card className="mt-6 max-w-2xl">
|
||||
<CardHeader>
|
||||
<CardTitle>{t("welcome", { name: session.user.name ?? "" })}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<dl className="grid grid-cols-2 gap-2 text-sm">
|
||||
<dt className="text-muted-foreground">{t("tenant")}</dt>
|
||||
<dd>{session.user.tenantSlug}</dd>
|
||||
<dt className="text-muted-foreground">{t("roles")}</dt>
|
||||
<dd>{session.user.roles.join(", ")}</dd>
|
||||
</dl>
|
||||
<p className="mt-6 text-sm text-muted-foreground">{t("placeholder")}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import {
|
||||
LayoutDashboard,
|
||||
Boxes,
|
||||
ShieldAlert,
|
||||
ClipboardCheck,
|
||||
KanbanSquare,
|
||||
Siren,
|
||||
BookOpenText,
|
||||
MessagesSquare,
|
||||
Network,
|
||||
FolderCheck,
|
||||
Truck,
|
||||
LineChart,
|
||||
} from "lucide-react";
|
||||
import { auth, signOut } from "@/server/auth";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { NavLink } from "@/components/nav-link";
|
||||
|
||||
export default async function AppLayout({
|
||||
children,
|
||||
}: Readonly<{ children: React.ReactNode }>) {
|
||||
const session = await auth();
|
||||
if (!session?.user) redirect("/login");
|
||||
|
||||
const t = await getTranslations("nav");
|
||||
const tc = await getTranslations("common");
|
||||
|
||||
const nav = [
|
||||
{ href: "/dashboard", label: t("dashboard"), icon: LayoutDashboard, enabled: true },
|
||||
{ href: "/assets", label: t("assetsBia"), icon: Boxes, enabled: true, match: ["/assets", "/processes"] },
|
||||
{ href: "/risks", label: t("risks"), icon: ShieldAlert, enabled: false },
|
||||
{ href: "/soa", label: t("soa"), icon: ClipboardCheck, enabled: false },
|
||||
{ href: "/measures", label: t("measures"), icon: KanbanSquare, enabled: false },
|
||||
{ href: "/incidents", label: t("incidents"), icon: Siren, enabled: false },
|
||||
{ href: "/policies", label: t("policies"), icon: BookOpenText, enabled: false },
|
||||
{ href: "/chat", label: t("chat"), icon: MessagesSquare, enabled: false },
|
||||
{ href: "/dependencies", label: t("dependencies"), icon: Network, enabled: false },
|
||||
{ href: "/evidence", label: t("evidence"), icon: FolderCheck, enabled: false },
|
||||
{ href: "/suppliers", label: t("suppliers"), icon: Truck, enabled: false },
|
||||
{ href: "/review", label: t("review"), icon: LineChart, enabled: false },
|
||||
];
|
||||
|
||||
async function logout() {
|
||||
"use server";
|
||||
await signOut({ redirectTo: "/login" });
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen">
|
||||
<aside className="flex w-60 shrink-0 flex-col border-r bg-sidebar">
|
||||
<div className="flex h-14 items-center border-b px-4">
|
||||
<Link href="/dashboard" className="font-semibold tracking-tight">
|
||||
{tc("appName")}
|
||||
</Link>
|
||||
</div>
|
||||
<nav className="flex-1 space-y-0.5 overflow-y-auto p-2">
|
||||
{nav.map((item) =>
|
||||
item.enabled ? (
|
||||
<NavLink key={item.href} href={item.href} match={item.match}>
|
||||
<item.icon className="size-4" />
|
||||
{item.label}
|
||||
</NavLink>
|
||||
) : (
|
||||
<span
|
||||
key={item.href}
|
||||
title={tc("comingSoon")}
|
||||
className="flex cursor-not-allowed items-center gap-2 rounded-md px-3 py-2 text-sm text-muted-foreground/50"
|
||||
>
|
||||
<item.icon className="size-4" />
|
||||
{item.label}
|
||||
</span>
|
||||
)
|
||||
)}
|
||||
</nav>
|
||||
<div className="border-t p-3 text-xs text-muted-foreground">
|
||||
<p className="truncate font-medium text-foreground">{session.user.name}</p>
|
||||
<p className="truncate">{session.user.tenantSlug}</p>
|
||||
<form action={logout} className="mt-2">
|
||||
<Button type="submit" variant="outline" size="sm" className="w-full">
|
||||
{tc("logout")}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
</aside>
|
||||
<div className="flex min-w-0 flex-1 flex-col">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { requireSession } from "@/server/auth";
|
||||
import { dbForTenant } from "@/server/db";
|
||||
import { requirePermission } from "@/server/rbac";
|
||||
import { updateProcess } from "@/server/actions/processes";
|
||||
import { ProcessForm } from "@/components/process-form";
|
||||
|
||||
export default async function EditProcessPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
const session = await requireSession();
|
||||
requirePermission(session, "bia:write");
|
||||
const t = await getTranslations("processes");
|
||||
|
||||
const { id } = await params;
|
||||
const db = dbForTenant(session.user.tenantId);
|
||||
const process = await db.process.findUnique({ where: { id } });
|
||||
if (!process) notFound();
|
||||
|
||||
const users = await db.user.findMany({
|
||||
where: { status: "ACTIVE" },
|
||||
select: { id: true, name: true },
|
||||
orderBy: { name: "asc" },
|
||||
});
|
||||
|
||||
const action = updateProcess.bind(null, process.id);
|
||||
|
||||
return (
|
||||
<main className="flex-1 p-6">
|
||||
<h1 className="text-xl font-semibold">{t("editTitle")}</h1>
|
||||
<div className="mt-6">
|
||||
<ProcessForm
|
||||
action={action}
|
||||
process={process}
|
||||
users={users}
|
||||
cancelHref={`/processes/${process.id}`}
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { ArrowLeft, Pencil, Trash2, X } from "lucide-react";
|
||||
import { requireSession } from "@/server/auth";
|
||||
import { dbForTenant } from "@/server/db";
|
||||
import { hasPermission, requirePermission } from "@/server/rbac";
|
||||
import {
|
||||
assignAsset,
|
||||
deleteProcess,
|
||||
saveBia,
|
||||
unassignAsset,
|
||||
} from "@/server/actions/processes";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { LevelBadge } from "@/components/level-badge";
|
||||
|
||||
const LEVELS = [1, 2, 3, 4] as const;
|
||||
|
||||
export default async function ProcessDetailPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
const session = await requireSession();
|
||||
requirePermission(session, "bia:read");
|
||||
const t = await getTranslations("processes");
|
||||
const tAssets = await getTranslations("assets");
|
||||
const tRole = await getTranslations("processRole");
|
||||
const tCrit = await getTranslations("criticality");
|
||||
const tc = await getTranslations("common");
|
||||
|
||||
const { id } = await params;
|
||||
const db = dbForTenant(session.user.tenantId);
|
||||
|
||||
const process = await db.process.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
owner: { select: { name: true } },
|
||||
bia: true,
|
||||
processAssets: {
|
||||
include: {
|
||||
asset: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
confidentiality: true,
|
||||
integrity: true,
|
||||
availability: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { role: "asc" },
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!process) notFound();
|
||||
|
||||
const canWrite = hasPermission(session, "bia:write");
|
||||
const assignedIds = process.processAssets.map((pa) => pa.assetId);
|
||||
const availableAssets = canWrite
|
||||
? await db.asset.findMany({
|
||||
where: { id: { notIn: assignedIds } },
|
||||
select: { id: true, name: true },
|
||||
orderBy: { name: "asc" },
|
||||
})
|
||||
: [];
|
||||
|
||||
const primary = process.processAssets.filter((pa) => pa.role === "PRIMARY");
|
||||
const secondary = process.processAssets.filter((pa) => pa.role === "SECONDARY");
|
||||
|
||||
const selectClass =
|
||||
"h-9 rounded-md border border-input bg-transparent px-3 text-sm";
|
||||
const bia = process.bia;
|
||||
|
||||
const assetList = (items: typeof primary) => {
|
||||
return (
|
||||
<ul className="space-y-1">
|
||||
{items.map((pa) => (
|
||||
<li key={pa.id} className="flex items-center gap-2">
|
||||
<Link href={`/assets/${pa.asset.id}`} className="hover:underline">
|
||||
{pa.asset.name}
|
||||
</Link>
|
||||
<span className="flex gap-1">
|
||||
<LevelBadge level={pa.asset.confidentiality} prefix="C" />
|
||||
<LevelBadge level={pa.asset.integrity} prefix="I" />
|
||||
<LevelBadge level={pa.asset.availability} prefix="A" />
|
||||
</span>
|
||||
{canWrite && (
|
||||
<form action={unassignAsset.bind(null, process!.id, pa.id)}>
|
||||
<button
|
||||
type="submit"
|
||||
title={tc("remove")}
|
||||
className="text-muted-foreground hover:text-destructive"
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="flex-1 p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Button variant="ghost" size="icon" nativeButton={false} render={<Link href="/processes" />}>
|
||||
<ArrowLeft className="size-4" />
|
||||
</Button>
|
||||
<h1 className="text-xl font-semibold">{process.name}</h1>
|
||||
{bia && (
|
||||
<LevelBadge level={bia.criticality} label={tCrit(String(bia.criticality))} />
|
||||
)}
|
||||
</div>
|
||||
{canWrite && (
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" nativeButton={false} render={<Link href={`/processes/${process.id}/edit`} />}>
|
||||
<Pencil className="size-4" /> {tc("edit")}
|
||||
</Button>
|
||||
<form action={deleteProcess.bind(null, process.id)}>
|
||||
<Button type="submit" variant="destructive">
|
||||
<Trash2 className="size-4" /> {tc("delete")}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-6 grid gap-4 lg:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t("detailTitle")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<dl className="grid grid-cols-[10rem_1fr] gap-2 text-sm">
|
||||
<dt className="text-muted-foreground">{t("description")}</dt>
|
||||
<dd className="whitespace-pre-wrap">{process.description ?? tc("none")}</dd>
|
||||
<dt className="text-muted-foreground">{t("owner")}</dt>
|
||||
<dd>{process.owner?.name ?? tc("none")}</dd>
|
||||
</dl>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t("linkedRisks")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground">{t("linkedRisksPlaceholder")}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t("assets")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4 text-sm">
|
||||
<div>
|
||||
<p className="font-medium">{t("primaryAssets")}</p>
|
||||
<p className="mb-1 text-xs text-muted-foreground">{t("primaryHint")}</p>
|
||||
{primary.length === 0 ? <p>{tc("none")}</p> : assetList(primary)}
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium">{t("secondaryAssets")}</p>
|
||||
<p className="mb-1 text-xs text-muted-foreground">{t("secondaryHint")}</p>
|
||||
{secondary.length === 0 ? <p>{tc("none")}</p> : assetList(secondary)}
|
||||
</div>
|
||||
{canWrite && availableAssets.length > 0 && (
|
||||
<form
|
||||
action={assignAsset.bind(null, process.id)}
|
||||
className="flex flex-wrap gap-2 border-t pt-3"
|
||||
>
|
||||
<select name="assetId" required className={`${selectClass} flex-1`}>
|
||||
{availableAssets.map((a) => (
|
||||
<option key={a.id} value={a.id}>
|
||||
{a.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select name="role" defaultValue="SECONDARY" className={selectClass}>
|
||||
<option value="PRIMARY">{tRole("PRIMARY")}</option>
|
||||
<option value="SECONDARY">{tRole("SECONDARY")}</option>
|
||||
</select>
|
||||
<Button type="submit" variant="secondary" size="sm">
|
||||
{t("assignAsset")}
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t("bia")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{!canWrite && !bia && (
|
||||
<p className="text-sm text-muted-foreground">{t("noBia")}</p>
|
||||
)}
|
||||
{!canWrite && bia && (
|
||||
<dl className="grid grid-cols-[10rem_1fr] gap-2 text-sm">
|
||||
<dt className="text-muted-foreground">RTO</dt>
|
||||
<dd>{bia.rtoHours != null ? `${bia.rtoHours} h` : tc("none")}</dd>
|
||||
<dt className="text-muted-foreground">RPO</dt>
|
||||
<dd>{bia.rpoHours != null ? `${bia.rpoHours} h` : tc("none")}</dd>
|
||||
<dt className="text-muted-foreground">MTD</dt>
|
||||
<dd>{bia.mtdHours != null ? `${bia.mtdHours} h` : tc("none")}</dd>
|
||||
<dt className="text-muted-foreground">{t("impact")}</dt>
|
||||
<dd className="flex gap-1">
|
||||
<LevelBadge level={bia.impactC} prefix="C" />
|
||||
<LevelBadge level={bia.impactI} prefix="I" />
|
||||
<LevelBadge level={bia.impactA} prefix="A" />
|
||||
</dd>
|
||||
</dl>
|
||||
)}
|
||||
{canWrite && (
|
||||
<form action={saveBia.bind(null, process.id)} className="space-y-4 text-sm">
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
{(
|
||||
[
|
||||
["rtoHours", t("rto"), t("rtoLong"), bia?.rtoHours],
|
||||
["rpoHours", t("rpo"), t("rpoLong"), bia?.rpoHours],
|
||||
["mtdHours", t("mtd"), t("mtdLong"), bia?.mtdHours],
|
||||
] as const
|
||||
).map(([name, label, hint, value]) => (
|
||||
<div key={name}>
|
||||
<Label htmlFor={name} title={hint}>
|
||||
{label}
|
||||
</Label>
|
||||
<Input
|
||||
id={name}
|
||||
name={name}
|
||||
type="number"
|
||||
min={0}
|
||||
defaultValue={value ?? ""}
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<fieldset>
|
||||
<legend className="font-medium">{t("impact")}</legend>
|
||||
<div className="mt-2 grid grid-cols-3 gap-4">
|
||||
{(
|
||||
[
|
||||
["impactC", tAssets("confidentiality"), bia?.impactC],
|
||||
["impactI", tAssets("integrity"), bia?.impactI],
|
||||
["impactA", tAssets("availability"), bia?.impactA],
|
||||
] as const
|
||||
).map(([name, label, value]) => (
|
||||
<div key={name}>
|
||||
<Label htmlFor={name}>{label}</Label>
|
||||
<select
|
||||
id={name}
|
||||
name={name}
|
||||
defaultValue={value ?? 1}
|
||||
className={`${selectClass} mt-1 w-full`}
|
||||
>
|
||||
{LEVELS.map((l) => (
|
||||
<option key={l} value={l}>
|
||||
{l} — {tCrit(String(l))}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</fieldset>
|
||||
<div>
|
||||
<Label htmlFor="notes">{t("notes")}</Label>
|
||||
<Textarea
|
||||
id="notes"
|
||||
name="notes"
|
||||
rows={3}
|
||||
defaultValue={bia?.notes ?? ""}
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit">{tc("save")}</Button>
|
||||
</form>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { requireSession } from "@/server/auth";
|
||||
import { dbForTenant } from "@/server/db";
|
||||
import { requirePermission } from "@/server/rbac";
|
||||
import { createProcess } from "@/server/actions/processes";
|
||||
import { ProcessForm } from "@/components/process-form";
|
||||
|
||||
export default async function NewProcessPage() {
|
||||
const session = await requireSession();
|
||||
requirePermission(session, "bia:write");
|
||||
const t = await getTranslations("processes");
|
||||
|
||||
const db = dbForTenant(session.user.tenantId);
|
||||
const users = await db.user.findMany({
|
||||
where: { status: "ACTIVE" },
|
||||
select: { id: true, name: true },
|
||||
orderBy: { name: "asc" },
|
||||
});
|
||||
|
||||
return (
|
||||
<main className="flex-1 p-6">
|
||||
<h1 className="text-xl font-semibold">{t("createTitle")}</h1>
|
||||
<div className="mt-6">
|
||||
<ProcessForm action={createProcess} users={users} cancelHref="/processes" />
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import Link from "next/link";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { Plus } from "lucide-react";
|
||||
import { requireSession } from "@/server/auth";
|
||||
import { dbForTenant } from "@/server/db";
|
||||
import { hasPermission, requirePermission } from "@/server/rbac";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { LevelBadge } from "@/components/level-badge";
|
||||
import { ModuleTabs } from "@/components/module-tabs";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
|
||||
export default async function ProcessesPage() {
|
||||
const session = await requireSession();
|
||||
requirePermission(session, "bia:read");
|
||||
const t = await getTranslations("processes");
|
||||
const tAssets = await getTranslations("assets");
|
||||
const tCrit = await getTranslations("criticality");
|
||||
const tc = await getTranslations("common");
|
||||
|
||||
const db = dbForTenant(session.user.tenantId);
|
||||
const processes = await db.process.findMany({
|
||||
include: {
|
||||
owner: { select: { name: true } },
|
||||
bia: { select: { criticality: true, rtoHours: true, mtdHours: true } },
|
||||
_count: { select: { processAssets: true } },
|
||||
},
|
||||
orderBy: { name: "asc" },
|
||||
take: 200,
|
||||
});
|
||||
|
||||
const canWrite = hasPermission(session, "bia:write");
|
||||
|
||||
return (
|
||||
<main className="flex-1 p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-xl font-semibold">{tAssets("title")}</h1>
|
||||
{canWrite && (
|
||||
<Button nativeButton={false} render={<Link href="/processes/new" />}>
|
||||
<Plus className="size-4" /> {t("newProcess")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<ModuleTabs
|
||||
active="/processes"
|
||||
tabs={[
|
||||
{ href: "/assets", label: tAssets("tabAssets") },
|
||||
{ href: "/processes", label: tAssets("tabProcesses") },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 rounded-lg border bg-card">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t("name")}</TableHead>
|
||||
<TableHead>{t("owner")}</TableHead>
|
||||
<TableHead>{t("criticality")}</TableHead>
|
||||
<TableHead>RTO</TableHead>
|
||||
<TableHead>MTD</TableHead>
|
||||
<TableHead>{t("assets")}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{processes.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="py-8 text-center text-muted-foreground">
|
||||
{t("empty")}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{processes.map((process) => (
|
||||
<TableRow key={process.id}>
|
||||
<TableCell>
|
||||
<Link href={`/processes/${process.id}`} className="font-medium hover:underline">
|
||||
{process.name}
|
||||
</Link>
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground">
|
||||
{process.owner?.name ?? tc("none")}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{process.bia ? (
|
||||
<LevelBadge
|
||||
level={process.bia.criticality}
|
||||
label={tCrit(String(process.bia.criticality))}
|
||||
/>
|
||||
) : (
|
||||
<span className="text-muted-foreground">{t("noBia")}</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground">
|
||||
{process.bia?.rtoHours != null ? `${process.bia.rtoHours} h` : tc("none")}
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground">
|
||||
{process.bia?.mtdHours != null ? `${process.bia.mtdHours} h` : tc("none")}
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground">
|
||||
{process._count.processAssets}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { redirect } from "next/navigation";
|
||||
import { auth, signOut } from "@/server/auth";
|
||||
|
||||
export default async function DashboardPage() {
|
||||
const session = await auth();
|
||||
if (!session?.user) redirect("/login");
|
||||
|
||||
const t = await getTranslations("dashboard");
|
||||
const tc = await getTranslations("common");
|
||||
|
||||
async function logout() {
|
||||
"use server";
|
||||
await signOut({ redirectTo: "/login" });
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="mx-auto w-full max-w-5xl flex-1 p-6">
|
||||
<header className="flex items-center justify-between border-b border-slate-200 pb-4">
|
||||
<h1 className="text-xl font-semibold">{t("title")}</h1>
|
||||
<form action={logout}>
|
||||
<button
|
||||
type="submit"
|
||||
className="rounded-md border border-slate-300 px-3 py-1.5 text-sm hover:bg-slate-100"
|
||||
>
|
||||
{tc("logout")}
|
||||
</button>
|
||||
</form>
|
||||
</header>
|
||||
|
||||
<section className="mt-6 rounded-xl border border-slate-200 bg-white p-6 shadow-sm">
|
||||
<p className="text-lg">{t("welcome", { name: session.user.name ?? "" })}</p>
|
||||
<dl className="mt-4 grid grid-cols-2 gap-2 text-sm">
|
||||
<dt className="text-slate-500">{t("tenant")}</dt>
|
||||
<dd>{session.user.tenantSlug}</dd>
|
||||
<dt className="text-slate-500">{t("roles")}</dt>
|
||||
<dd>{session.user.roles.join(", ")}</dd>
|
||||
</dl>
|
||||
<p className="mt-6 text-sm text-slate-400">{t("placeholder")}</p>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
+123
-7
@@ -1,21 +1,137 @@
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
@import "shadcn/tailwind.css";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
/* Helles Basis-Theme; Dark-Mode folgt kontrolliert mit dem shadcn/ui-Theming
|
||||
(kein automatisches prefers-color-scheme, damit Komponenten konsistent bleiben). */
|
||||
:root {
|
||||
--background: #f8fafc;
|
||||
--foreground: #0f172a;
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--font-sans: var(--font-geist-sans);
|
||||
--font-sans: var(--font-sans);
|
||||
--font-mono: var(--font-geist-mono);
|
||||
--font-heading: var(--font-sans);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-ring: var(--ring);
|
||||
--color-input: var(--input);
|
||||
--color-border: var(--border);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-card: var(--card);
|
||||
--radius-sm: calc(var(--radius) * 0.6);
|
||||
--radius-md: calc(var(--radius) * 0.8);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) * 1.4);
|
||||
--radius-2xl: calc(var(--radius) * 1.8);
|
||||
--radius-3xl: calc(var(--radius) * 2.2);
|
||||
--radius-4xl: calc(var(--radius) * 2.6);
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
font-family: var(--font-sans), Arial, Helvetica, sans-serif;
|
||||
}
|
||||
|
||||
:root {
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.145 0 0);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.145 0 0);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.145 0 0);
|
||||
--primary: oklch(0.205 0 0);
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--secondary: oklch(0.97 0 0);
|
||||
--secondary-foreground: oklch(0.205 0 0);
|
||||
--muted: oklch(0.97 0 0);
|
||||
--muted-foreground: oklch(0.556 0 0);
|
||||
--accent: oklch(0.97 0 0);
|
||||
--accent-foreground: oklch(0.205 0 0);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--border: oklch(0.922 0 0);
|
||||
--input: oklch(0.922 0 0);
|
||||
--ring: oklch(0.708 0 0);
|
||||
--chart-1: oklch(0.87 0 0);
|
||||
--chart-2: oklch(0.556 0 0);
|
||||
--chart-3: oklch(0.439 0 0);
|
||||
--chart-4: oklch(0.371 0 0);
|
||||
--chart-5: oklch(0.269 0 0);
|
||||
--radius: 0.625rem;
|
||||
--sidebar: oklch(0.985 0 0);
|
||||
--sidebar-foreground: oklch(0.145 0 0);
|
||||
--sidebar-primary: oklch(0.205 0 0);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.97 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||
--sidebar-border: oklch(0.922 0 0);
|
||||
--sidebar-ring: oklch(0.708 0 0);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.145 0 0);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.205 0 0);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.205 0 0);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.922 0 0);
|
||||
--primary-foreground: oklch(0.205 0 0);
|
||||
--secondary: oklch(0.269 0 0);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.269 0 0);
|
||||
--muted-foreground: oklch(0.708 0 0);
|
||||
--accent: oklch(0.269 0 0);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.556 0 0);
|
||||
--chart-1: oklch(0.87 0 0);
|
||||
--chart-2: oklch(0.556 0 0);
|
||||
--chart-3: oklch(0.439 0 0);
|
||||
--chart-4: oklch(0.371 0 0);
|
||||
--chart-5: oklch(0.269 0 0);
|
||||
--sidebar: oklch(0.205 0 0);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.269 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
--sidebar-ring: oklch(0.556 0 0);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
html {
|
||||
@apply font-sans;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import Link from "next/link";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import type { Asset } from "@prisma/client";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
|
||||
const ASSET_TYPES = ["INFORMATION", "SYSTEM", "APPLICATION", "LOCATION", "SUPPLIER", "PERSON", "DATA"] as const;
|
||||
const ASSET_STATUS = ["ACTIVE", "PLANNED", "RETIRED"] as const;
|
||||
const LEVELS = [1, 2, 3, 4] as const;
|
||||
|
||||
/** Formular für Anlegen/Bearbeiten eines Assets (Server-Action wird übergeben). */
|
||||
export async function AssetForm({
|
||||
action,
|
||||
asset,
|
||||
users,
|
||||
cancelHref,
|
||||
}: {
|
||||
action: (formData: FormData) => Promise<void>;
|
||||
asset?: Asset;
|
||||
users: { id: string; name: string }[];
|
||||
cancelHref: string;
|
||||
}) {
|
||||
const t = await getTranslations("assets");
|
||||
const tType = await getTranslations("assetType");
|
||||
const tStatus = await getTranslations("assetStatus");
|
||||
const tLevel = await getTranslations("protectionLevel");
|
||||
const tc = await getTranslations("common");
|
||||
|
||||
const selectClass =
|
||||
"h-9 w-full rounded-md border border-input bg-transparent px-3 text-sm";
|
||||
|
||||
return (
|
||||
<form action={action} className="max-w-2xl space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="name">{t("name")}</Label>
|
||||
<Input id="name" name="name" required defaultValue={asset?.name} className="mt-1" />
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="description">{t("description")}</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
name="description"
|
||||
rows={3}
|
||||
defaultValue={asset?.description ?? ""}
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label htmlFor="type">{t("type")}</Label>
|
||||
<select id="type" name="type" defaultValue={asset?.type ?? "SYSTEM"} className={`${selectClass} mt-1`}>
|
||||
{ASSET_TYPES.map((v) => (
|
||||
<option key={v} value={v}>
|
||||
{tType(v)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="status">{t("status")}</Label>
|
||||
<select id="status" name="status" defaultValue={asset?.status ?? "ACTIVE"} className={`${selectClass} mt-1`}>
|
||||
{ASSET_STATUS.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={asset?.ownerId ?? ""} className={`${selectClass} mt-1`}>
|
||||
<option value="">{tc("none")}</option>
|
||||
{users.map((u) => (
|
||||
<option key={u.id} value={u.id}>
|
||||
{u.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="location">{t("location")}</Label>
|
||||
<Input id="location" name="location" defaultValue={asset?.location ?? ""} className="mt-1" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<fieldset>
|
||||
<legend className="text-sm font-medium">{t("protection")} (1–4)</legend>
|
||||
<div className="mt-2 grid grid-cols-3 gap-4">
|
||||
{(
|
||||
[
|
||||
["confidentiality", t("confidentiality"), asset?.confidentiality],
|
||||
["integrity", t("integrity"), asset?.integrity],
|
||||
["availability", t("availability"), asset?.availability],
|
||||
] as const
|
||||
).map(([name, label, value]) => (
|
||||
<div key={name}>
|
||||
<Label htmlFor={name}>{label}</Label>
|
||||
<select id={name} name={name} defaultValue={value ?? 1} className={`${selectClass} mt-1`}>
|
||||
{LEVELS.map((l) => (
|
||||
<option key={l} value={l}>
|
||||
{l} — {tLevel(String(l))}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="tags">{t("tags")}</Label>
|
||||
<Input id="tags" name="tags" defaultValue={asset?.tags.join(", ") ?? ""} className="mt-1" />
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 pt-2">
|
||||
<Button type="submit">{tc("save")}</Button>
|
||||
<Button variant="outline" nativeButton={false} render={<Link href={cancelHref} />}>
|
||||
{tc("cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { levelColor } from "@/lib/levels";
|
||||
|
||||
/** Stufen-Badge 1–4, optional mit Präfix (z. B. "C", "RTO"). */
|
||||
export function LevelBadge({
|
||||
level,
|
||||
label,
|
||||
prefix,
|
||||
}: {
|
||||
level: number;
|
||||
label?: string;
|
||||
prefix?: string;
|
||||
}) {
|
||||
return (
|
||||
<Badge variant="outline" className={levelColor(level)}>
|
||||
{prefix ? `${prefix}${level}` : label ?? level}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import Link from "next/link";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/** Tab-Leiste innerhalb eines Moduls (z. B. Assets ↔ Prozesse & BIA). */
|
||||
export function ModuleTabs({
|
||||
tabs,
|
||||
active,
|
||||
}: {
|
||||
tabs: { href: string; label: string }[];
|
||||
active: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex gap-1 border-b">
|
||||
{tabs.map((tab) => (
|
||||
<Link
|
||||
key={tab.href}
|
||||
href={tab.href}
|
||||
className={cn(
|
||||
"-mb-px border-b-2 px-4 py-2 text-sm transition-colors",
|
||||
active === tab.href
|
||||
? "border-primary font-medium text-foreground"
|
||||
: "border-transparent text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
{tab.label}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function NavLink({
|
||||
href,
|
||||
match,
|
||||
children,
|
||||
}: {
|
||||
href: string;
|
||||
match?: string[];
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const pathname = usePathname();
|
||||
const active = (match ?? [href]).some(
|
||||
(m) => pathname === m || pathname.startsWith(m + "/")
|
||||
);
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
className={cn(
|
||||
"flex items-center gap-2 rounded-md px-3 py-2 text-sm transition-colors",
|
||||
active
|
||||
? "bg-sidebar-accent font-medium text-sidebar-accent-foreground"
|
||||
: "text-muted-foreground hover:bg-sidebar-accent/50 hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import Link from "next/link";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import type { Process } from "@prisma/client";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
|
||||
/** Formular für Anlegen/Bearbeiten eines Prozesses (Server-Action wird übergeben). */
|
||||
export async function ProcessForm({
|
||||
action,
|
||||
process,
|
||||
users,
|
||||
cancelHref,
|
||||
}: {
|
||||
action: (formData: FormData) => Promise<void>;
|
||||
process?: Process;
|
||||
users: { id: string; name: string }[];
|
||||
cancelHref: string;
|
||||
}) {
|
||||
const t = await getTranslations("processes");
|
||||
const tc = await getTranslations("common");
|
||||
|
||||
return (
|
||||
<form action={action} className="max-w-2xl space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="name">{t("name")}</Label>
|
||||
<Input id="name" name="name" required defaultValue={process?.name} className="mt-1" />
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="description">{t("description")}</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
name="description"
|
||||
rows={3}
|
||||
defaultValue={process?.description ?? ""}
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="ownerId">{t("owner")}</Label>
|
||||
<select
|
||||
id="ownerId"
|
||||
name="ownerId"
|
||||
defaultValue={process?.ownerId ?? ""}
|
||||
className="mt-1 h-9 w-full rounded-md border border-input bg-transparent px-3 text-sm"
|
||||
>
|
||||
<option value="">{tc("none")}</option>
|
||||
{users.map((u) => (
|
||||
<option key={u.id} value={u.id}>
|
||||
{u.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex gap-2 pt-2">
|
||||
<Button type="submit">{tc("save")}</Button>
|
||||
<Button variant="outline" nativeButton={false} render={<Link href={cancelHref} />}>
|
||||
{tc("cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { mergeProps } from "@base-ui/react/merge-props"
|
||||
import { useRender } from "@base-ui/react/use-render"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const badgeVariants = cva(
|
||||
"group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
|
||||
destructive:
|
||||
"bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20",
|
||||
outline:
|
||||
"border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
|
||||
ghost:
|
||||
"hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Badge({
|
||||
className,
|
||||
variant = "default",
|
||||
render,
|
||||
...props
|
||||
}: useRender.ComponentProps<"span"> & VariantProps<typeof badgeVariants>) {
|
||||
return useRender({
|
||||
defaultTagName: "span",
|
||||
props: mergeProps<"span">(
|
||||
{
|
||||
className: cn(badgeVariants({ variant }), className),
|
||||
},
|
||||
props
|
||||
),
|
||||
render,
|
||||
state: {
|
||||
slot: "badge",
|
||||
variant,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants }
|
||||
@@ -0,0 +1,58 @@
|
||||
import { Button as ButtonPrimitive } from "@base-ui/react/button"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const buttonVariants = cva(
|
||||
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/80",
|
||||
outline:
|
||||
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)] aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
|
||||
ghost:
|
||||
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
|
||||
destructive:
|
||||
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default:
|
||||
"h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
||||
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
|
||||
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
|
||||
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
||||
icon: "size-8",
|
||||
"icon-xs":
|
||||
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
|
||||
"icon-sm":
|
||||
"size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
|
||||
"icon-lg": "size-9",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Button({
|
||||
className,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
...props
|
||||
}: ButtonPrimitive.Props & VariantProps<typeof buttonVariants>) {
|
||||
return (
|
||||
<ButtonPrimitive
|
||||
data-slot="button"
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Button, buttonVariants }
|
||||
@@ -0,0 +1,103 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Card({
|
||||
className,
|
||||
size = "default",
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"group/card flex flex-col gap-(--card-spacing) overflow-hidden rounded-xl bg-card py-(--card-spacing) text-sm text-card-foreground ring-1 ring-foreground/10 [--card-spacing:--spacing(4)] has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:[--card-spacing:--spacing(3)] data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-header"
|
||||
className={cn(
|
||||
"group/card-header @container/card-header grid auto-rows-min items-start gap-1 rounded-t-xl px-(--card-spacing) has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-(--card-spacing)",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-title"
|
||||
className={cn(
|
||||
"font-heading text-base leading-snug font-medium group-data-[size=sm]/card:text-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-description"
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-action"
|
||||
className={cn(
|
||||
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-content"
|
||||
className={cn("px-(--card-spacing)", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-footer"
|
||||
className={cn(
|
||||
"flex items-center rounded-b-xl border-t bg-muted/50 p-(--card-spacing)",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardFooter,
|
||||
CardTitle,
|
||||
CardAction,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import * as React from "react"
|
||||
import { Input as InputPrimitive } from "@base-ui/react/input"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||
return (
|
||||
<InputPrimitive
|
||||
type={type}
|
||||
data-slot="input"
|
||||
className={cn(
|
||||
"h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Input }
|
||||
@@ -0,0 +1,20 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Label({ className, ...props }: React.ComponentProps<"label">) {
|
||||
return (
|
||||
<label
|
||||
data-slot="label"
|
||||
className={cn(
|
||||
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Label }
|
||||
@@ -0,0 +1,201 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Select as SelectPrimitive } from "@base-ui/react/select"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { ChevronDownIcon, CheckIcon, ChevronUpIcon } from "lucide-react"
|
||||
|
||||
const Select = SelectPrimitive.Root
|
||||
|
||||
function SelectGroup({ className, ...props }: SelectPrimitive.Group.Props) {
|
||||
return (
|
||||
<SelectPrimitive.Group
|
||||
data-slot="select-group"
|
||||
className={cn("scroll-my-1 p-1", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectValue({ className, ...props }: SelectPrimitive.Value.Props) {
|
||||
return (
|
||||
<SelectPrimitive.Value
|
||||
data-slot="select-value"
|
||||
className={cn("flex flex-1 text-left", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectTrigger({
|
||||
className,
|
||||
size = "default",
|
||||
children,
|
||||
...props
|
||||
}: SelectPrimitive.Trigger.Props & {
|
||||
size?: "sm" | "default"
|
||||
}) {
|
||||
return (
|
||||
<SelectPrimitive.Trigger
|
||||
data-slot="select-trigger"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"flex w-fit items-center justify-between gap-1.5 rounded-lg border border-input bg-transparent py-2 pr-2 pl-2.5 text-sm whitespace-nowrap transition-colors outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon
|
||||
render={
|
||||
<ChevronDownIcon className="pointer-events-none size-4 text-muted-foreground" />
|
||||
}
|
||||
/>
|
||||
</SelectPrimitive.Trigger>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectContent({
|
||||
className,
|
||||
children,
|
||||
side = "bottom",
|
||||
sideOffset = 4,
|
||||
align = "center",
|
||||
alignOffset = 0,
|
||||
alignItemWithTrigger = true,
|
||||
...props
|
||||
}: SelectPrimitive.Popup.Props &
|
||||
Pick<
|
||||
SelectPrimitive.Positioner.Props,
|
||||
"align" | "alignOffset" | "side" | "sideOffset" | "alignItemWithTrigger"
|
||||
>) {
|
||||
return (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Positioner
|
||||
side={side}
|
||||
sideOffset={sideOffset}
|
||||
align={align}
|
||||
alignOffset={alignOffset}
|
||||
alignItemWithTrigger={alignItemWithTrigger}
|
||||
className="isolate z-50"
|
||||
>
|
||||
<SelectPrimitive.Popup
|
||||
data-slot="select-content"
|
||||
data-align-trigger={alignItemWithTrigger}
|
||||
className={cn("relative isolate z-50 max-h-(--available-height) w-(--anchor-width) min-w-36 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[align-trigger=true]:animate-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.List>{children}</SelectPrimitive.List>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Popup>
|
||||
</SelectPrimitive.Positioner>
|
||||
</SelectPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectLabel({
|
||||
className,
|
||||
...props
|
||||
}: SelectPrimitive.GroupLabel.Props) {
|
||||
return (
|
||||
<SelectPrimitive.GroupLabel
|
||||
data-slot="select-label"
|
||||
className={cn("px-1.5 py-1 text-xs text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: SelectPrimitive.Item.Props) {
|
||||
return (
|
||||
<SelectPrimitive.Item
|
||||
data-slot="select-item"
|
||||
className={cn(
|
||||
"relative flex w-full cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SelectPrimitive.ItemText className="flex flex-1 shrink-0 gap-2 whitespace-nowrap">
|
||||
{children}
|
||||
</SelectPrimitive.ItemText>
|
||||
<SelectPrimitive.ItemIndicator
|
||||
render={
|
||||
<span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center" />
|
||||
}
|
||||
>
|
||||
<CheckIcon className="pointer-events-none" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</SelectPrimitive.Item>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectSeparator({
|
||||
className,
|
||||
...props
|
||||
}: SelectPrimitive.Separator.Props) {
|
||||
return (
|
||||
<SelectPrimitive.Separator
|
||||
data-slot="select-separator"
|
||||
className={cn("pointer-events-none -mx-1 my-1 h-px bg-border", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectScrollUpButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpArrow>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollUpArrow
|
||||
data-slot="select-scroll-up-button"
|
||||
className={cn(
|
||||
"top-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUpIcon
|
||||
/>
|
||||
</SelectPrimitive.ScrollUpArrow>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectScrollDownButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownArrow>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollDownArrow
|
||||
data-slot="select-scroll-down-button"
|
||||
className={cn(
|
||||
"bottom-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDownIcon
|
||||
/>
|
||||
</SelectPrimitive.ScrollDownArrow>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectScrollDownButton,
|
||||
SelectScrollUpButton,
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
"use client"
|
||||
|
||||
import { Separator as SeparatorPrimitive } from "@base-ui/react/separator"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Separator({
|
||||
className,
|
||||
orientation = "horizontal",
|
||||
...props
|
||||
}: SeparatorPrimitive.Props) {
|
||||
return (
|
||||
<SeparatorPrimitive
|
||||
data-slot="separator"
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"shrink-0 bg-border data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Separator }
|
||||
@@ -0,0 +1,116 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Table({ className, ...props }: React.ComponentProps<"table">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="table-container"
|
||||
className="relative w-full overflow-x-auto"
|
||||
>
|
||||
<table
|
||||
data-slot="table"
|
||||
className={cn("w-full caption-bottom text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
|
||||
return (
|
||||
<thead
|
||||
data-slot="table-header"
|
||||
className={cn("[&_tr]:border-b", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
|
||||
return (
|
||||
<tbody
|
||||
data-slot="table-body"
|
||||
className={cn("[&_tr:last-child]:border-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
|
||||
return (
|
||||
<tfoot
|
||||
data-slot="table-footer"
|
||||
className={cn(
|
||||
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
|
||||
return (
|
||||
<tr
|
||||
data-slot="table-row"
|
||||
className={cn(
|
||||
"border-b transition-colors hover:bg-muted/50 has-aria-expanded:bg-muted/50 data-[state=selected]:bg-muted",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
|
||||
return (
|
||||
<th
|
||||
data-slot="table-head"
|
||||
className={cn(
|
||||
"h-10 px-2 text-left align-middle font-medium whitespace-nowrap text-foreground [&:has([role=checkbox])]:pr-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
|
||||
return (
|
||||
<td
|
||||
data-slot="table-cell"
|
||||
className={cn(
|
||||
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableCaption({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"caption">) {
|
||||
return (
|
||||
<caption
|
||||
data-slot="table-caption"
|
||||
className={cn("mt-4 text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Table,
|
||||
TableHeader,
|
||||
TableBody,
|
||||
TableFooter,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TableCell,
|
||||
TableCaption,
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
"use client"
|
||||
|
||||
import { Tabs as TabsPrimitive } from "@base-ui/react/tabs"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Tabs({
|
||||
className,
|
||||
orientation = "horizontal",
|
||||
...props
|
||||
}: TabsPrimitive.Root.Props) {
|
||||
return (
|
||||
<TabsPrimitive.Root
|
||||
data-slot="tabs"
|
||||
data-orientation={orientation}
|
||||
className={cn(
|
||||
"group/tabs flex gap-2 data-horizontal:flex-col",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const tabsListVariants = cva(
|
||||
"group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-horizontal/tabs:h-8 group-data-vertical/tabs:h-fit group-data-vertical/tabs:flex-col data-[variant=line]:rounded-none",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-muted",
|
||||
line: "gap-1 bg-transparent",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function TabsList({
|
||||
className,
|
||||
variant = "default",
|
||||
...props
|
||||
}: TabsPrimitive.List.Props & VariantProps<typeof tabsListVariants>) {
|
||||
return (
|
||||
<TabsPrimitive.List
|
||||
data-slot="tabs-list"
|
||||
data-variant={variant}
|
||||
className={cn(tabsListVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TabsTrigger({ className, ...props }: TabsPrimitive.Tab.Props) {
|
||||
return (
|
||||
<TabsPrimitive.Tab
|
||||
data-slot="tabs-trigger"
|
||||
className={cn(
|
||||
"relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-1.5 py-0.5 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all group-data-vertical/tabs:w-full group-data-vertical/tabs:justify-start hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 has-data-[icon=inline-end]:pr-1 has-data-[icon=inline-start]:pl-1 aria-disabled:pointer-events-none aria-disabled:opacity-50 dark:text-muted-foreground dark:hover:text-foreground group-data-[variant=default]/tabs-list:data-active:shadow-sm group-data-[variant=line]/tabs-list:data-active:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-active:bg-transparent dark:group-data-[variant=line]/tabs-list:data-active:border-transparent dark:group-data-[variant=line]/tabs-list:data-active:bg-transparent",
|
||||
"data-active:bg-background data-active:text-foreground dark:data-active:border-input dark:data-active:bg-input/30 dark:data-active:text-foreground",
|
||||
"after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-horizontal/tabs:after:inset-x-0 group-data-horizontal/tabs:after:bottom-[-5px] group-data-horizontal/tabs:after:h-0.5 group-data-vertical/tabs:after:inset-y-0 group-data-vertical/tabs:after:-right-1 group-data-vertical/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-active:after:opacity-100",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TabsContent({ className, ...props }: TabsPrimitive.Panel.Props) {
|
||||
return (
|
||||
<TabsPrimitive.Panel
|
||||
data-slot="tabs-content"
|
||||
className={cn("flex-1 text-sm outline-none", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants }
|
||||
@@ -0,0 +1,18 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
|
||||
return (
|
||||
<textarea
|
||||
data-slot="textarea"
|
||||
className={cn(
|
||||
"flex field-sizing-content min-h-16 w-full rounded-lg border border-input bg-transparent px-2.5 py-2 text-base transition-colors outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Textarea }
|
||||
@@ -0,0 +1,13 @@
|
||||
/** Farbklassen für Stufen 1–4 (Schutzbedarf, Schadenshöhe, Kritikalität). */
|
||||
export function levelColor(level: number): string {
|
||||
switch (level) {
|
||||
case 2:
|
||||
return "bg-yellow-100 text-yellow-800 border-yellow-200";
|
||||
case 3:
|
||||
return "bg-orange-100 text-orange-800 border-orange-200";
|
||||
case 4:
|
||||
return "bg-red-100 text-red-800 border-red-200";
|
||||
default:
|
||||
return "bg-emerald-100 text-emerald-800 border-emerald-200";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { redirect } from "next/navigation";
|
||||
import { z } from "zod";
|
||||
import { requireSession } from "@/server/auth";
|
||||
import { dbForTenant } from "@/server/db";
|
||||
import { requirePermission } from "@/server/rbac";
|
||||
import { writeAuditLog } from "@/server/audit";
|
||||
|
||||
const level = z.coerce.number().int().min(1).max(4);
|
||||
|
||||
const assetSchema = z.object({
|
||||
name: z.string().trim().min(1).max(200),
|
||||
description: z.string().trim().max(5000).optional(),
|
||||
type: z.enum(["INFORMATION", "SYSTEM", "APPLICATION", "LOCATION", "SUPPLIER", "PERSON", "DATA"]),
|
||||
status: z.enum(["ACTIVE", "PLANNED", "RETIRED"]),
|
||||
ownerId: z.string().optional(),
|
||||
location: z.string().trim().max(200).optional(),
|
||||
tags: z.string().optional(),
|
||||
confidentiality: level,
|
||||
integrity: level,
|
||||
availability: level,
|
||||
});
|
||||
|
||||
function parseAssetForm(formData: FormData) {
|
||||
const parsed = assetSchema.parse({
|
||||
name: formData.get("name"),
|
||||
description: formData.get("description") || undefined,
|
||||
type: formData.get("type"),
|
||||
status: formData.get("status"),
|
||||
ownerId: formData.get("ownerId") || undefined,
|
||||
location: formData.get("location") || undefined,
|
||||
tags: formData.get("tags") || undefined,
|
||||
confidentiality: formData.get("confidentiality"),
|
||||
integrity: formData.get("integrity"),
|
||||
availability: formData.get("availability"),
|
||||
});
|
||||
return {
|
||||
...parsed,
|
||||
ownerId: parsed.ownerId || null,
|
||||
description: parsed.description || null,
|
||||
location: parsed.location || null,
|
||||
tags: parsed.tags
|
||||
? parsed.tags.split(",").map((t) => t.trim()).filter(Boolean)
|
||||
: [],
|
||||
};
|
||||
}
|
||||
|
||||
export async function createAsset(formData: FormData) {
|
||||
const session = await requireSession();
|
||||
requirePermission(session, "asset:write");
|
||||
const db = dbForTenant(session.user.tenantId);
|
||||
|
||||
const data = parseAssetForm(formData);
|
||||
// tenantId doppelt gemoppelt: explizit für die Typen, der Guard injiziert ohnehin
|
||||
const asset = await db.asset.create({
|
||||
data: { ...data, tenantId: session.user.tenantId, createdBy: session.user.id },
|
||||
});
|
||||
|
||||
await writeAuditLog({
|
||||
tenantId: session.user.tenantId,
|
||||
actorId: session.user.id,
|
||||
action: "create",
|
||||
entity: "asset",
|
||||
entityId: asset.id,
|
||||
after: data,
|
||||
});
|
||||
revalidatePath("/assets");
|
||||
redirect(`/assets/${asset.id}`);
|
||||
}
|
||||
|
||||
export async function updateAsset(assetId: string, formData: FormData) {
|
||||
const session = await requireSession();
|
||||
requirePermission(session, "asset:write");
|
||||
const db = dbForTenant(session.user.tenantId);
|
||||
|
||||
const before = await db.asset.findUnique({ where: { id: assetId } });
|
||||
if (!before) throw new Error("Asset nicht gefunden");
|
||||
|
||||
const data = parseAssetForm(formData);
|
||||
await db.asset.update({ where: { id: assetId }, data });
|
||||
|
||||
await writeAuditLog({
|
||||
tenantId: session.user.tenantId,
|
||||
actorId: session.user.id,
|
||||
action: "update",
|
||||
entity: "asset",
|
||||
entityId: assetId,
|
||||
before,
|
||||
after: data,
|
||||
});
|
||||
revalidatePath("/assets");
|
||||
revalidatePath(`/assets/${assetId}`);
|
||||
redirect(`/assets/${assetId}`);
|
||||
}
|
||||
|
||||
export async function deleteAsset(assetId: string) {
|
||||
const session = await requireSession();
|
||||
requirePermission(session, "asset:write");
|
||||
const db = dbForTenant(session.user.tenantId);
|
||||
|
||||
const before = await db.asset.findUnique({ where: { id: assetId } });
|
||||
if (!before) throw new Error("Asset nicht gefunden");
|
||||
|
||||
await db.asset.delete({ where: { id: assetId } });
|
||||
|
||||
await writeAuditLog({
|
||||
tenantId: session.user.tenantId,
|
||||
actorId: session.user.id,
|
||||
action: "delete",
|
||||
entity: "asset",
|
||||
entityId: assetId,
|
||||
before,
|
||||
});
|
||||
revalidatePath("/assets");
|
||||
redirect("/assets");
|
||||
}
|
||||
|
||||
export async function addAssetRelation(assetId: string, formData: FormData) {
|
||||
const session = await requireSession();
|
||||
requirePermission(session, "asset:write");
|
||||
const db = dbForTenant(session.user.tenantId);
|
||||
|
||||
const relatedAssetId = z.string().min(1).parse(formData.get("relatedAssetId"));
|
||||
if (relatedAssetId === assetId) return;
|
||||
|
||||
// Beide Assets müssen zum Mandanten gehören (Guard filtert, count prüft)
|
||||
const count = await db.asset.count({ where: { id: { in: [assetId, relatedAssetId] } } });
|
||||
if (count !== 2) throw new Error("Asset nicht gefunden");
|
||||
|
||||
await db.assetRelation.upsert({
|
||||
where: {
|
||||
assetId_relatedAssetId_type: { assetId, relatedAssetId, type: "depends_on" },
|
||||
},
|
||||
update: {},
|
||||
create: {
|
||||
assetId,
|
||||
relatedAssetId,
|
||||
type: "depends_on",
|
||||
tenantId: session.user.tenantId,
|
||||
},
|
||||
});
|
||||
|
||||
await writeAuditLog({
|
||||
tenantId: session.user.tenantId,
|
||||
actorId: session.user.id,
|
||||
action: "create",
|
||||
entity: "asset_relation",
|
||||
entityId: assetId,
|
||||
after: { assetId, relatedAssetId, type: "depends_on" },
|
||||
});
|
||||
revalidatePath(`/assets/${assetId}`);
|
||||
}
|
||||
|
||||
export async function removeAssetRelation(assetId: string, relationId: string) {
|
||||
const session = await requireSession();
|
||||
requirePermission(session, "asset:write");
|
||||
const db = dbForTenant(session.user.tenantId);
|
||||
|
||||
const before = await db.assetRelation.findUnique({ where: { id: relationId } });
|
||||
if (!before) return;
|
||||
await db.assetRelation.delete({ where: { id: relationId } });
|
||||
|
||||
await writeAuditLog({
|
||||
tenantId: session.user.tenantId,
|
||||
actorId: session.user.id,
|
||||
action: "delete",
|
||||
entity: "asset_relation",
|
||||
entityId: relationId,
|
||||
before,
|
||||
});
|
||||
revalidatePath(`/assets/${assetId}`);
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { redirect } from "next/navigation";
|
||||
import { z } from "zod";
|
||||
import { requireSession } from "@/server/auth";
|
||||
import { dbForTenant } from "@/server/db";
|
||||
import { requirePermission } from "@/server/rbac";
|
||||
import { writeAuditLog } from "@/server/audit";
|
||||
|
||||
const processSchema = z.object({
|
||||
name: z.string().trim().min(1).max(200),
|
||||
description: z.string().trim().max(5000).optional(),
|
||||
ownerId: z.string().optional(),
|
||||
});
|
||||
|
||||
function parseProcessForm(formData: FormData) {
|
||||
const parsed = processSchema.parse({
|
||||
name: formData.get("name"),
|
||||
description: formData.get("description") || undefined,
|
||||
ownerId: formData.get("ownerId") || undefined,
|
||||
});
|
||||
return {
|
||||
...parsed,
|
||||
description: parsed.description || null,
|
||||
ownerId: parsed.ownerId || null,
|
||||
};
|
||||
}
|
||||
|
||||
export async function createProcess(formData: FormData) {
|
||||
const session = await requireSession();
|
||||
requirePermission(session, "bia:write");
|
||||
const db = dbForTenant(session.user.tenantId);
|
||||
|
||||
const data = parseProcessForm(formData);
|
||||
// tenantId explizit für die Typen, der Guard injiziert ohnehin
|
||||
const process = await db.process.create({
|
||||
data: { ...data, tenantId: session.user.tenantId, createdBy: session.user.id },
|
||||
});
|
||||
|
||||
await writeAuditLog({
|
||||
tenantId: session.user.tenantId,
|
||||
actorId: session.user.id,
|
||||
action: "create",
|
||||
entity: "process",
|
||||
entityId: process.id,
|
||||
after: data,
|
||||
});
|
||||
revalidatePath("/processes");
|
||||
redirect(`/processes/${process.id}`);
|
||||
}
|
||||
|
||||
export async function updateProcess(processId: string, formData: FormData) {
|
||||
const session = await requireSession();
|
||||
requirePermission(session, "bia:write");
|
||||
const db = dbForTenant(session.user.tenantId);
|
||||
|
||||
const before = await db.process.findUnique({ where: { id: processId } });
|
||||
if (!before) throw new Error("Prozess nicht gefunden");
|
||||
|
||||
const data = parseProcessForm(formData);
|
||||
await db.process.update({ where: { id: processId }, data });
|
||||
|
||||
await writeAuditLog({
|
||||
tenantId: session.user.tenantId,
|
||||
actorId: session.user.id,
|
||||
action: "update",
|
||||
entity: "process",
|
||||
entityId: processId,
|
||||
before,
|
||||
after: data,
|
||||
});
|
||||
revalidatePath("/processes");
|
||||
revalidatePath(`/processes/${processId}`);
|
||||
redirect(`/processes/${processId}`);
|
||||
}
|
||||
|
||||
export async function deleteProcess(processId: string) {
|
||||
const session = await requireSession();
|
||||
requirePermission(session, "bia:write");
|
||||
const db = dbForTenant(session.user.tenantId);
|
||||
|
||||
const before = await db.process.findUnique({ where: { id: processId } });
|
||||
if (!before) throw new Error("Prozess nicht gefunden");
|
||||
|
||||
await db.process.delete({ where: { id: processId } });
|
||||
|
||||
await writeAuditLog({
|
||||
tenantId: session.user.tenantId,
|
||||
actorId: session.user.id,
|
||||
action: "delete",
|
||||
entity: "process",
|
||||
entityId: processId,
|
||||
before,
|
||||
});
|
||||
revalidatePath("/processes");
|
||||
redirect("/processes");
|
||||
}
|
||||
|
||||
export async function assignAsset(processId: string, formData: FormData) {
|
||||
const session = await requireSession();
|
||||
requirePermission(session, "bia:write");
|
||||
const db = dbForTenant(session.user.tenantId);
|
||||
|
||||
const assetId = z.string().min(1).parse(formData.get("assetId"));
|
||||
const role = z.enum(["PRIMARY", "SECONDARY"]).parse(formData.get("role"));
|
||||
|
||||
const assetCount = await db.asset.count({ where: { id: assetId } });
|
||||
const processCount = await db.process.count({ where: { id: processId } });
|
||||
if (assetCount !== 1 || processCount !== 1) throw new Error("Nicht gefunden");
|
||||
|
||||
await db.processAsset.upsert({
|
||||
where: { processId_assetId: { processId, assetId } },
|
||||
update: { role },
|
||||
create: { processId, assetId, role, tenantId: session.user.tenantId },
|
||||
});
|
||||
|
||||
await writeAuditLog({
|
||||
tenantId: session.user.tenantId,
|
||||
actorId: session.user.id,
|
||||
action: "update",
|
||||
entity: "process_asset",
|
||||
entityId: processId,
|
||||
after: { processId, assetId, role },
|
||||
});
|
||||
revalidatePath(`/processes/${processId}`);
|
||||
}
|
||||
|
||||
export async function unassignAsset(processId: string, processAssetId: string) {
|
||||
const session = await requireSession();
|
||||
requirePermission(session, "bia:write");
|
||||
const db = dbForTenant(session.user.tenantId);
|
||||
|
||||
const before = await db.processAsset.findUnique({ where: { id: processAssetId } });
|
||||
if (!before) return;
|
||||
await db.processAsset.delete({ where: { id: processAssetId } });
|
||||
|
||||
await writeAuditLog({
|
||||
tenantId: session.user.tenantId,
|
||||
actorId: session.user.id,
|
||||
action: "delete",
|
||||
entity: "process_asset",
|
||||
entityId: processAssetId,
|
||||
before,
|
||||
});
|
||||
revalidatePath(`/processes/${processId}`);
|
||||
}
|
||||
|
||||
const hours = z
|
||||
.union([z.literal(""), z.coerce.number().int().min(0).max(100000)])
|
||||
.transform((v) => (v === "" ? null : v));
|
||||
|
||||
export async function saveBia(processId: string, formData: FormData) {
|
||||
const session = await requireSession();
|
||||
requirePermission(session, "bia:write");
|
||||
const db = dbForTenant(session.user.tenantId);
|
||||
|
||||
const processCount = await db.process.count({ where: { id: processId } });
|
||||
if (processCount !== 1) throw new Error("Prozess nicht gefunden");
|
||||
|
||||
const level = z.coerce.number().int().min(1).max(4);
|
||||
const data = {
|
||||
rtoHours: hours.parse(formData.get("rtoHours") ?? ""),
|
||||
rpoHours: hours.parse(formData.get("rpoHours") ?? ""),
|
||||
mtdHours: hours.parse(formData.get("mtdHours") ?? ""),
|
||||
impactC: level.parse(formData.get("impactC")),
|
||||
impactI: level.parse(formData.get("impactI")),
|
||||
impactA: level.parse(formData.get("impactA")),
|
||||
notes: (formData.get("notes") as string)?.trim() || null,
|
||||
};
|
||||
// Kritikalität nach Max-Prinzip aus den Schadenshöhen (SPEC §4.1.2)
|
||||
const criticality = Math.max(data.impactC, data.impactI, data.impactA);
|
||||
|
||||
const before = await db.biaEntry.findUnique({ where: { processId } });
|
||||
await db.biaEntry.upsert({
|
||||
where: { processId },
|
||||
update: { ...data, criticality },
|
||||
create: { ...data, criticality, processId, tenantId: session.user.tenantId },
|
||||
});
|
||||
|
||||
await writeAuditLog({
|
||||
tenantId: session.user.tenantId,
|
||||
actorId: session.user.id,
|
||||
action: before ? "update" : "create",
|
||||
entity: "bia_entry",
|
||||
entityId: processId,
|
||||
before: before ?? undefined,
|
||||
after: { ...data, criticality },
|
||||
});
|
||||
revalidatePath("/processes");
|
||||
revalidatePath(`/processes/${processId}`);
|
||||
}
|
||||
+15
-1
@@ -24,7 +24,16 @@ export const prisma =
|
||||
if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;
|
||||
|
||||
/** Models that carry a tenantId column and must never be queried without one. */
|
||||
const TENANT_MODELS = new Set<string>(["User", "Role", "AuditLog"]);
|
||||
const TENANT_MODELS = new Set<string>([
|
||||
"User",
|
||||
"Role",
|
||||
"AuditLog",
|
||||
"Asset",
|
||||
"AssetRelation",
|
||||
"Process",
|
||||
"ProcessAsset",
|
||||
"BiaEntry",
|
||||
]);
|
||||
|
||||
/**
|
||||
* Returns a Prisma client that transparently enforces the tenant scope:
|
||||
@@ -44,8 +53,10 @@ export function dbForTenant(tenantId: string) {
|
||||
if (
|
||||
operation === "findMany" ||
|
||||
operation === "findFirst" ||
|
||||
operation === "findFirstOrThrow" ||
|
||||
operation === "count" ||
|
||||
operation === "aggregate" ||
|
||||
operation === "groupBy" ||
|
||||
operation === "updateMany" ||
|
||||
operation === "deleteMany"
|
||||
) {
|
||||
@@ -87,6 +98,9 @@ export function dbForTenant(tenantId: string) {
|
||||
`Tenant isolation violation: ${model} belongs to another tenant`
|
||||
);
|
||||
}
|
||||
if (operation === "upsert") {
|
||||
a.create = { ...(a.create as object), tenantId };
|
||||
}
|
||||
}
|
||||
|
||||
return query(args);
|
||||
|
||||
Reference in New Issue
Block a user