Fundament: ISMS-Module entfernt; Craftvia-Rollen, Module, Navigation, i18n-Split

- ISMS-Routen, Actions, Server-/Lib-Code, Komponenten, Prisma-Modelle, Seeds,
  Importer, Skripte und ISMS-Tests entfernt (Fundament bleibt: Auth, Identity,
  MFA/WebAuthn, RBAC, Audit, Mail, Storage, Backup/DSGVO, Plattform-Admin)
- Schema auf Fundament-Modelle reduziert; TenantSettings generisch (+phone/email)
- TENANT_MODELS (db.ts, backup/topology.ts) und PII-Felder ausgedünnt
- RBAC: Rollen tenant-admin/backoffice/team-lead/technician + Craftvia-Permissions
- Modul-Katalog (customers, sites, teams, work_orders, imports, field, reports,
  emergency, documents, notifications, lotse) + Navigation aus src/lib/nav.ts
- Modul-Routen mit requireModule-Layout und Platzhalterseite
- Message-Katalog je Namespace (messages/<locale>/<namespace>.json), fs-Loader
- check-module-guards: Modul-Key aus src/server/actions/<moduleKey>/
- Provisionierung, Admin-Konsole, Einstellungen, Files-Route, Mail entkoppelt
- Seed minimal (demo/demo2, Nutzer je Rolle); Fundament-Tests auf Role/
  NotificationPreference-Fixtures umgestellt

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-14 11:35:44 +02:00
co-authored by Claude Opus 5
parent c8e6f30a27
commit 8491c7f173
443 changed files with 1325 additions and 87773 deletions
+12 -168
View File
@@ -7,8 +7,7 @@ import { platformAuth } from "@/server/platform-auth";
import { Button } from "@/components/ui/button";
import { PageHead, Pill } from "@/components/mockup-ui";
import { MODULES } from "@/lib/modules";
import { setTenantStatus, toggleTenantModule, setTenantTisaxLevel, setTenantMfaRequired, setTenantLocale, importPolicyPackageForTenant, setTenantFrameworks } from "@/server/actions/admin";
import { getTenantFrameworks } from "../../../../../prisma/template-store";
import { setTenantStatus, toggleTenantModule, setTenantMfaRequired, setTenantLocale } from "@/server/actions/admin";
import { resolveMfaRequired } from "@/lib/mfa-policy";
import { createTenantUser, updateTenantUser, setTenantUserRoles, setTenantUserStatus } from "@/server/actions/platform-users";
import { enqueueRestore, enqueueExport, enqueueDsgvoExport } from "@/server/actions/backup-admin";
@@ -20,9 +19,6 @@ import { UserTable } from "@/components/user-table";
import { UserCreateForm, UserEditForm } from "@/components/user-forms";
import { AuditTrailModal, type AuditRow } from "@/components/audit-trail";
import { RestoreModalBody, ExportModalBody, DsgvoModalBody, type SnapshotOption, type SubjectOption } from "@/components/backup-admin-panel";
import { provisionIncidentIntake, setIncidentIntakeStatus } from "@/server/actions/incident-intake-admin";
import { intakeAddress } from "@/server/incident-inbound/parse";
import { Siren } from "lucide-react";
const STATUS_TONE: Record<string, "ok" | "warn" | "mut"> = { ACTIVE: "ok", SUSPENDED: "warn", ARCHIVED: "mut" };
@@ -31,7 +27,7 @@ export default async function AdminTenantPage({
searchParams,
}: {
params: Promise<{ id: string }>;
searchParams: Promise<{ new?: string; edit?: string; audit?: string; modules?: string; users?: string; restore?: string; export?: string; dsgvo?: string; intake?: string }>;
searchParams: Promise<{ new?: string; edit?: string; audit?: string; modules?: string; users?: string; restore?: string; export?: string; dsgvo?: string }>;
}) {
// Zugriff (Plattform-Session + MFA) wird im (platform)/layout.tsx erzwungen.
const { id } = await params;
@@ -52,10 +48,6 @@ export default async function AdminTenantPage({
});
if (!tenant) notFound();
const frameworks = await getTenantFrameworks(prisma, tenant.id);
const runsTisax = frameworks.includes("TISAX");
const runsIso = frameworks.includes("ISO_27001");
const STATUS_LABEL: Record<string, string> = { ACTIVE: t("statusActive"), SUSPENDED: t("statusSuspended"), ARCHIVED: t("statusArchived") };
const roleOptions = tenant.roles.map((r) => ({ id: r.id, name: r.name }));
@@ -68,12 +60,6 @@ export default async function AdminTenantPage({
const moduleState = new Map(tenant.modules.map((m) => [m.moduleKey, m.enabled]));
const isOn = (key: string) => moduleState.get(key) ?? true;
// IM-D: Intake-Konfiguration (E-Mail-Eingang für Vorfälle). Nur relevant bei aktivem Modul.
const incidentsEnabled = isOn("incidents");
const intakeConfig = incidentsEnabled
? await prisma.incidentIntakeConfig.findUnique({ where: { tenantId: id } })
: null;
const s = tenant.settings;
// Hauptkontakt (bestätigte Entscheidung): AUS den tenant-admin-Usern ABLEITEN —
@@ -115,7 +101,7 @@ export default async function AdminTenantPage({
const adminMfaEnrolled = currentAdmin?.mfaEnrolledAt != null;
// Sicherungspunkte + Dry-run-Vorschau (Manifest je Snapshot, nur wenn Popup offen).
// FAIL-SAFE: Ein nicht erreichbarer Sicherungsspeicher (S3/MinIO nicht konfiguriert,
// FAIL-SAFE: Ein nicht erreichbarer Sicherungsspeicher (S3/Garage nicht konfiguriert,
// Bucket fehlt, Creds falsch) darf die Betreiber-Konsole NICHT auf eine Fehlerseite
// werfen — die Liste bleibt dann leer und das Popup zeigt einen Hinweis.
const snapshotOptions: SnapshotOption[] = [];
@@ -170,7 +156,7 @@ export default async function AdminTenantPage({
<PageHead
crumb={t("crumb")}
title={tenant.name}
sub={t("sub", { slug: tenant.slug, sector: tenant.sector ? ` · ${tenant.sector}` : "", level: s?.tisaxLevel ?? "AL2" })}
sub={t("sub", { slug: tenant.slug, sector: tenant.sector ? ` · ${tenant.sector}` : "" })}
actions={<Pill tone={STATUS_TONE[tenant.status]}>{STATUS_LABEL[tenant.status]}</Pill>}
/>
</div>
@@ -188,9 +174,8 @@ export default async function AdminTenantPage({
<Field label={t("slug")} value={tenant.slug} />
<Field label={t("sector")} value={s?.sector ?? tenant.sector} empty={t("notSet")} />
<Field label={t("address")} value={s?.address} empty={t("notSet")} />
<Field label={t("duns")} value={s?.duns} empty={t("notSet")} />
<Field label={t("ismsScope")} value={s?.ismsScope} empty={t("notSet")} />
<Field label={t("tisaxLevel")} value={s?.tisaxLevel ?? "AL2"} />
<Field label={t("phone")} value={s?.phone} empty={t("notSet")} />
<Field label={t("email")} value={s?.email} empty={t("notSet")} />
<Field label={t("status")} value={STATUS_LABEL[tenant.status]} />
</dl>
</div>
@@ -226,20 +211,6 @@ export default async function AdminTenantPage({
<Link href={usersBase} scroll={false}>
<Button variant="outline" size="sm" className="gap-2"><Users className="size-4" /> {t("manageUsers")} · {t("usersCount", { count: tenant.users.length })}</Button>
</Link>
{incidentsEnabled && (
<Link href={`${base}?intake=1`} scroll={false}>
<Button variant="outline" size="sm" className="gap-2">
<Siren className="size-4" /> E-Mail-Eingang
{intakeConfig ? (
<Pill tone={intakeConfig.status === "verifiziert" ? "ok" : "warn"}>
{intakeConfig.status === "verifiziert" ? "verifiziert" : "ausstehend"}
</Pill>
) : (
<Pill tone="mut">nicht eingerichtet</Pill>
)}
</Button>
</Link>
)}
</div>
</div>
</div>
@@ -262,55 +233,6 @@ export default async function AdminTenantPage({
<p className="mt-3 text-[11px] text-muted-foreground">{t("lifecycleNote")}</p>
</div>
{/* Normen/Rahmenwerke je Mandant — nachträglich aktivierbar/deaktivierbar */}
<div className="shadow-card rounded-xl border bg-card p-5">
<p className="mb-1 font-heading text-sm font-semibold">{t("frameworksTitle")}</p>
<p className="mb-3 text-[12px] text-muted-foreground">
{t("frameworksHint", { list: frameworks.map((f) => (f === "TISAX" ? t("frameworksTisax") : t("frameworksIso"))).join(" + ") })}
</p>
<div className="space-y-2">
<div className="flex items-center justify-between gap-2">
<span className="text-[13px]">{t("frameworksTisax")}</span>
{runsTisax ? (
<form action={setTenantFrameworks.bind(null, tenant.id, frameworks.filter((f) => f !== "TISAX"))}>
<Button type="submit" variant="outline" size="sm" disabled={!runsIso}>{t("frameworksDeactivate")}</Button>
</form>
) : (
<form action={setTenantFrameworks.bind(null, tenant.id, [...frameworks, "TISAX" as const])}>
<Button type="submit" variant="secondary" size="sm">{t("frameworksActivate")}</Button>
</form>
)}
</div>
<div className="flex items-center justify-between gap-2">
<span className="text-[13px]">{t("frameworksIso")}</span>
{runsIso ? (
<form action={setTenantFrameworks.bind(null, tenant.id, frameworks.filter((f) => f !== "ISO_27001"))}>
<Button type="submit" variant="outline" size="sm" disabled={!runsTisax}>{t("frameworksDeactivate")}</Button>
</form>
) : (
<form action={setTenantFrameworks.bind(null, tenant.id, [...frameworks, "ISO_27001" as const])}>
<Button type="submit" variant="secondary" size="sm">{t("frameworksActivate")}</Button>
</form>
)}
</div>
</div>
<p className="mt-3 text-[11.5px] text-muted-foreground">{t("frameworksNote")}</p>
</div>
{/* Kern-Einstellung: Assessment-Level / Schutzbedarf (nur Superadmin) */}
<div className="shadow-card rounded-xl border bg-card p-5">
<p className="mb-1 font-heading text-sm font-semibold">{t("assessmentTitle")}</p>
<p className="mb-3 text-[12px] text-muted-foreground">{t("assessmentHint", { level: s?.tisaxLevel ?? "AL2" })}</p>
<div className="space-y-2">
<form action={setTenantTisaxLevel.bind(null, tenant.id, "AL2")}>
<Button type="submit" variant={(s?.tisaxLevel ?? "AL2") === "AL2" ? "secondary" : "outline"} size="sm" className="w-full justify-center" disabled={(s?.tisaxLevel ?? "AL2") === "AL2"}>{t("assessmentAl2")}</Button>
</form>
<form action={setTenantTisaxLevel.bind(null, tenant.id, "AL3")}>
<Button type="submit" variant={s?.tisaxLevel === "AL3" ? "secondary" : "outline"} size="sm" className="w-full justify-center" disabled={s?.tisaxLevel === "AL3"}>{t("assessmentAl3")}</Button>
</form>
</div>
</div>
{/* SEC3-a: MFA-Pflicht je Mandant (nur Superadmin) */}
<div className="shadow-card rounded-xl border bg-card p-5">
<p className="mb-1 font-heading text-sm font-semibold">{t("mfaTitle")}</p>
@@ -325,16 +247,16 @@ export default async function AdminTenantPage({
</div>
</div>
{/* Sprachwahl je Mandant (steuert die Import-Sprache des Vorlagenpakets) */}
{/* Standardsprache je Mandant (Benachrichtigungen/Dokumente) */}
<div className="shadow-card rounded-xl border bg-card p-5">
<p className="mb-1 font-heading text-sm font-semibold">{t("policyLangTitle")}</p>
<p className="mb-3 text-[12px] text-muted-foreground">{t("policyLangHint", { lang: s?.locale === "en" ? t("policyLangEn") : t("policyLangDe") })}</p>
<p className="mb-1 font-heading text-sm font-semibold">{t("localeTitle")}</p>
<p className="mb-3 text-[12px] text-muted-foreground">{t("localeHint", { lang: s?.locale === "en" ? t("localeEn") : t("localeDe") })}</p>
<div className="space-y-2">
<form action={setTenantLocale.bind(null, tenant.id, "de")}>
<Button type="submit" variant={(s?.locale ?? "de") === "de" ? "secondary" : "outline"} size="sm" className="w-full justify-center" disabled={(s?.locale ?? "de") === "de"}>{t("policyLangDe")}</Button>
<Button type="submit" variant={(s?.locale ?? "de") === "de" ? "secondary" : "outline"} size="sm" className="w-full justify-center" disabled={(s?.locale ?? "de") === "de"}>{t("localeDe")}</Button>
</form>
<form action={setTenantLocale.bind(null, tenant.id, "en")}>
<Button type="submit" variant={s?.locale === "en" ? "secondary" : "outline"} size="sm" className="w-full justify-center" disabled={s?.locale === "en"}>{t("policyLangEn")}</Button>
<Button type="submit" variant={s?.locale === "en" ? "secondary" : "outline"} size="sm" className="w-full justify-center" disabled={s?.locale === "en"}>{t("localeEn")}</Button>
</form>
</div>
</div>
@@ -407,16 +329,9 @@ export default async function AdminTenantPage({
<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>
<p className="text-[11px] text-muted-foreground">{m.href ?? m.key}</p>
</div>
<div className="flex items-center gap-2">
{m.key === "policies" && on && (
<form action={importPolicyPackageForTenant.bind(null, tenant.id)}>
<button type="submit" className="rounded-full border px-3 py-1 text-[12px] font-medium hover:bg-muted" title={t("importTemplatesTitle")}>
{t("importTemplates")}
</button>
</form>
)}
<Pill tone={on ? "ok" : "mut"}>{on ? t("moduleActive") : t("moduleInactive")}</Pill>
<form action={toggleTenantModule.bind(null, tenant.id, m.key, !on)}>
<Button type="submit" size="sm" variant="outline">{on ? t("moduleDeactivate") : t("moduleActivate")}</Button>
@@ -484,77 +399,6 @@ export default async function AdminTenantPage({
<DsgvoModalBody tenantId={tenant.id} action={enqueueDsgvoExport} subjects={subjectOptions} mfaEnrolled={adminMfaEnrolled} />
</Modal>
)}
{/* IM-D: E-Mail-Eingang provisionieren → Popup (?intake=1) */}
{incidentsEnabled && sp.intake && (
<Modal title="E-Mail-Eingang für Vorfälle" sub={`Intake-Provisionierung — ${tenant.name}`} closeHref={base} closeLabel={t("close")}>
<div className="space-y-5 p-5">
<div className="space-y-1.5">
<p className="text-[11px] font-medium uppercase tracking-wide text-muted-foreground">Intake-Adresse</p>
{intakeConfig ? (
<div className="flex flex-wrap items-center gap-2">
<code className="rounded-md border bg-muted px-2.5 py-1.5 text-[13px] font-mono select-all">{intakeAddress(intakeConfig.token)}</code>
<Pill tone={intakeConfig.status === "verifiziert" ? "ok" : "warn"}>
{intakeConfig.status === "verifiziert" ? "verifiziert" : "Weiterleitung ausstehend"}
</Pill>
</div>
) : (
<p className="text-[12.5px] text-muted-foreground">Wird beim Speichern erzeugt (global eindeutiger Token).</p>
)}
<p className="text-[11.5px] text-muted-foreground">
Der Kunde richtet eine Weiterleitung von seiner Adresse auf diese Intake-Adresse ein. Aus jeder
eingehenden Mail (erlaubte Domäne + DKIM) wird automatisch ein Vorfall.
</p>
</div>
<form action={provisionIncidentIntake.bind(null, tenant.id)} className="space-y-3 border-t pt-4">
<div className="space-y-1.5">
<label htmlFor="intake-domains" className="text-[12.5px] font-medium">Erlaubte Absender-Domänen</label>
<textarea
id="intake-domains"
name="allowlistDomains"
rows={3}
className="w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm"
placeholder={"kunde.de\nit.kunde.de"}
defaultValue={(intakeConfig?.allowlistDomains ?? []).join("\n")}
/>
<p className="text-[11px] text-muted-foreground">Eine je Zeile. Pflicht für den Automatikbetrieb.</p>
</div>
<div className="space-y-1.5">
<label htmlFor="intake-source" className="text-[12.5px] font-medium">Quelladresse (optional)</label>
<input
id="intake-source"
name="sourceAddress"
type="email"
className="h-9 w-full rounded-md border border-input bg-transparent px-3 text-sm"
placeholder="vorfall@kunde.de"
defaultValue={intakeConfig?.sourceAddress ?? ""}
/>
</div>
<div className="flex justify-end">
<Button type="submit" size="sm">{intakeConfig ? "Speichern" : "Intake anlegen"}</Button>
</div>
</form>
{intakeConfig && (
<div className="flex items-center justify-between border-t pt-4">
<p className="text-[12px] text-muted-foreground">
Status manuell setzen (i. d. R. automatisch bei erster Test-Mail).
</p>
{intakeConfig.status === "verifiziert" ? (
<form action={setIncidentIntakeStatus.bind(null, tenant.id, false)}>
<Button type="submit" variant="ghost" size="sm">Auf „ausstehend“ zurücksetzen</Button>
</form>
) : (
<form action={setIncidentIntakeStatus.bind(null, tenant.id, true)}>
<Button type="submit" variant="outline" size="sm">Als verifiziert markieren</Button>
</form>
)}
</div>
)}
</div>
</Modal>
)}
</main>
);
}