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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user