Basis: Certvia dev@a48c5fb als Fundament für Craftvia
CI / build-and-check (push) Canceled after 0s
CI / audit (push) Canceled after 0s
CI / sbom (push) Canceled after 0s

Unveränderter Stand von certvia/dev (a48c5fb) plus Craftvia-Spezifikation
und Brandbook unter docs/craftvia/. ISMS-Module werden im Folgecommit entfernt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-14 11:05:39 +02:00
co-authored by Claude Opus 5
commit c8e6f30a27
720 changed files with 140143 additions and 0 deletions
+575
View File
@@ -0,0 +1,575 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { ArrowLeft, History, Boxes, Users, DatabaseBackup, ShieldCheck, Download } from "lucide-react";
import { prisma } from "@/server/db";
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 { resolveMfaRequired } from "@/lib/mfa-policy";
import { createTenantUser, updateTenantUser, setTenantUserRoles, setTenantUserStatus } from "@/server/actions/platform-users";
import { enqueueRestore, enqueueExport, enqueueDsgvoExport } from "@/server/actions/backup-admin";
import { listSnapshots } from "@/server/backup/export";
import { getBackupStore } from "@/server/storage/backup-store";
import type { BackupManifest } from "@/server/backup/serialization";
import { Modal } from "@/components/modal";
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" };
export default async function AdminTenantPage({
params,
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 }>;
}) {
// Zugriff (Plattform-Session + MFA) wird im (platform)/layout.tsx erzwungen.
const { id } = await params;
const sp = await searchParams;
const t = await getTranslations("admin");
const tenant = await prisma.tenant.findUnique({
where: { id },
include: {
settings: true,
modules: true,
roles: { select: { id: true, key: true, name: true }, orderBy: { name: "asc" } },
users: {
select: { id: true, name: true, email: true, status: true, identityId: true, userRoles: { select: { role: { select: { id: true, name: true } } } } },
orderBy: { createdAt: "asc" },
},
},
});
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 }));
const tableUsers = tenant.users.map((u) => ({
id: u.id, name: u.name, email: u.email, status: u.status, roleNames: u.userRoles.map((ur) => ur.role.name),
}));
const editUser = sp.edit ? tenant.users.find((u) => u.id === sp.edit) : null;
const base = `/admin/${tenant.id}`;
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 —
// keine eigenen TenantSettings-Felder, keine Migration. Aktive Mitglieder mit der
// Standardrolle "tenant-admin" (RBAC-Key) gelten als Hauptkontakt.
const adminRoleIds = new Set(tenant.roles.filter((r) => r.key === "tenant-admin").map((r) => r.id));
const mainContacts = tenant.users.filter(
(u) => u.status === "ACTIVE" && u.userRoles.some((ur) => adminRoleIds.has(ur.role.id)),
);
// Audit-Trail dieses Mandanten (nur laden, wenn Popup offen). Als Superadmin
// cross-tenant über den Owner-Client, explizit auf diesen Mandanten gefiltert.
let auditRows: AuditRow[] = [];
let auditActors: Record<string, string> = {};
if (sp.audit) {
auditRows = await prisma.auditLog.findMany({
where: { tenantId: id },
orderBy: { createdAt: "desc" },
take: 200,
select: { id: true, createdAt: true, actorId: true, action: true, entity: true, entityId: true, scope: true },
});
const actorIds = [...new Set(auditRows.map((r) => r.actorId).filter(Boolean))] as string[];
if (actorIds.length) {
const users = await prisma.user.findMany({ where: { id: { in: actorIds } }, select: { id: true, name: true } });
auditActors = Object.fromEntries(users.map((u) => [u.id, u.name]));
}
}
const usersBase = `${base}?users=1`;
// ── Datensicherung & DSGVO (Betreiber-Portal) ──────────────────────────────
// Nur Voll-Admins dürfen Restore/Export/DSGVO auslösen; MFA-Status steuert das
// Step-up-Feld. Die Session ist im (platform)/layout bereits erzwungen.
const platformSession = await platformAuth();
const currentAdmin = platformSession?.user?.id
? await prisma.platformAdmin.findUnique({ where: { id: platformSession.user.id }, select: { role: true, mfaEnrolledAt: true } })
: null;
const isFullAdmin = currentAdmin?.role === "full";
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,
// 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[] = [];
let snapshotStoreError = false;
if (isFullAdmin && (sp.restore || sp.dsgvo)) {
try {
const store = await getBackupStore();
const ids = await listSnapshots(id);
for (const snapshotId of ids.reverse()) {
const raw = await store.get(`${id}/backups/${snapshotId}/manifest.json`);
let manifest: BackupManifest | null = null;
if (raw) { try { manifest = JSON.parse(raw.toString("utf8")) as BackupManifest; } catch { manifest = null; } }
snapshotOptions.push({
snapshotId,
snapshotAt: manifest?.snapshotAt ?? null,
totalRows: manifest?.totalRows ?? null,
artifactTenantSlug: manifest?.tenantSlug ?? null,
tenantMismatch: manifest ? manifest.tenantId !== id : false,
tables: (manifest?.tables ?? []).filter((tm) => tm.rowCount > 0).map((tm) => ({ model: tm.model, rows: tm.rowCount })).sort((a, b) => b.rows - a.rows),
});
}
} catch (err) {
snapshotStoreError = true;
console.error("[backup] Sicherungsspeicher nicht erreichbar (list/get):", err);
}
}
// Betroffene Personen (für Per-Person-DSGVO-Auskunft).
const subjectOptions: SubjectOption[] = tenant.users.map((u) => ({
identityId: u.identityId,
label: `${u.name} · ${u.email}`,
})).filter((s) => s.identityId);
// Letzte Jobs dieses Mandanten (Status/Ergebnis-Anzeige).
const backupJobs = isFullAdmin
? await prisma.backupJob.findMany({
where: { tenantId: id },
orderBy: { createdAt: "desc" },
take: 8,
select: { id: true, kind: true, status: true, snapshotId: true, error: true, createdAt: true, downloadToken: true, downloadExpiresAt: true },
})
: [];
const jobKindLabel: Record<string, string> = { tenant_restore: "Restore", tenant_export: "Export", dsgvo_export: "DSGVO-Export" };
const jobStatusTone: Record<string, "ok" | "warn" | "mut"> = { done: "ok", queued: "warn", running: "warn", failed: "mut" };
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" /> {t("backToOverview")}
</Link>
<div className="mt-3">
<PageHead
crumb={t("crumb")}
title={tenant.name}
sub={t("sub", { slug: tenant.slug, sector: tenant.sector ? ` · ${tenant.sector}` : "", level: s?.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]">
{/* Hauptfenster: Stammdaten + Hauptkontakt + Verwaltung (Module/Benutzer als Popup) */}
<div className="space-y-5">
{/* Stammdaten (aus TenantSettings — einzige Quelle) */}
<div className="shadow-card rounded-xl border bg-card p-5">
<p className="mb-1 font-heading text-sm font-semibold">{t("masterDataTitle")}</p>
<p className="mb-3 text-[12px] text-muted-foreground">{t("masterDataHint")}</p>
<dl className="grid gap-x-6 gap-y-2.5 sm:grid-cols-2">
<Field label={t("orgName")} value={s?.orgName ?? tenant.name} />
<Field label={t("orgShort")} value={s?.orgShort ?? tenant.short} empty={t("notSet")} />
<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("status")} value={STATUS_LABEL[tenant.status]} />
</dl>
</div>
{/* Hauptkontakt (abgeleitet aus tenant-admin) */}
<div className="shadow-card rounded-xl border bg-card p-5">
<p className="mb-1 font-heading text-sm font-semibold">{t("mainContactTitle")}</p>
<p className="mb-3 text-[12px] text-muted-foreground">{t("mainContactHint")}</p>
{mainContacts.length ? (
<ul className="space-y-2">
{mainContacts.map((c) => (
<li key={c.id} className="flex items-center justify-between gap-3 rounded-lg border bg-muted/40 px-3 py-2">
<div>
<p className="text-sm font-medium">{c.name}</p>
<p className="text-[12px] text-muted-foreground">{c.email}</p>
</div>
</li>
))}
</ul>
) : (
<p className="text-[12.5px] text-muted-foreground">{t("mainContactNone")}</p>
)}
</div>
{/* Verwaltung: Module & Benutzer als Popup (bestehendes ?param/<Modal>-Muster) */}
<div className="shadow-card rounded-xl border bg-card p-5">
<p className="mb-1 font-heading text-sm font-semibold">{t("manageTitle")}</p>
<p className="mb-3 text-[12px] text-muted-foreground">{t("manageHint")}</p>
<div className="flex flex-wrap gap-2">
<Link href={`${base}?modules=1`} scroll={false}>
<Button variant="outline" size="sm" className="gap-2"><Boxes className="size-4" /> {t("manageModules")}</Button>
</Link>
<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>
<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">{t("lifecycleTitle")}</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"}>{t("lifecycleActivate")}</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"}>{t("lifecycleSuspend")}</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"}>{t("lifecycleArchive")}</Button>
</form>
</div>
<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>
<p className="mb-3 text-[12px] text-muted-foreground">{t("mfaHint", { state: resolveMfaRequired(s?.securityPolicy) ? t("mfaStateOn") : t("mfaStateOff") })}</p>
<div className="space-y-2">
<form action={setTenantMfaRequired.bind(null, tenant.id, true)}>
<Button type="submit" variant={resolveMfaRequired(s?.securityPolicy) ? "secondary" : "outline"} size="sm" className="w-full justify-center" disabled={resolveMfaRequired(s?.securityPolicy)}>{t("mfaActivate")}</Button>
</form>
<form action={setTenantMfaRequired.bind(null, tenant.id, false)}>
<Button type="submit" variant={!resolveMfaRequired(s?.securityPolicy) ? "secondary" : "outline"} size="sm" className="w-full justify-center" disabled={!resolveMfaRequired(s?.securityPolicy)}>{t("mfaDeactivate")}</Button>
</form>
</div>
</div>
{/* Sprachwahl je Mandant (steuert die Import-Sprache des Vorlagenpakets) */}
<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>
<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>
</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>
</form>
</div>
</div>
{/* Audit-Trail (Aktivitätsprotokoll dieses Mandanten) — Popup wie gehabt */}
<div className="shadow-card rounded-xl border bg-card p-5">
<p className="mb-1 font-heading text-sm font-semibold">{t("auditTitle")}</p>
<p className="mb-3 text-[12px] text-muted-foreground">{t("auditHint")}</p>
<Link href={`${base}?audit=1`} scroll={false}>
<Button variant="outline" size="sm" className="w-full justify-center gap-2"><History className="size-4" /> {t("auditView")}</Button>
</Link>
</div>
{/* Datensicherung & DSGVO (nur Voll-Admin) — Restore ist DESTRUKTIV */}
{isFullAdmin && (
<div className="shadow-card rounded-xl border bg-card p-5">
<p className="mb-1 font-heading text-sm font-semibold">Datensicherung & DSGVO</p>
<p className="mb-3 text-[12px] text-muted-foreground">Betreiber-Aktionen: Sicherung erstellen, Mandant wiederherstellen (destruktiv, MFA + Bestätigung), DSGVO-Paket zustellen.</p>
<div className="space-y-2">
<Link href={`${base}?export=1`} scroll={false}>
<Button variant="outline" size="sm" className="w-full justify-center gap-2"><DatabaseBackup className="size-4" /> Export jetzt</Button>
</Link>
<Link href={`${base}?restore=1`} scroll={false}>
<Button variant="outline" size="sm" className="w-full justify-center gap-2 border-destructive/40 text-destructive hover:bg-destructive/10"><ShieldCheck className="size-4" /> Wiederherstellen…</Button>
</Link>
<Link href={`${base}?dsgvo=1`} scroll={false}>
<Button variant="outline" size="sm" className="w-full justify-center gap-2"><Download className="size-4" /> DSGVO-Export</Button>
</Link>
</div>
{backupJobs.length > 0 && (
<div className="mt-3 border-t pt-3">
<p className="mb-1.5 text-[11px] font-medium uppercase tracking-wide text-muted-foreground">Letzte Jobs</p>
<ul className="space-y-1.5">
{backupJobs.map((j) => {
const linkLive = j.kind === "dsgvo_export" && j.status === "done" && !!j.downloadToken && isDownloadLive(j.downloadExpiresAt);
return (
<li key={j.id} className="flex items-center justify-between gap-2 text-[12px]">
<span className="flex items-center gap-2">
<Pill tone={jobStatusTone[j.status] ?? "mut"}>{j.status}</Pill>
<span>{jobKindLabel[j.kind] ?? j.kind}</span>
</span>
{linkLive ? (
<a href={`/api/platform/dsgvo/${j.downloadToken}`} className="font-medium text-primary hover:underline">Download</a>
) : (
<span className="text-muted-foreground">{new Date(j.createdAt).toLocaleString()}</span>
)}
</li>
);
})}
</ul>
{backupJobs.some((j) => j.status === "failed") && (
<p className="mt-1.5 text-[11px] text-destructive">{backupJobs.find((j) => j.status === "failed")?.error}</p>
)}
</div>
)}
</div>
)}
</div>
</div>
{/* Module → Popup (?modules=1) */}
{sp.modules && (
<Modal title={t("modulesTitle")} sub={t("modulesModalSub", { name: tenant.name })} closeHref={base} closeLabel={t("close")}>
<div className="p-5">
<p className="mb-3 text-[12px] text-muted-foreground">{t("modulesHint")}</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>
<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>
</form>
</div>
</div>
);
})}
</div>
</div>
</Modal>
)}
{/* Benutzer → Popup (?users=1); Anlegen/Bearbeiten laufen verschachtelt (?new/?edit) */}
{sp.users && (
<Modal title={`${t("manageUsers")} — ${tenant.name}`} sub={t("usersModalSub", { name: tenant.name })} closeHref={base} closeLabel={t("close")}>
<div className="p-5">
<UserTable users={tableUsers} newHref={`${usersBase}&new=1`} editHref={(uid) => `${usersBase}&edit=${uid}`} />
</div>
</Modal>
)}
{sp.users && sp.new && (
<Modal title={t("userCreate")} sub={t("userCreateSub", { name: tenant.name })} closeHref={usersBase} closeLabel={t("close")}>
<UserCreateForm action={createTenantUser.bind(null, tenant.id)} roles={roleOptions} closeHref={usersBase} />
</Modal>
)}
{sp.users && editUser && (
<Modal title={t("userEdit", { name: editUser.name })} sub={editUser.email} closeHref={usersBase} closeLabel={t("close")}>
<UserEditForm
user={{
id: editUser.id, name: editUser.name, email: editUser.email, status: editUser.status,
roleIds: editUser.userRoles.map((ur) => ur.role.id),
}}
roles={roleOptions}
updateAction={updateTenantUser.bind(null, tenant.id, editUser.id)}
rolesAction={setTenantUserRoles.bind(null, tenant.id, editUser.id)}
statusAction={setTenantUserStatus.bind(null, tenant.id, editUser.id, editUser.status === "ACTIVE" ? "DEACTIVATED" : "ACTIVE")}
/>
</Modal>
)}
{sp.audit && (
<AuditTrailModal rows={auditRows} actorNames={auditActors} closeHref={base} sub={t("auditSub", { name: tenant.name })} />
)}
{/* Export jetzt → Popup (?export=1) */}
{isFullAdmin && sp.export && (
<Modal title="Export jetzt" sub={`On-demand-Sicherung — ${tenant.name}`} closeHref={base} closeLabel={t("close")}>
<ExportModalBody tenantId={tenant.id} action={enqueueExport} mfaEnrolled={adminMfaEnrolled} />
</Modal>
)}
{/* Portal-Restore → Popup (?restore=1) — destruktiv */}
{isFullAdmin && sp.restore && (
<Modal title="Mandant wiederherstellen" sub={`Destruktiver Restore — ${tenant.name} (${tenant.slug})`} closeHref={base} closeLabel={t("close")}>
<RestoreModalBody tenantId={tenant.id} tenantSlug={tenant.slug} snapshots={snapshotOptions} action={enqueueRestore} mfaEnrolled={adminMfaEnrolled} storeError={snapshotStoreError} />
</Modal>
)}
{/* DSGVO-Export → Popup (?dsgvo=1) */}
{isFullAdmin && sp.dsgvo && (
<Modal title="DSGVO-Export" sub={`Auskunft/Portabilität — ${tenant.name}`} closeHref={base} closeLabel={t("close")}>
<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>
);
}
/** TTL-Prüfung des DSGVO-Download-Links (Modul-Scope: hält die Render-Funktion rein). */
function isDownloadLive(expiresAt: Date | null): boolean {
return !!expiresAt && expiresAt.getTime() > Date.now();
}
/** Kleines Stammdaten-Feld (Label + Wert, Fallback bei leer). */
function Field({ label, value, empty }: { label: string; value?: string | null; empty?: string }) {
return (
<div>
<dt className="text-[11px] font-medium uppercase tracking-wide text-muted-foreground">{label}</dt>
<dd className="text-[13.5px]">{value && value.trim() ? value : (empty ?? "—")}</dd>
</div>
);
}