diff --git a/messages/de.json b/messages/de.json index ce62d4b..d05bc8b 100644 --- a/messages/de.json +++ b/messages/de.json @@ -30,7 +30,9 @@ "suppliers": "Lieferanten", "review": "Management-Review", "assets": "Asset-Inventar", - "bia": "Business Impact Analyse" + "bia": "Business Impact Analyse", + "settings": "Einstellungen", + "admin": "Admin-Konsole" }, "login": { "title": "Anmelden", diff --git a/messages/en.json b/messages/en.json index aecdc59..ef6834f 100644 --- a/messages/en.json +++ b/messages/en.json @@ -30,7 +30,9 @@ "suppliers": "Suppliers", "review": "Management review", "assets": "Asset inventory", - "bia": "Business impact analysis" + "bia": "Business impact analysis", + "settings": "Settings", + "admin": "Admin console" }, "login": { "title": "Sign in", diff --git a/prisma/migrations/20260719203024_admin_console/migration.sql b/prisma/migrations/20260719203024_admin_console/migration.sql new file mode 100644 index 0000000..03f0049 --- /dev/null +++ b/prisma/migrations/20260719203024_admin_console/migration.sql @@ -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 $$; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index e362d53..e90bfb9 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -18,6 +18,7 @@ datasource db { enum TenantStatus { ACTIVE SUSPENDED + ARCHIVED } enum UserStatus { @@ -31,6 +32,8 @@ model Tenant { id String @id @default(cuid()) name String slug String @unique + short String? + sector String? status TenantStatus @default(ACTIVE) config Json @default("{}") createdAt DateTime @default(now()) @map("created_at") @@ -39,10 +42,59 @@ model Tenant { users User[] roles Role[] auditLogs AuditLog[] + settings TenantSettings? + modules TenantModule[] @@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 { id String @id @default(cuid()) tenantId String @map("tenant_id") @@ -641,6 +693,7 @@ model MaturityAssessment { model AuditLog { id String @id @default(cuid()) tenantId String @map("tenant_id") + scope String @default("tenant") // tenant | platform actorId String? @map("actor_id") action String // create | update | delete | login | export | … entity String // z. B. asset, risk, user diff --git a/prisma/seed.ts b/prisma/seed.ts index 7006008..bcdeff3 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -6,6 +6,7 @@ import { join } from "node:path"; import { PERMISSIONS, ROLE_DEFS } from "../src/server/rbac"; import { importPolicies } from "./import-policies"; import { importManaged } from "./import-managed"; +import { MODULE_KEYS } from "../src/lib/modules"; /** * Seed: global permission catalog + demo tenant with roles and users. @@ -526,6 +527,34 @@ async function main() { 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`); + + // 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() diff --git a/src/app/(app)/admin/[id]/page.tsx b/src/app/(app)/admin/[id]/page.tsx new file mode 100644 index 0000000..556e138 --- /dev/null +++ b/src/app/(app)/admin/[id]/page.tsx @@ -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 = { ACTIVE: "ok", SUSPENDED: "warn", ARCHIVED: "mut" }; +const STATUS_LABEL: Record = { 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 ( +
+ + Zurück zur Übersicht + +
+ {STATUS_LABEL[tenant.status]}} + /> +
+ +
+ {/* Module */} +
+

Module

+

Deaktivierte Module sind für den Kunden ausgeblendet und serverseitig gesperrt.

+
+ {MODULES.map((m) => { + const on = isOn(m.key); + return ( +
+
+

{m.name}

+

{m.href}

+
+
+ +
+
+ ); + })} +
+
+ +
+ {/* Lebenszyklus */} +
+

Lebenszyklus

+
+
+ +
+
+ +
+
+ +
+
+

Löschung/Datenexport (DSGVO) und Retention folgen in Phase 2.

+
+ + {/* Nutzer */} +
+

Nutzer ({tenant.users.length})

+
    + {tenant.users.map((u) => ( +
  • + + {u.name} + {u.email} + + {u.isPlatformAdmin && Superadmin} +
  • + ))} +
+
+
+
+
+ ); +} diff --git a/src/app/(app)/admin/page.tsx b/src/app/(app)/admin/page.tsx new file mode 100644 index 0000000..389865b --- /dev/null +++ b/src/app/(app)/admin/page.tsx @@ -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 = { ACTIVE: "ok", SUSPENDED: "warn", ARCHIVED: "mut" }; +const STATUS_LABEL: Record = { 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 ( +
+ }> + Neuer Kunde + + } + /> + + {params.new && ( +
+

Neuen Kunden anlegen

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+ + +
+
+

+ Beim Anlegen werden automatisch ausgerollt: Standard-Rollen, erster Admin-User, alle Module, Mandanten-Einstellungen und der TISAX-Default (idempotent, protokolliert). +

+
+ )} + +
+ + + + Kunde + Kürzel + Status + Nutzer + Aktive Module + + + + {tenants.map((t) => { + const active = t.modules.filter((m) => m.enabled).length; + return ( + + + {t.name} + {t.sector &&
{t.sector}
} +
+ {t.slug} + {STATUS_LABEL[t.status]} + {t._count.users} + {active > 0 ? `${active} Module` : "—"} +
+ ); + })} +
+
+
+
+ ); +} diff --git a/src/app/(app)/layout.tsx b/src/app/(app)/layout.tsx index 58515fa..78e33b3 100644 --- a/src/app/(app)/layout.tsx +++ b/src/app/(app)/layout.tsx @@ -17,8 +17,13 @@ import { Truck, LineChart, Search, + Settings, + ShieldCheck, } from "lucide-react"; 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 { NavLink } from "@/components/nav-link"; @@ -47,6 +52,18 @@ export default async function AppLayout({ { 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 ?? "?") .split(/\s+/) .map((p) => p[0]) @@ -76,7 +93,7 @@ export default async function AppLayout({ diff --git a/src/app/(app)/settings/page.tsx b/src/app/(app)/settings/page.tsx new file mode 100644 index 0000000..5191b35 --- /dev/null +++ b/src/app/(app)/settings/page.tsx @@ -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 ( +
+ + +
+
+ {/* Unternehmensdaten */} +
+

Unternehmensdaten

+

Diese Werte füttern automatisch die Template-Variablen des Richtlinienmoduls (eine Pflegestelle).

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +