Admin-Konsole & Mandantenverwaltung (Phase 1)
Plattform-Admin-Konsole (Superadmin) + Kunden-Einstellungsbereich: - Datenmodell: TenantSettings (Quelle der ISMS-Template-Variablen), TenantModule (Feature-Toggles je Mandant), Tenant += short/sector, TenantStatus += ARCHIVED, AuditLog += scope (tenant|platform); RLS für die neuen Tenant-Tabellen. - Wiederverwendbare Provisionierung (server/provision.ts): Rollen+Permissions, erster Admin-User, Einstellungen, alle Module, optional Richtlinienpaket-Seed + TISAX-Default; idempotent + Audit (§3.7). syncPolicyVariablesFromSettings mappt Stammdaten → ISMS-Variablen (ORG_NAME, ISMS_SCOPE, ROLE_* …). - Admin-Konsole /admin (Guard isPlatformAdmin): Mandantenliste, „Neuer Kunde" (anlegen + provisionieren), Detailseite mit Modul-Toggles, Lebenszyklus (aktiv/gesperrt/archiviert), Nutzerliste. Aktionen im Plattform-Audit-Log. - Kunden-Einstellungen /settings (Guard tenant:manage): Unternehmensdaten, verantwortliche Rollen, Branding/Regionales, TISAX-Level — Stammdaten speisen die ISMS-Variablen (eine Pflegestelle). Modul-Übersicht. - Modul-Gating der Navigation (deaktivierte Module ausgeblendet); Login bereits für gesperrte/archivierte Mandanten blockiert (auth.authorize). - Seed: Demo-Mandant mit Einstellungen, 12 aktiven Modulen; admin@demo.example = Superadmin. Verifiziert: tsc/lint/build grün; Seed rollt Einstellungen/Module/Superadmin aus. (Browser-Verifikation diese Sitzung nicht möglich — Preview-Tools getrennt.) Offen (Phase 2): separater Superadmin-Store + eigener Login + MFA-Pflicht; Impersonation (zeitlich begrenzt, protokolliert); Plan/Limits; Logo-Upload/ Objektspeicher; SMTP/Benachrichtigungen; per-Route-Modul-Enforcement (serverseitig); Datenexport/Löschung/Retention (DSGVO); SSO. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+3
-1
@@ -30,7 +30,9 @@
|
|||||||
"suppliers": "Lieferanten",
|
"suppliers": "Lieferanten",
|
||||||
"review": "Management-Review",
|
"review": "Management-Review",
|
||||||
"assets": "Asset-Inventar",
|
"assets": "Asset-Inventar",
|
||||||
"bia": "Business Impact Analyse"
|
"bia": "Business Impact Analyse",
|
||||||
|
"settings": "Einstellungen",
|
||||||
|
"admin": "Admin-Konsole"
|
||||||
},
|
},
|
||||||
"login": {
|
"login": {
|
||||||
"title": "Anmelden",
|
"title": "Anmelden",
|
||||||
|
|||||||
+3
-1
@@ -30,7 +30,9 @@
|
|||||||
"suppliers": "Suppliers",
|
"suppliers": "Suppliers",
|
||||||
"review": "Management review",
|
"review": "Management review",
|
||||||
"assets": "Asset inventory",
|
"assets": "Asset inventory",
|
||||||
"bia": "Business impact analysis"
|
"bia": "Business impact analysis",
|
||||||
|
"settings": "Settings",
|
||||||
|
"admin": "Admin console"
|
||||||
},
|
},
|
||||||
"login": {
|
"login": {
|
||||||
"title": "Sign in",
|
"title": "Sign in",
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
-- AlterEnum
|
||||||
|
ALTER TYPE "TenantStatus" ADD VALUE 'ARCHIVED';
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "audit_logs" ADD COLUMN "scope" TEXT NOT NULL DEFAULT 'tenant';
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "tenants" ADD COLUMN "sector" TEXT,
|
||||||
|
ADD COLUMN "short" TEXT;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "tenant_settings" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"tenant_id" TEXT NOT NULL,
|
||||||
|
"org_name" TEXT NOT NULL,
|
||||||
|
"org_short" TEXT,
|
||||||
|
"address" TEXT,
|
||||||
|
"sector" TEXT,
|
||||||
|
"duns" TEXT,
|
||||||
|
"isms_scope" TEXT,
|
||||||
|
"isms_scope_description" TEXT,
|
||||||
|
"role_management" TEXT,
|
||||||
|
"role_isb" TEXT,
|
||||||
|
"role_it_lead" TEXT,
|
||||||
|
"role_dpo" TEXT,
|
||||||
|
"logo_key" TEXT,
|
||||||
|
"accent" TEXT,
|
||||||
|
"locale" TEXT NOT NULL DEFAULT 'de',
|
||||||
|
"timezone" TEXT NOT NULL DEFAULT 'Europe/Berlin',
|
||||||
|
"tisax_level" TEXT NOT NULL DEFAULT 'AL2',
|
||||||
|
"security_policy" JSONB NOT NULL DEFAULT '{}',
|
||||||
|
"smtp" JSONB NOT NULL DEFAULT '{}',
|
||||||
|
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "tenant_settings_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "tenant_modules" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"tenant_id" TEXT NOT NULL,
|
||||||
|
"module_key" TEXT NOT NULL,
|
||||||
|
"enabled" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "tenant_modules_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "tenant_settings_tenant_id_key" ON "tenant_settings"("tenant_id");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "tenant_settings_tenant_id_idx" ON "tenant_settings"("tenant_id");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "tenant_modules_tenant_id_idx" ON "tenant_modules"("tenant_id");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "tenant_modules_tenant_id_module_key_key" ON "tenant_modules"("tenant_id", "module_key");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "tenant_settings" ADD CONSTRAINT "tenant_settings_tenant_id_fkey" FOREIGN KEY ("tenant_id") REFERENCES "tenants"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "tenant_modules" ADD CONSTRAINT "tenant_modules_tenant_id_fkey" FOREIGN KEY ("tenant_id") REFERENCES "tenants"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
|
||||||
|
-- RLS für die neuen mandantenbezogenen Tabellen
|
||||||
|
DO $$
|
||||||
|
DECLARE t text;
|
||||||
|
BEGIN
|
||||||
|
FOREACH t IN ARRAY ARRAY['tenant_settings','tenant_modules'] LOOP
|
||||||
|
EXECUTE format('ALTER TABLE %I ENABLE ROW LEVEL SECURITY', t);
|
||||||
|
EXECUTE format('CREATE POLICY tenant_isolation ON %I USING (tenant_id = current_setting(''app.tenant_id'', true))', t);
|
||||||
|
END LOOP;
|
||||||
|
END $$;
|
||||||
@@ -18,6 +18,7 @@ datasource db {
|
|||||||
enum TenantStatus {
|
enum TenantStatus {
|
||||||
ACTIVE
|
ACTIVE
|
||||||
SUSPENDED
|
SUSPENDED
|
||||||
|
ARCHIVED
|
||||||
}
|
}
|
||||||
|
|
||||||
enum UserStatus {
|
enum UserStatus {
|
||||||
@@ -31,6 +32,8 @@ model Tenant {
|
|||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
name String
|
name String
|
||||||
slug String @unique
|
slug String @unique
|
||||||
|
short String?
|
||||||
|
sector String?
|
||||||
status TenantStatus @default(ACTIVE)
|
status TenantStatus @default(ACTIVE)
|
||||||
config Json @default("{}")
|
config Json @default("{}")
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
@@ -39,10 +42,59 @@ model Tenant {
|
|||||||
users User[]
|
users User[]
|
||||||
roles Role[]
|
roles Role[]
|
||||||
auditLogs AuditLog[]
|
auditLogs AuditLog[]
|
||||||
|
settings TenantSettings?
|
||||||
|
modules TenantModule[]
|
||||||
|
|
||||||
@@map("tenants")
|
@@map("tenants")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Mandanten-Einstellungen: Quelle der ISMS-Template-Variablen (§4.1) + Branding/Policy
|
||||||
|
model TenantSettings {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
tenantId String @unique @map("tenant_id")
|
||||||
|
orgName String @map("org_name")
|
||||||
|
orgShort String? @map("org_short")
|
||||||
|
address String?
|
||||||
|
sector String?
|
||||||
|
duns String?
|
||||||
|
ismsScope String? @map("isms_scope")
|
||||||
|
ismsScopeDescription String? @map("isms_scope_description")
|
||||||
|
roleManagement String? @map("role_management")
|
||||||
|
roleIsb String? @map("role_isb")
|
||||||
|
roleItLead String? @map("role_it_lead")
|
||||||
|
roleDpo String? @map("role_dpo")
|
||||||
|
logoKey String? @map("logo_key")
|
||||||
|
accent String?
|
||||||
|
locale String @default("de")
|
||||||
|
timezone String @default("Europe/Berlin")
|
||||||
|
tisaxLevel String @default("AL2") @map("tisax_level") // AL2 | AL3
|
||||||
|
securityPolicy Json @default("{}") @map("security_policy") // pw/mfa/session
|
||||||
|
smtp Json @default("{}")
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
|
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@index([tenantId])
|
||||||
|
@@map("tenant_settings")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Modul-Freischaltung je Mandant (Feature-Toggle) — gated Navigation, API und Daten
|
||||||
|
model TenantModule {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
tenantId String @map("tenant_id")
|
||||||
|
moduleKey String @map("module_key")
|
||||||
|
enabled Boolean @default(true)
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
|
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@unique([tenantId, moduleKey])
|
||||||
|
@@index([tenantId])
|
||||||
|
@@map("tenant_modules")
|
||||||
|
}
|
||||||
|
|
||||||
model User {
|
model User {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
tenantId String @map("tenant_id")
|
tenantId String @map("tenant_id")
|
||||||
@@ -641,6 +693,7 @@ model MaturityAssessment {
|
|||||||
model AuditLog {
|
model AuditLog {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
tenantId String @map("tenant_id")
|
tenantId String @map("tenant_id")
|
||||||
|
scope String @default("tenant") // tenant | platform
|
||||||
actorId String? @map("actor_id")
|
actorId String? @map("actor_id")
|
||||||
action String // create | update | delete | login | export | …
|
action String // create | update | delete | login | export | …
|
||||||
entity String // z. B. asset, risk, user
|
entity String // z. B. asset, risk, user
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { join } from "node:path";
|
|||||||
import { PERMISSIONS, ROLE_DEFS } from "../src/server/rbac";
|
import { PERMISSIONS, ROLE_DEFS } from "../src/server/rbac";
|
||||||
import { importPolicies } from "./import-policies";
|
import { importPolicies } from "./import-policies";
|
||||||
import { importManaged } from "./import-managed";
|
import { importManaged } from "./import-managed";
|
||||||
|
import { MODULE_KEYS } from "../src/lib/modules";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Seed: global permission catalog + demo tenant with roles and users.
|
* Seed: global permission catalog + demo tenant with roles and users.
|
||||||
@@ -526,6 +527,34 @@ async function main() {
|
|||||||
|
|
||||||
const mc = await importManaged(prisma, tenant.id);
|
const mc = await importManaged(prisma, tenant.id);
|
||||||
console.log(`✔ Verwaltete Register: Krypto ${mc.crypto}, Klassifizierung ${mc.classes}×${mc.aspects}, Risikomatrix (${mc.damage} Schadensdim.), Handbuch ${mc.handbook} Themen`);
|
console.log(`✔ Verwaltete Register: Krypto ${mc.crypto}, Klassifizierung ${mc.classes}×${mc.aspects}, Risikomatrix (${mc.damage} Schadensdim.), Handbuch ${mc.handbook} Themen`);
|
||||||
|
|
||||||
|
// 11. Admin-Konsole: Mandanten-Einstellungen, Module, Superadmin
|
||||||
|
await prisma.user.updateMany({ where: { tenantId: tenant.id, email: "admin@demo.example" }, data: { isPlatformAdmin: true } });
|
||||||
|
await prisma.tenantSettings.upsert({
|
||||||
|
where: { tenantId: tenant.id },
|
||||||
|
update: {},
|
||||||
|
create: {
|
||||||
|
tenantId: tenant.id,
|
||||||
|
orgName: "GEFIM Demo GmbH",
|
||||||
|
orgShort: "GEFIM",
|
||||||
|
sector: "IT-Dienstleistung",
|
||||||
|
ismsScope: "IT-Betrieb & Softwareentwicklung",
|
||||||
|
ismsScopeDescription: "IT-Betrieb, Softwareentwicklung und zugehörige Unterstützungsprozesse am Standort Zentrale",
|
||||||
|
roleManagement: "Geschäftsführung",
|
||||||
|
roleIsb: "Informationssicherheitsbeauftragte(r) (ISB)",
|
||||||
|
roleItLead: "IT-Leitung",
|
||||||
|
roleDpo: "Datenschutzbeauftragte(r) (DSB)",
|
||||||
|
tisaxLevel: "AL2",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
for (const key of MODULE_KEYS) {
|
||||||
|
await prisma.tenantModule.upsert({
|
||||||
|
where: { tenantId_moduleKey: { tenantId: tenant.id, moduleKey: key } },
|
||||||
|
update: {},
|
||||||
|
create: { tenantId: tenant.id, moduleKey: key, enabled: true },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
console.log(`✔ Admin-Konsole: Einstellungen, ${MODULE_KEYS.length} Module aktiv, admin@demo.example = Superadmin`);
|
||||||
}
|
}
|
||||||
|
|
||||||
main()
|
main()
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { notFound, redirect } from "next/navigation";
|
||||||
|
import { ArrowLeft } from "lucide-react";
|
||||||
|
import { requireSession } from "@/server/auth";
|
||||||
|
import { prisma } from "@/server/db";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { PageHead, Pill } from "@/components/mockup-ui";
|
||||||
|
import { MODULES } from "@/lib/modules";
|
||||||
|
import { setTenantStatus, toggleTenantModule } from "@/server/actions/admin";
|
||||||
|
|
||||||
|
const STATUS_TONE: Record<string, "ok" | "warn" | "mut"> = { ACTIVE: "ok", SUSPENDED: "warn", ARCHIVED: "mut" };
|
||||||
|
const STATUS_LABEL: Record<string, string> = { ACTIVE: "Aktiv", SUSPENDED: "Gesperrt", ARCHIVED: "Archiviert" };
|
||||||
|
|
||||||
|
export default async function AdminTenantPage({ params }: { params: Promise<{ id: string }> }) {
|
||||||
|
const session = await requireSession();
|
||||||
|
if (!session.user.isPlatformAdmin) redirect("/dashboard");
|
||||||
|
const { id } = await params;
|
||||||
|
|
||||||
|
const tenant = await prisma.tenant.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: {
|
||||||
|
settings: true,
|
||||||
|
modules: true,
|
||||||
|
users: { select: { id: true, name: true, email: true, status: true, isPlatformAdmin: true }, orderBy: { createdAt: "asc" } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!tenant) notFound();
|
||||||
|
|
||||||
|
const moduleState = new Map(tenant.modules.map((m) => [m.moduleKey, m.enabled]));
|
||||||
|
const isOn = (key: string) => moduleState.get(key) ?? true;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="flex-1 p-6">
|
||||||
|
<Link href="/admin" className="inline-flex items-center gap-1.5 text-[12.5px] font-semibold text-muted-foreground hover:text-foreground">
|
||||||
|
<ArrowLeft className="size-4" /> Zurück zur Übersicht
|
||||||
|
</Link>
|
||||||
|
<div className="mt-3">
|
||||||
|
<PageHead
|
||||||
|
crumb="Mandant"
|
||||||
|
title={tenant.name}
|
||||||
|
sub={`Kürzel ${tenant.slug}${tenant.sector ? ` · ${tenant.sector}` : ""} · TISAX ${tenant.settings?.tisaxLevel ?? "AL2"}`}
|
||||||
|
actions={<Pill tone={STATUS_TONE[tenant.status]}>{STATUS_LABEL[tenant.status]}</Pill>}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 grid gap-5 lg:grid-cols-[1fr_320px]">
|
||||||
|
{/* Module */}
|
||||||
|
<div className="shadow-card rounded-xl border bg-card p-5">
|
||||||
|
<p className="mb-1 font-heading text-sm font-semibold">Module</p>
|
||||||
|
<p className="mb-3 text-[12px] text-muted-foreground">Deaktivierte Module sind für den Kunden ausgeblendet und serverseitig gesperrt.</p>
|
||||||
|
<div className="divide-y">
|
||||||
|
{MODULES.map((m) => {
|
||||||
|
const on = isOn(m.key);
|
||||||
|
return (
|
||||||
|
<div key={m.key} className="flex items-center justify-between gap-3 py-2.5">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium">{m.name}</p>
|
||||||
|
<p className="text-[11px] text-muted-foreground">{m.href}</p>
|
||||||
|
</div>
|
||||||
|
<form action={toggleTenantModule.bind(null, tenant.id, m.key, !on)}>
|
||||||
|
<button type="submit" className={`rounded-full px-3 py-1 text-[12px] font-bold ${on ? "bg-[rgba(57,192,127,0.16)] text-[var(--ok)]" : "bg-muted text-muted-foreground"}`}>
|
||||||
|
{on ? "Aktiv" : "Inaktiv"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-5">
|
||||||
|
{/* Lebenszyklus */}
|
||||||
|
<div className="shadow-card rounded-xl border bg-card p-5">
|
||||||
|
<p className="mb-3 font-heading text-sm font-semibold">Lebenszyklus</p>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<form action={setTenantStatus.bind(null, tenant.id, "ACTIVE")}>
|
||||||
|
<Button type="submit" variant={tenant.status === "ACTIVE" ? "secondary" : "outline"} size="sm" className="w-full justify-center" disabled={tenant.status === "ACTIVE"}>Aktivieren</Button>
|
||||||
|
</form>
|
||||||
|
<form action={setTenantStatus.bind(null, tenant.id, "SUSPENDED")}>
|
||||||
|
<Button type="submit" variant="outline" size="sm" className="w-full justify-center" disabled={tenant.status === "SUSPENDED"}>Sperren (Login blockiert)</Button>
|
||||||
|
</form>
|
||||||
|
<form action={setTenantStatus.bind(null, tenant.id, "ARCHIVED")}>
|
||||||
|
<Button type="submit" variant="outline" size="sm" className="w-full justify-center" disabled={tenant.status === "ARCHIVED"}>Archivieren</Button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<p className="mt-3 text-[11px] text-muted-foreground">Löschung/Datenexport (DSGVO) und Retention folgen in Phase 2.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Nutzer */}
|
||||||
|
<div className="shadow-card rounded-xl border bg-card p-5">
|
||||||
|
<p className="mb-3 font-heading text-sm font-semibold">Nutzer ({tenant.users.length})</p>
|
||||||
|
<ul className="space-y-1.5 text-sm">
|
||||||
|
{tenant.users.map((u) => (
|
||||||
|
<li key={u.id} className="flex items-center justify-between gap-2">
|
||||||
|
<span>
|
||||||
|
<span className="font-medium">{u.name}</span>
|
||||||
|
<span className="block text-[11px] text-muted-foreground">{u.email}</span>
|
||||||
|
</span>
|
||||||
|
{u.isPlatformAdmin && <Pill tone="violet">Superadmin</Pill>}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { Plus } from "lucide-react";
|
||||||
|
import { requireSession } from "@/server/auth";
|
||||||
|
import { prisma } from "@/server/db";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { PageHead, Pill } from "@/components/mockup-ui";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table";
|
||||||
|
import { createTenant } from "@/server/actions/admin";
|
||||||
|
|
||||||
|
const STATUS_TONE: Record<string, "ok" | "warn" | "mut"> = { ACTIVE: "ok", SUSPENDED: "warn", ARCHIVED: "mut" };
|
||||||
|
const STATUS_LABEL: Record<string, string> = { ACTIVE: "Aktiv", SUSPENDED: "Gesperrt", ARCHIVED: "Archiviert" };
|
||||||
|
const inputCls = "h-9 w-full rounded-md border border-input bg-transparent px-3 text-sm";
|
||||||
|
|
||||||
|
export default async function AdminPage({ searchParams }: { searchParams: Promise<{ new?: string }> }) {
|
||||||
|
const session = await requireSession();
|
||||||
|
if (!session.user.isPlatformAdmin) redirect("/dashboard");
|
||||||
|
const params = await searchParams;
|
||||||
|
|
||||||
|
const tenants = await prisma.tenant.findMany({
|
||||||
|
include: { _count: { select: { users: true } }, modules: true },
|
||||||
|
orderBy: { createdAt: "asc" },
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="flex-1 p-6">
|
||||||
|
<PageHead
|
||||||
|
crumb="Plattform-Betrieb"
|
||||||
|
title="Admin-Konsole — Mandantenverwaltung"
|
||||||
|
sub="Kunden (Mandanten) anlegen, provisionieren, Module & Lebenszyklus verwalten"
|
||||||
|
actions={
|
||||||
|
<Button nativeButton={false} render={<Link href={params.new ? "/admin" : "/admin?new=1"} />}>
|
||||||
|
<Plus className="size-4" /> Neuer Kunde
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{params.new && (
|
||||||
|
<div className="shadow-card mt-4 rounded-xl border bg-card p-5">
|
||||||
|
<p className="mb-3 font-heading text-sm font-semibold">Neuen Kunden anlegen</p>
|
||||||
|
<form action={createTenant} className="grid gap-4 md:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="name">Firmenname *</Label>
|
||||||
|
<Input id="name" name="name" required className="mt-1" placeholder="Muster GmbH" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="short">Kurzname</Label>
|
||||||
|
<Input id="short" name="short" className="mt-1" placeholder="Muster" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="slug">Kürzel/Slug (optional)</Label>
|
||||||
|
<Input id="slug" name="slug" className="mt-1" placeholder="wird aus Name erzeugt" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="sector">Sektor</Label>
|
||||||
|
<Input id="sector" name="sector" className="mt-1" placeholder="z. B. Automotive-Zulieferer" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="adminEmail">Admin-E-Mail *</Label>
|
||||||
|
<Input id="adminEmail" name="adminEmail" type="email" required className="mt-1" placeholder="admin@muster.example" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="adminName">Admin-Name</Label>
|
||||||
|
<Input id="adminName" name="adminName" className="mt-1" placeholder="Vor- und Nachname" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="adminPassword">Initial-Passwort * (min. 8)</Label>
|
||||||
|
<Input id="adminPassword" name="adminPassword" type="text" required className="mt-1" placeholder="wird dem Admin mitgeteilt" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="tisaxLevel">TISAX-Level</Label>
|
||||||
|
<select id="tisaxLevel" name="tisaxLevel" defaultValue="AL2" className={`${inputCls} mt-1`}>
|
||||||
|
<option value="AL2">AL2 (MUSS · SOLL · HOCH)</option>
|
||||||
|
<option value="AL3">AL3 (+ SEHR HOCH)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<label className="flex items-center gap-2 text-sm md:col-span-2">
|
||||||
|
<input type="checkbox" name="seedPolicies" defaultChecked /> Richtlinienpaket (VDA-ISA 2027) beim Anlegen ausrollen
|
||||||
|
</label>
|
||||||
|
<div className="flex gap-2 md:col-span-2">
|
||||||
|
<Button type="submit">Kunde anlegen & provisionieren</Button>
|
||||||
|
<Button variant="outline" nativeButton={false} render={<Link href="/admin" />}>Abbrechen</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<p className="mt-3 text-[11.5px] text-muted-foreground">
|
||||||
|
Beim Anlegen werden automatisch ausgerollt: Standard-Rollen, erster Admin-User, alle Module, Mandanten-Einstellungen und der TISAX-Default (idempotent, protokolliert).
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="shadow-card mt-4 rounded-xl border bg-card">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Kunde</TableHead>
|
||||||
|
<TableHead>Kürzel</TableHead>
|
||||||
|
<TableHead>Status</TableHead>
|
||||||
|
<TableHead>Nutzer</TableHead>
|
||||||
|
<TableHead>Aktive Module</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{tenants.map((t) => {
|
||||||
|
const active = t.modules.filter((m) => m.enabled).length;
|
||||||
|
return (
|
||||||
|
<TableRow key={t.id}>
|
||||||
|
<TableCell>
|
||||||
|
<Link href={`/admin/${t.id}`} className="font-bold hover:underline">{t.name}</Link>
|
||||||
|
{t.sector && <div className="text-xs text-muted-foreground">{t.sector}</div>}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-muted-foreground">{t.slug}</TableCell>
|
||||||
|
<TableCell><Pill tone={STATUS_TONE[t.status]}>{STATUS_LABEL[t.status]}</Pill></TableCell>
|
||||||
|
<TableCell className="text-muted-foreground">{t._count.users}</TableCell>
|
||||||
|
<TableCell className="text-muted-foreground">{active > 0 ? `${active} Module` : "—"}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -17,8 +17,13 @@ import {
|
|||||||
Truck,
|
Truck,
|
||||||
LineChart,
|
LineChart,
|
||||||
Search,
|
Search,
|
||||||
|
Settings,
|
||||||
|
ShieldCheck,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { auth, signOut } from "@/server/auth";
|
import { auth, signOut } from "@/server/auth";
|
||||||
|
import { dbForTenant } from "@/server/db";
|
||||||
|
import { hasPermission } from "@/server/rbac";
|
||||||
|
import { HREF_TO_MODULE } from "@/lib/modules";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { NavLink } from "@/components/nav-link";
|
import { NavLink } from "@/components/nav-link";
|
||||||
|
|
||||||
@@ -47,6 +52,18 @@ export default async function AppLayout({
|
|||||||
{ href: "/review", label: t("review"), icon: LineChart, enabled: false },
|
{ href: "/review", label: t("review"), icon: LineChart, enabled: false },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// Modul-Gating: deaktivierte Module werden ausgeblendet (§3.4)
|
||||||
|
const moduleRows = await dbForTenant(session.user.tenantId).tenantModule.findMany();
|
||||||
|
const disabledModules = new Set(moduleRows.filter((m) => !m.enabled).map((m) => m.moduleKey));
|
||||||
|
const moduleEnabled = (href: string) => {
|
||||||
|
const key = HREF_TO_MODULE[href];
|
||||||
|
return !key || !disabledModules.has(key);
|
||||||
|
};
|
||||||
|
const visibleNav = nav.filter((item) => !item.enabled || moduleEnabled(item.href));
|
||||||
|
|
||||||
|
const isPlatformAdmin = session.user.isPlatformAdmin;
|
||||||
|
const canManageTenant = hasPermission(session, "tenant:manage");
|
||||||
|
|
||||||
const initials = (session.user.name ?? "?")
|
const initials = (session.user.name ?? "?")
|
||||||
.split(/\s+/)
|
.split(/\s+/)
|
||||||
.map((p) => p[0])
|
.map((p) => p[0])
|
||||||
@@ -76,7 +93,7 @@ export default async function AppLayout({
|
|||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
<nav className="flex-1 space-y-0.5 overflow-y-auto px-2.5 py-3">
|
<nav className="flex-1 space-y-0.5 overflow-y-auto px-2.5 py-3">
|
||||||
{nav.map((item) =>
|
{visibleNav.map((item) =>
|
||||||
item.enabled ? (
|
item.enabled ? (
|
||||||
<NavLink key={item.href} href={item.href}>
|
<NavLink key={item.href} href={item.href}>
|
||||||
<item.icon className="size-[18px] opacity-85" />
|
<item.icon className="size-[18px] opacity-85" />
|
||||||
@@ -93,6 +110,21 @@ export default async function AppLayout({
|
|||||||
</span>
|
</span>
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{(canManageTenant || isPlatformAdmin) && (
|
||||||
|
<div className="mt-2 space-y-0.5 border-t border-sidebar-border pt-2">
|
||||||
|
{canManageTenant && (
|
||||||
|
<NavLink href="/settings">
|
||||||
|
<Settings className="size-[18px] opacity-85" /> {t("settings")}
|
||||||
|
</NavLink>
|
||||||
|
)}
|
||||||
|
{isPlatformAdmin && (
|
||||||
|
<NavLink href="/admin">
|
||||||
|
<ShieldCheck className="size-[18px] opacity-85" /> {t("admin")}
|
||||||
|
</NavLink>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</nav>
|
</nav>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,122 @@
|
|||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { requireSession } from "@/server/auth";
|
||||||
|
import { dbForTenant } from "@/server/db";
|
||||||
|
import { hasPermission } from "@/server/rbac";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { PageHead, Pill } from "@/components/mockup-ui";
|
||||||
|
import { MODULES } from "@/lib/modules";
|
||||||
|
import { updateTenantSettings } from "@/server/actions/tenant-settings";
|
||||||
|
|
||||||
|
const inputCls = "h-9 w-full rounded-md border border-input bg-transparent px-3 text-sm";
|
||||||
|
|
||||||
|
export default async function SettingsPage() {
|
||||||
|
const session = await requireSession();
|
||||||
|
if (!hasPermission(session, "tenant:manage")) redirect("/dashboard");
|
||||||
|
const db = dbForTenant(session.user.tenantId);
|
||||||
|
|
||||||
|
const [s, moduleRows] = await Promise.all([
|
||||||
|
db.tenantSettings.findUnique({ where: { tenantId: session.user.tenantId } }),
|
||||||
|
db.tenantModule.findMany(),
|
||||||
|
]);
|
||||||
|
const enabled = new Set(moduleRows.filter((m) => m.enabled).map((m) => m.moduleKey));
|
||||||
|
const v = (x?: string | null) => x ?? "";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="flex-1 p-6">
|
||||||
|
<PageHead crumb="Organisation" title="Einstellungen" sub="Unternehmensdaten, Branding, Sicherheits-Policy — Stammdaten speisen die ISMS-Variablen" />
|
||||||
|
|
||||||
|
<form action={updateTenantSettings} className="mt-4 grid gap-5 lg:grid-cols-[1fr_320px]">
|
||||||
|
<div className="space-y-5">
|
||||||
|
{/* Unternehmensdaten */}
|
||||||
|
<div className="shadow-card rounded-xl border bg-card p-5">
|
||||||
|
<p className="mb-1 font-heading text-sm font-semibold">Unternehmensdaten</p>
|
||||||
|
<p className="mb-3 text-[12px] text-muted-foreground">Diese Werte füttern automatisch die Template-Variablen des Richtlinienmoduls (eine Pflegestelle).</p>
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
<div className="md:col-span-2">
|
||||||
|
<Label htmlFor="orgName">Unternehmensname *</Label>
|
||||||
|
<Input id="orgName" name="orgName" required defaultValue={v(s?.orgName)} className="mt-1" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="orgShort">Kurzname</Label>
|
||||||
|
<Input id="orgShort" name="orgShort" defaultValue={v(s?.orgShort)} className="mt-1" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="sector">Sektor</Label>
|
||||||
|
<Input id="sector" name="sector" defaultValue={v(s?.sector)} className="mt-1" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="address">Adresse</Label>
|
||||||
|
<Input id="address" name="address" defaultValue={v(s?.address)} className="mt-1" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="duns">D-U-N-S</Label>
|
||||||
|
<Input id="duns" name="duns" defaultValue={v(s?.duns)} className="mt-1" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="ismsScope">ISMS-Geltungsbereich (Kurz)</Label>
|
||||||
|
<Input id="ismsScope" name="ismsScope" defaultValue={v(s?.ismsScope)} className="mt-1" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="ismsScopeDescription">Geltungsbereich (Beschreibung)</Label>
|
||||||
|
<Textarea id="ismsScopeDescription" name="ismsScopeDescription" rows={2} defaultValue={v(s?.ismsScopeDescription)} className="mt-1" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Verantwortliche Rollen */}
|
||||||
|
<div className="shadow-card rounded-xl border bg-card p-5">
|
||||||
|
<p className="mb-3 font-heading text-sm font-semibold">Verantwortliche Rollen (→ ISMS-Variablen)</p>
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
<div><Label htmlFor="roleManagement">Oberste Leitung (ROLE_MANAGEMENT)</Label><Input id="roleManagement" name="roleManagement" defaultValue={v(s?.roleManagement)} className="mt-1" /></div>
|
||||||
|
<div><Label htmlFor="roleIsb">ISB / CISO (ROLE_ISB)</Label><Input id="roleIsb" name="roleIsb" defaultValue={v(s?.roleIsb)} className="mt-1" /></div>
|
||||||
|
<div><Label htmlFor="roleItLead">IT-Leitung (ROLE_IT_LEAD)</Label><Input id="roleItLead" name="roleItLead" defaultValue={v(s?.roleItLead)} className="mt-1" /></div>
|
||||||
|
<div><Label htmlFor="roleDpo">Datenschutz (ROLE_DPO)</Label><Input id="roleDpo" name="roleDpo" defaultValue={v(s?.roleDpo)} className="mt-1" /></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-5">
|
||||||
|
{/* Branding & Regionales */}
|
||||||
|
<div className="shadow-card rounded-xl border bg-card p-5">
|
||||||
|
<p className="mb-3 font-heading text-sm font-semibold">Branding & Regionales</p>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div><Label htmlFor="accent">Akzentfarbe (Hex)</Label><Input id="accent" name="accent" defaultValue={v(s?.accent)} placeholder="#7d6fd6" className="mt-1" /></div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="locale">Sprache</Label>
|
||||||
|
<select id="locale" name="locale" defaultValue={s?.locale ?? "de"} className={`${inputCls} mt-1`}><option value="de">Deutsch</option><option value="en">English</option></select>
|
||||||
|
</div>
|
||||||
|
<div><Label htmlFor="timezone">Zeitzone</Label><Input id="timezone" name="timezone" defaultValue={s?.timezone ?? "Europe/Berlin"} className="mt-1" /></div>
|
||||||
|
<p className="text-[11px] text-muted-foreground">Logo-Upload (PNG/SVG) folgt in Phase 2 (Objektspeicher).</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Schutzbedarf / TISAX */}
|
||||||
|
<div className="shadow-card rounded-xl border bg-card p-5">
|
||||||
|
<p className="mb-3 font-heading text-sm font-semibold">Schutzbedarf / TISAX-Level</p>
|
||||||
|
<select name="tisaxLevel" defaultValue={s?.tisaxLevel ?? "AL2"} className={inputCls}>
|
||||||
|
<option value="AL2">AL2 — MUSS · SOLL · HOCH</option>
|
||||||
|
<option value="AL3">AL3 — zusätzlich SEHR HOCH</option>
|
||||||
|
</select>
|
||||||
|
<p className="mt-2 text-[11px] text-muted-foreground">Steuert die Schutzbedarf-Flags des Richtlinienmoduls (global; Override je Richtlinie im Editor).</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button type="submit" className="w-full justify-center">Speichern</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{/* Module (Übersicht, Freischaltung durch Plattform-Admin) */}
|
||||||
|
<div className="shadow-card mt-5 rounded-xl border bg-card p-5">
|
||||||
|
<p className="mb-3 font-heading text-sm font-semibold">Freigeschaltete Module</p>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{MODULES.map((m) => (
|
||||||
|
<Pill key={m.key} tone={enabled.has(m.key) ? "ok" : "mut"}>{m.name}</Pill>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className="mt-3 text-[11px] text-muted-foreground">Die Modul-Freischaltung erfolgt durch den Plattform-Betreiber.</p>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
/**
|
||||||
|
* Aktivierbare Module je Mandant (§3.4). `nav` verweist auf den Übersetzungs-
|
||||||
|
* schlüssel des Navigationseintrags, `href` auf die gegatete Route. Das Dashboard
|
||||||
|
* ist immer aktiv und kein Modul.
|
||||||
|
*/
|
||||||
|
export interface ModuleDef {
|
||||||
|
key: string;
|
||||||
|
name: string; // Anzeigename in der Admin-Konsole
|
||||||
|
href: string;
|
||||||
|
nav: string; // Schlüssel in messages nav.*
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MODULES: ModuleDef[] = [
|
||||||
|
{ key: "assets", name: "Asset-Inventar", href: "/assets", nav: "assets" },
|
||||||
|
{ key: "bia", name: "Business Impact Analyse", href: "/processes", nav: "bia" },
|
||||||
|
{ key: "risk", name: "Risikoanalyse", href: "/risks", nav: "risks" },
|
||||||
|
{ key: "soa", name: "SoA & Controls", href: "/soa", nav: "soa" },
|
||||||
|
{ key: "measures", name: "Maßnahmen / Reviews", href: "/measures", nav: "measures" },
|
||||||
|
{ key: "incidents", name: "Vorfälle", href: "/incidents", nav: "incidents" },
|
||||||
|
{ key: "policies", name: "Richtlinien & Verfahren", href: "/policies", nav: "policies" },
|
||||||
|
{ key: "suppliers", name: "Lieferanten- / IT-Service-Management", href: "/suppliers", nav: "suppliers" },
|
||||||
|
{ key: "dependencies", name: "Abhängigkeiten", href: "/dependencies", nav: "dependencies" },
|
||||||
|
{ key: "evidence", name: "Nachweise", href: "/evidence", nav: "evidence" },
|
||||||
|
{ key: "chat", name: "KI-Chat", href: "/chat", nav: "chat" },
|
||||||
|
{ key: "review", name: "Management-Review", href: "/review", nav: "review" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const MODULE_KEYS = MODULES.map((m) => m.key);
|
||||||
|
|
||||||
|
/** Route → Modul-Key (für den serverseitigen requireModule-Guard). */
|
||||||
|
export const HREF_TO_MODULE: Record<string, string> = Object.fromEntries(
|
||||||
|
MODULES.map((m) => [m.href, m.key])
|
||||||
|
);
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { requireSession } from "@/server/auth";
|
||||||
|
import { prisma } from "@/server/db";
|
||||||
|
import { provisionTenant } from "@/server/provision";
|
||||||
|
|
||||||
|
/** Nur Plattform-Admins (Superadmin) — mandantenübergreifend. */
|
||||||
|
async function requirePlatformAdmin() {
|
||||||
|
const session = await requireSession();
|
||||||
|
if (!session.user.isPlatformAdmin) throw new Error("Nur für Plattform-Admins.");
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
|
||||||
|
const str = (v: FormDataEntryValue | null) => (v ? String(v).trim() : "");
|
||||||
|
const slugify = (s: string) =>
|
||||||
|
s.toLowerCase().normalize("NFKD").replace(/[^\p{L}\p{N}]+/gu, "-").replace(/^-+|-+$/g, "").slice(0, 40) || "kunde";
|
||||||
|
|
||||||
|
export async function createTenant(formData: FormData) {
|
||||||
|
const session = await requirePlatformAdmin();
|
||||||
|
const name = z.string().trim().min(2).parse(formData.get("name"));
|
||||||
|
const slug = str(formData.get("slug")) || slugify(name);
|
||||||
|
const adminEmail = z.string().trim().email().parse(formData.get("adminEmail"));
|
||||||
|
const adminName = str(formData.get("adminName")) || "Administrator";
|
||||||
|
const adminPassword = z.string().min(8).parse(formData.get("adminPassword"));
|
||||||
|
const tisaxLevel = str(formData.get("tisaxLevel")) === "AL3" ? "AL3" : "AL2";
|
||||||
|
const seedPolicies = formData.get("seedPolicies") === "on";
|
||||||
|
|
||||||
|
const existing = await prisma.tenant.findUnique({ where: { slug } });
|
||||||
|
if (existing) throw new Error(`Kürzel „${slug}" ist bereits vergeben.`);
|
||||||
|
|
||||||
|
const tenant = await provisionTenant(prisma, {
|
||||||
|
name,
|
||||||
|
slug,
|
||||||
|
short: str(formData.get("short")) || undefined,
|
||||||
|
sector: str(formData.get("sector")) || undefined,
|
||||||
|
admin: { email: adminEmail, name: adminName, password: adminPassword },
|
||||||
|
tisaxLevel,
|
||||||
|
seedPoliciesDir: seedPolicies ? join(process.cwd(), "seed", "isms-vorlagenpaket-v2") : undefined,
|
||||||
|
actorId: session.user.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
revalidatePath("/admin");
|
||||||
|
redirect(`/admin?created=${tenant.id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setTenantStatus(tenantId: string, status: "ACTIVE" | "SUSPENDED" | "ARCHIVED") {
|
||||||
|
const session = await requirePlatformAdmin();
|
||||||
|
await prisma.tenant.update({ where: { id: tenantId }, data: { status } });
|
||||||
|
await prisma.auditLog.create({
|
||||||
|
data: { tenantId, scope: "platform", actorId: session.user.id, action: "update", entity: "tenant", entityId: tenantId, after: { status } },
|
||||||
|
});
|
||||||
|
revalidatePath("/admin");
|
||||||
|
revalidatePath(`/admin/${tenantId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function toggleTenantModule(tenantId: string, moduleKey: string, enabled: boolean) {
|
||||||
|
const session = await requirePlatformAdmin();
|
||||||
|
await prisma.tenantModule.upsert({
|
||||||
|
where: { tenantId_moduleKey: { tenantId, moduleKey } },
|
||||||
|
update: { enabled },
|
||||||
|
create: { tenantId, moduleKey, enabled },
|
||||||
|
});
|
||||||
|
await prisma.auditLog.create({
|
||||||
|
data: { tenantId, scope: "platform", actorId: session.user.id, action: "update", entity: "tenant_module", entityId: moduleKey, after: { enabled } },
|
||||||
|
});
|
||||||
|
revalidatePath(`/admin/${tenantId}`);
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { requireSession } from "@/server/auth";
|
||||||
|
import { dbForTenant, prisma } from "@/server/db";
|
||||||
|
import { requirePermission } from "@/server/rbac";
|
||||||
|
import { writeAuditLog } from "@/server/audit";
|
||||||
|
import { syncPolicyVariablesFromSettings } from "@/server/provision";
|
||||||
|
|
||||||
|
const str = (v: FormDataEntryValue | null) => (v ? String(v).trim() : "");
|
||||||
|
|
||||||
|
/** Kunden-Admin pflegt Stammdaten/Branding/Policy; Stammdaten speisen die ISMS-Variablen. */
|
||||||
|
export async function updateTenantSettings(formData: FormData) {
|
||||||
|
const session = await requireSession();
|
||||||
|
requirePermission(session, "tenant:manage");
|
||||||
|
const tenantId = session.user.tenantId;
|
||||||
|
const db = dbForTenant(tenantId);
|
||||||
|
|
||||||
|
const orgName = z.string().trim().min(1).parse(formData.get("orgName"));
|
||||||
|
const tisaxLevel = str(formData.get("tisaxLevel")) === "AL3" ? "AL3" : "AL2";
|
||||||
|
|
||||||
|
const data = {
|
||||||
|
orgName,
|
||||||
|
orgShort: str(formData.get("orgShort")) || null,
|
||||||
|
address: str(formData.get("address")) || null,
|
||||||
|
sector: str(formData.get("sector")) || null,
|
||||||
|
duns: str(formData.get("duns")) || null,
|
||||||
|
ismsScope: str(formData.get("ismsScope")) || null,
|
||||||
|
ismsScopeDescription: str(formData.get("ismsScopeDescription")) || null,
|
||||||
|
roleManagement: str(formData.get("roleManagement")) || null,
|
||||||
|
roleIsb: str(formData.get("roleIsb")) || null,
|
||||||
|
roleItLead: str(formData.get("roleItLead")) || null,
|
||||||
|
roleDpo: str(formData.get("roleDpo")) || null,
|
||||||
|
accent: str(formData.get("accent")) || null,
|
||||||
|
locale: str(formData.get("locale")) || "de",
|
||||||
|
timezone: str(formData.get("timezone")) || "Europe/Berlin",
|
||||||
|
tisaxLevel,
|
||||||
|
};
|
||||||
|
|
||||||
|
await db.tenantSettings.upsert({
|
||||||
|
where: { tenantId },
|
||||||
|
update: data,
|
||||||
|
create: { tenantId, ...data },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Stammdaten → ISMS-Template-Variablen (eine Pflegestelle)
|
||||||
|
await syncPolicyVariablesFromSettings(prisma, tenantId, data);
|
||||||
|
// TISAX-Level steuert die Schutzbedarf-Flags des Richtlinienmoduls
|
||||||
|
await prisma.policyVariable.updateMany({ where: { tenantId, key: "FLAG_HIGH_PROTECTION" }, data: { value: "true" } });
|
||||||
|
await prisma.policyVariable.updateMany({ where: { tenantId, key: "FLAG_VERY_HIGH_PROTECTION" }, data: { value: tisaxLevel === "AL3" ? "true" : "false" } });
|
||||||
|
|
||||||
|
await writeAuditLog({ tenantId, actorId: session.user.id, action: "update", entity: "tenant_settings", after: { orgName, tisaxLevel } });
|
||||||
|
revalidatePath("/settings");
|
||||||
|
revalidatePath("/policies");
|
||||||
|
}
|
||||||
@@ -28,6 +28,8 @@ const TENANT_MODELS = new Set<string>([
|
|||||||
"User",
|
"User",
|
||||||
"Role",
|
"Role",
|
||||||
"AuditLog",
|
"AuditLog",
|
||||||
|
"TenantSettings",
|
||||||
|
"TenantModule",
|
||||||
"Asset",
|
"Asset",
|
||||||
"AssetRelation",
|
"AssetRelation",
|
||||||
"Process",
|
"Process",
|
||||||
|
|||||||
@@ -0,0 +1,141 @@
|
|||||||
|
import { hash } from "@node-rs/argon2";
|
||||||
|
import type { PrismaClient } from "@prisma/client";
|
||||||
|
import { PERMISSIONS, ROLE_DEFS } from "@/server/rbac";
|
||||||
|
import { MODULES } from "@/lib/modules";
|
||||||
|
import { importPolicies } from "../../prisma/import-policies";
|
||||||
|
import { importManaged } from "../../prisma/import-managed";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mappt die Mandanten-Stammdaten (TenantSettings) auf die ISMS-Template-Variablen
|
||||||
|
* des Richtlinienmoduls (§4.1) — eine Pflegestelle, keine Doppeleingabe.
|
||||||
|
*/
|
||||||
|
export async function syncPolicyVariablesFromSettings(
|
||||||
|
prisma: PrismaClient,
|
||||||
|
tenantId: string,
|
||||||
|
s: {
|
||||||
|
orgName: string; orgShort?: string | null; ismsScope?: string | null; ismsScopeDescription?: string | null;
|
||||||
|
roleManagement?: string | null; roleIsb?: string | null; roleItLead?: string | null; roleDpo?: string | null;
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
const map: Record<string, string | null | undefined> = {
|
||||||
|
ORG_NAME: s.orgName,
|
||||||
|
ORG_SHORT: s.orgShort,
|
||||||
|
ISMS_SCOPE: s.ismsScope,
|
||||||
|
ISMS_SCOPE_DESCRIPTION: s.ismsScopeDescription,
|
||||||
|
ROLE_MANAGEMENT: s.roleManagement,
|
||||||
|
ROLE_ISB: s.roleIsb,
|
||||||
|
ROLE_IT_LEAD: s.roleItLead,
|
||||||
|
ROLE_DPO: s.roleDpo,
|
||||||
|
};
|
||||||
|
for (const [key, value] of Object.entries(map)) {
|
||||||
|
if (value == null || value === "") continue;
|
||||||
|
await prisma.policyVariable.updateMany({ where: { tenantId, key }, data: { value } });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProvisionOpts {
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
short?: string;
|
||||||
|
sector?: string;
|
||||||
|
admin: { email: string; name: string; password: string; isPlatformAdmin?: boolean };
|
||||||
|
tisaxLevel?: "AL2" | "AL3";
|
||||||
|
seedPoliciesDir?: string;
|
||||||
|
actorId?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Legt einen Mandanten idempotent an bzw. aktualisiert ihn: Standard-Rollen +
|
||||||
|
* Permissions, erster Admin-User, Mandanten-Einstellungen, alle Module aktiv,
|
||||||
|
* optional Richtlinienpaket-Seed inkl. TISAX-Default (§3.7 Auto-Provisioning).
|
||||||
|
*/
|
||||||
|
export async function provisionTenant(prisma: PrismaClient, opts: ProvisionOpts) {
|
||||||
|
const tisaxLevel = opts.tisaxLevel ?? "AL2";
|
||||||
|
|
||||||
|
// 1. Globaler Permission-Katalog
|
||||||
|
for (const key of PERMISSIONS) {
|
||||||
|
await prisma.permission.upsert({ where: { key }, update: {}, create: { key } });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Mandant
|
||||||
|
const tenant = await prisma.tenant.upsert({
|
||||||
|
where: { slug: opts.slug },
|
||||||
|
update: { name: opts.name, short: opts.short ?? null, sector: opts.sector ?? null },
|
||||||
|
create: { name: opts.name, slug: opts.slug, short: opts.short ?? null, sector: opts.sector ?? null },
|
||||||
|
});
|
||||||
|
|
||||||
|
// 3. Rollen + Permissions
|
||||||
|
for (const [key, def] of Object.entries(ROLE_DEFS)) {
|
||||||
|
const role = await prisma.role.upsert({
|
||||||
|
where: { tenantId_key: { tenantId: tenant.id, key } },
|
||||||
|
update: { name: def.name },
|
||||||
|
create: { tenantId: tenant.id, key, name: def.name },
|
||||||
|
});
|
||||||
|
const perms = await prisma.permission.findMany({ where: { key: { in: [...def.permissions] } } });
|
||||||
|
for (const p of perms) {
|
||||||
|
await prisma.rolePermission.upsert({
|
||||||
|
where: { roleId_permissionId: { roleId: role.id, permissionId: p.id } },
|
||||||
|
update: {},
|
||||||
|
create: { roleId: role.id, permissionId: p.id },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Erster Admin-User (Mandanten-Admin + ISB)
|
||||||
|
const passwordHash = await hash(opts.admin.password);
|
||||||
|
const admin = await prisma.user.upsert({
|
||||||
|
where: { tenantId_email: { tenantId: tenant.id, email: opts.admin.email } },
|
||||||
|
update: { name: opts.admin.name, isPlatformAdmin: opts.admin.isPlatformAdmin ?? false },
|
||||||
|
create: { tenantId: tenant.id, email: opts.admin.email, name: opts.admin.name, passwordHash, isPlatformAdmin: opts.admin.isPlatformAdmin ?? false },
|
||||||
|
});
|
||||||
|
const adminRoles = await prisma.role.findMany({ where: { tenantId: tenant.id, key: { in: ["tenant-admin", "isb"] } } });
|
||||||
|
for (const r of adminRoles) {
|
||||||
|
await prisma.userRole.upsert({
|
||||||
|
where: { userId_roleId: { userId: admin.id, roleId: r.id } },
|
||||||
|
update: {},
|
||||||
|
create: { userId: admin.id, roleId: r.id },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Mandanten-Einstellungen (Quelle der ISMS-Variablen)
|
||||||
|
await prisma.tenantSettings.upsert({
|
||||||
|
where: { tenantId: tenant.id },
|
||||||
|
update: {},
|
||||||
|
create: {
|
||||||
|
tenantId: tenant.id,
|
||||||
|
orgName: opts.name,
|
||||||
|
orgShort: opts.short ?? null,
|
||||||
|
sector: opts.sector ?? null,
|
||||||
|
roleManagement: "Geschäftsführung",
|
||||||
|
roleIsb: "Informationssicherheitsbeauftragte(r) (ISB)",
|
||||||
|
roleItLead: "IT-Leitung",
|
||||||
|
roleDpo: "Datenschutzbeauftragte(r) (DSB)",
|
||||||
|
tisaxLevel,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// 6. Alle Module aktivieren
|
||||||
|
for (const m of MODULES) {
|
||||||
|
await prisma.tenantModule.upsert({
|
||||||
|
where: { tenantId_moduleKey: { tenantId: tenant.id, moduleKey: m.key } },
|
||||||
|
update: {},
|
||||||
|
create: { tenantId: tenant.id, moduleKey: m.key, enabled: true },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7. Richtlinienpaket + TISAX-Default (falls Seed-Verzeichnis übergeben)
|
||||||
|
if (opts.seedPoliciesDir) {
|
||||||
|
await importPolicies(prisma, tenant.id, opts.seedPoliciesDir);
|
||||||
|
await importManaged(prisma, tenant.id);
|
||||||
|
await prisma.policyVariable.updateMany({ where: { tenantId: tenant.id, key: "FLAG_HIGH_PROTECTION" }, data: { value: "true" } });
|
||||||
|
await prisma.policyVariable.updateMany({ where: { tenantId: tenant.id, key: "FLAG_VERY_HIGH_PROTECTION" }, data: { value: tisaxLevel === "AL3" ? "true" : "false" } });
|
||||||
|
const settings = await prisma.tenantSettings.findUnique({ where: { tenantId: tenant.id } });
|
||||||
|
if (settings) await syncPolicyVariablesFromSettings(prisma, tenant.id, settings);
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.auditLog.create({
|
||||||
|
data: { tenantId: tenant.id, scope: "platform", actorId: opts.actorId ?? null, action: "provision", entity: "tenant", entityId: tenant.id, after: { name: opts.name, tisaxLevel } },
|
||||||
|
});
|
||||||
|
|
||||||
|
return tenant;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user