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, 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"; 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 { TrialAdminCard } from "@/components/trial/trial-admin-card"; const STATUS_TONE: Record = { 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; trial?: string; invited?: string; trialDone?: 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 STATUS_LABEL: Record = { 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; 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 = {}; 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/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[] = []; 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 = { tenant_restore: "Restore", tenant_export: "Export", dsgvo_export: "DSGVO-Export" }; const jobStatusTone: Record = { done: "ok", queued: "warn", running: "warn", failed: "mut" }; return (
{t("backToOverview")}
{STATUS_LABEL[tenant.status]}} />
{/* Hauptfenster: Stammdaten + Hauptkontakt + Verwaltung (Module/Benutzer als Popup) */}
{/* Stammdaten (aus TenantSettings — einzige Quelle) */}

{t("masterDataTitle")}

{t("masterDataHint")}

{/* Hauptkontakt (abgeleitet aus tenant-admin) */}

{t("mainContactTitle")}

{t("mainContactHint")}

{mainContacts.length ? (
    {mainContacts.map((c) => (
  • {c.name}

    {c.email}

  • ))}
) : (

{t("mainContactNone")}

)}
{/* Verwaltung: Module & Benutzer als Popup (bestehendes ?param/-Muster) */}

{t("manageTitle")}

{t("manageHint")}

{/* L15 Testphase: Enddatum, Umwandlung, Beenden, Löschung (Bestätigung + Plattform-Audit) */} {/* Lebenszyklus */}

{t("lifecycleTitle")}

{t("lifecycleNote")}

{/* SEC3-a: MFA-Pflicht je Mandant (nur Superadmin) */}

{t("mfaTitle")}

{t("mfaHint", { state: resolveMfaRequired(s?.securityPolicy) ? t("mfaStateOn") : t("mfaStateOff") })}

{/* Standardsprache je Mandant (Benachrichtigungen/Dokumente) */}

{t("localeTitle")}

{t("localeHint", { lang: s?.locale === "en" ? t("localeEn") : t("localeDe") })}

{/* Audit-Trail (Aktivitätsprotokoll dieses Mandanten) — Popup wie gehabt */}

{t("auditTitle")}

{t("auditHint")}

{/* Datensicherung & DSGVO (nur Voll-Admin) — Restore ist DESTRUKTIV */} {isFullAdmin && (

Datensicherung & DSGVO

Betreiber-Aktionen: Sicherung erstellen, Mandant wiederherstellen (destruktiv, MFA + Bestätigung), DSGVO-Paket zustellen.

{backupJobs.length > 0 && (

Letzte Jobs

    {backupJobs.map((j) => { const linkLive = j.kind === "dsgvo_export" && j.status === "done" && !!j.downloadToken && isDownloadLive(j.downloadExpiresAt); return (
  • {j.status} {jobKindLabel[j.kind] ?? j.kind} {linkLive ? ( Download ) : ( {new Date(j.createdAt).toLocaleString()} )}
  • ); })}
{backupJobs.some((j) => j.status === "failed") && (

{backupJobs.find((j) => j.status === "failed")?.error}

)}
)}
)}
{/* Module → Popup (?modules=1) */} {sp.modules && (

{t("modulesHint")}

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

{m.name}

{m.href ?? m.key}

{on ? t("moduleActive") : t("moduleInactive")}
); })}
)} {/* Benutzer → Popup (?users=1); Anlegen/Bearbeiten laufen verschachtelt (?new/?edit) */} {sp.users && (
`${usersBase}&edit=${uid}`} />
)} {sp.users && sp.new && ( )} {sp.users && editUser && ( 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")} /> )} {sp.audit && ( )} {/* Export jetzt → Popup (?export=1) */} {isFullAdmin && sp.export && ( )} {/* Portal-Restore → Popup (?restore=1) — destruktiv */} {isFullAdmin && sp.restore && ( )} {/* DSGVO-Export → Popup (?dsgvo=1) */} {isFullAdmin && sp.dsgvo && ( )}
); } /** 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 (
{label}
{value && value.trim() ? value : (empty ?? "—")}
); }