Basis: Certvia dev@a48c5fb als Fundament für Craftvia
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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import Link from "next/link";
|
||||
import { ArrowLeft, DatabaseBackup } from "lucide-react";
|
||||
import { prisma } from "@/server/db";
|
||||
import { platformAuth } from "@/server/platform-auth";
|
||||
import { PageHead } from "@/components/mockup-ui";
|
||||
import { getBackupTargetView, saveBackupTarget, testBackupConnection } from "@/server/actions/backup-settings";
|
||||
import { BackupTargetForm } from "@/components/backup-target-form";
|
||||
|
||||
/**
|
||||
* Betreiber-Portal → Backup-Zielspeicher (Lane „Konfigurierbarer Backup-Zielspeicher").
|
||||
* Zugriff: Plattform-Session + MFA-Policy erzwingt bereits das (platform)/layout;
|
||||
* die Zielspeicher-Konfiguration ist zusätzlich auf Voll-Admins beschränkt
|
||||
* (Server-Actions rufen requirePlatformFullAdmin; hier wird Read-only sauber abgefangen).
|
||||
*/
|
||||
export default async function BackupTargetPage() {
|
||||
const session = await platformAuth();
|
||||
const admin = session?.user?.id
|
||||
? await prisma.platformAdmin.findUnique({
|
||||
where: { id: session.user.id },
|
||||
select: { role: true, mfaEnrolledAt: true },
|
||||
})
|
||||
: null;
|
||||
const isFullAdmin = admin?.role === "full";
|
||||
|
||||
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" /> Zur Mandantenverwaltung
|
||||
</Link>
|
||||
<div className="mt-3">
|
||||
<PageHead
|
||||
crumb="Plattform-Betrieb"
|
||||
title="Backup-Zielspeicher"
|
||||
sub="Ziel der Backup-/DSGVO-Artefakte konfigurieren: lokal (persistentes Volume) oder S3/MinIO."
|
||||
/>
|
||||
</div>
|
||||
|
||||
{!isFullAdmin ? (
|
||||
<div className="shadow-card mt-4 max-w-2xl rounded-xl border bg-card p-5">
|
||||
<p className="text-[13px] text-muted-foreground">
|
||||
Diese Einstellung ist nur für <strong>Voll-Administratoren</strong> zugänglich (Betreiber-Config mit Credentials).
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-4 max-w-2xl space-y-4">
|
||||
<div className="shadow-card rounded-xl border bg-card p-5">
|
||||
<p className="mb-1 flex items-center gap-2 font-heading text-sm font-semibold">
|
||||
<DatabaseBackup className="size-4" /> Zielspeicher
|
||||
</p>
|
||||
<p className="mb-4 text-[12px] text-muted-foreground">
|
||||
Die Auswahl gilt für neue Sicherungen, Restores und DSGVO-Pakete. „Verbindung testen“ legt kurz ein
|
||||
winziges Test-Objekt an (put/get/remove) und entfernt es sofort wieder. Das S3-Secret wird verschlüsselt
|
||||
gespeichert; die <strong>Artefakt-Verschlüsselung</strong> (BACKUP_ENC_KEY) ist davon getrennt.
|
||||
</p>
|
||||
<BackupTargetForm
|
||||
initial={await getBackupTargetView()}
|
||||
saveAction={saveBackupTarget}
|
||||
testAction={testBackupConnection}
|
||||
mfaEnrolled={admin?.mfaEnrolledAt != null}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
Hinweis: Bestehende Env-Deployments (S3_*/BACKUP_LOCAL_DIR) laufen unverändert weiter — sobald hier ein Ziel
|
||||
gespeichert wird, hat die DB-Config Vorrang vor den Env-Variablen.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
import Link from "next/link";
|
||||
import { Plus } from "lucide-react";
|
||||
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";
|
||||
import { getMailStatus } from "@/server/actions/mail";
|
||||
import { MailStatusPanel } from "@/components/mail-status-panel";
|
||||
import { resolveInboundReview } from "@/server/actions/incident-intake-admin";
|
||||
import { intakeAddress } from "@/server/incident-inbound/parse";
|
||||
import { Siren } from "lucide-react";
|
||||
|
||||
const REVIEW_REASON_LABEL: Record<string, string> = {
|
||||
no_token: "kein Token",
|
||||
unknown_token: "unbekannter Token",
|
||||
allowlist_failed: "Absender nicht in Allowlist",
|
||||
dkim_failed: "DKIM fehlgeschlagen",
|
||||
};
|
||||
|
||||
const STATUS_TONE: Record<string, "ok" | "warn" | "mut"> = { ACTIVE: "ok", SUSPENDED: "warn", ARCHIVED: "mut" };
|
||||
const STATUS_LABEL: Record<string, string> = { 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 }> }) {
|
||||
// Zugriff (Plattform-Session + MFA) wird im (platform)/layout.tsx erzwungen.
|
||||
const params = await searchParams;
|
||||
|
||||
const [tenants, mailStatus, pendingIntake, openReviews] = await Promise.all([
|
||||
prisma.tenant.findMany({
|
||||
include: { _count: { select: { users: true } }, modules: true },
|
||||
orderBy: { createdAt: "asc" },
|
||||
}),
|
||||
// SEC1: Betriebszustand der Mail-Strecke + Testversand.
|
||||
getMailStatus(),
|
||||
// IM-D: Kunden, deren E-Mail-Weiterleitung noch nicht verifiziert ist.
|
||||
prisma.incidentIntakeConfig.findMany({
|
||||
where: { status: "weiterleitung_ausstehend" },
|
||||
select: { tenantId: true, token: true },
|
||||
}),
|
||||
// IM-D: Inbound-Mails ohne/mit unbekanntem Token → Betreiber-Sichtung.
|
||||
prisma.incidentInboundReview.findMany({
|
||||
where: { status: "offen" },
|
||||
orderBy: { receivedAt: "desc" },
|
||||
take: 50,
|
||||
select: { id: true, sender: true, subject: true, recipient: true, reason: true, receivedAt: true },
|
||||
}),
|
||||
]);
|
||||
// Mandantennamen zu den offenen Verifizierungen (mandantenübergreifende Betreiber-Sicht).
|
||||
const pendingTenantNames = new Map(
|
||||
tenants.filter((t) => pendingIntake.some((p) => p.tenantId === t.id)).map((t) => [t.id, t.name]),
|
||||
);
|
||||
|
||||
return (
|
||||
<main className="flex-1 p-6">
|
||||
<PageHead
|
||||
crumb="Plattform-Betrieb"
|
||||
title="Admin-Konsole — Mandantenverwaltung"
|
||||
sub="Kunden (Mandanten) anlegen, provisionieren, Module & Lebenszyklus verwalten"
|
||||
actions={
|
||||
<span className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" nativeButton={false} render={<Link href="/admins" />}>Administratoren</Button>
|
||||
<Button nativeButton={false} render={<Link href={params.new ? "/admin" : "/admin?new=1"} />}>
|
||||
<Plus className="size-4" /> Neuer Kunde
|
||||
</Button>
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
|
||||
{params.new && (
|
||||
<div className="shadow-card mt-4 rounded-xl border bg-card p-5">
|
||||
<p className="mb-3 font-heading text-sm font-semibold">Neuen Kunden anlegen</p>
|
||||
<form action={createTenant} className="grid gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<Label htmlFor="name">Firmenname *</Label>
|
||||
<Input id="name" name="name" required className="mt-1" placeholder="Muster GmbH" />
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="short">Kurzname</Label>
|
||||
<Input id="short" name="short" className="mt-1" placeholder="Muster" />
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="slug">Kürzel/Slug (optional)</Label>
|
||||
<Input id="slug" name="slug" className="mt-1" placeholder="wird aus Name erzeugt" />
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="sector">Sektor</Label>
|
||||
<Input id="sector" name="sector" className="mt-1" placeholder="z. B. Automotive-Zulieferer" />
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="adminEmail">Admin-E-Mail *</Label>
|
||||
<Input id="adminEmail" name="adminEmail" type="email" required className="mt-1" placeholder="admin@muster.example" />
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="adminName">Admin-Name</Label>
|
||||
<Input id="adminName" name="adminName" className="mt-1" placeholder="Vor- und Nachname" />
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="adminPassword">Initial-Passwort * (min. 8)</Label>
|
||||
<Input id="adminPassword" name="adminPassword" type="text" required className="mt-1" placeholder="wird dem Admin mitgeteilt" />
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="tisaxLevel">TISAX-Level</Label>
|
||||
<select id="tisaxLevel" name="tisaxLevel" defaultValue="AL2" className={`${inputCls} mt-1`}>
|
||||
<option value="AL2">AL2 (MUSS · SOLL · HOCH)</option>
|
||||
<option value="AL3">AL3 (+ SEHR HOCH)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="md:col-span-2">
|
||||
<Label>Rahmenwerk(e)</Label>
|
||||
<div className="mt-1 flex flex-wrap gap-4 text-sm">
|
||||
<label className="flex items-center gap-2">
|
||||
<input type="checkbox" name="fw_tisax" defaultChecked /> TISAX / VDA ISA
|
||||
</label>
|
||||
<label className="flex items-center gap-2">
|
||||
<input type="checkbox" name="fw_iso" /> ISO/IEC 27001
|
||||
</label>
|
||||
</div>
|
||||
<p className="mt-1 text-[11.5px] text-muted-foreground">
|
||||
Mind. eines wählen (nichts gewählt = TISAX). Bei beiden ist TISAX das Primär-Framework; die Anforderungssichten koexistieren je Dokument.
|
||||
</p>
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-sm md:col-span-2">
|
||||
<input type="checkbox" name="seedPolicies" defaultChecked /> Richtlinienpaket beim Anlegen ausrollen (je gewähltem Rahmenwerk)
|
||||
</label>
|
||||
<div className="flex gap-2 md:col-span-2">
|
||||
<Button type="submit">Kunde anlegen & provisionieren</Button>
|
||||
<Button variant="outline" nativeButton={false} render={<Link href="/admin" />}>Abbrechen</Button>
|
||||
</div>
|
||||
</form>
|
||||
<p className="mt-3 text-[11.5px] text-muted-foreground">
|
||||
Beim Anlegen werden automatisch ausgerollt: Standard-Rollen, erster Admin-User, alle Module, Mandanten-Einstellungen und der TISAX-Default (idempotent, protokolliert).
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="shadow-card mt-4 rounded-xl border bg-card">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Kunde</TableHead>
|
||||
<TableHead>Kürzel</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Nutzer</TableHead>
|
||||
<TableHead>Aktive Module</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{tenants.map((t) => {
|
||||
const active = t.modules.filter((m) => m.enabled).length;
|
||||
return (
|
||||
<TableRow key={t.id}>
|
||||
<TableCell>
|
||||
<Link href={`/admin/${t.id}`} className="font-bold hover:underline">{t.name}</Link>
|
||||
{t.sector && <div className="text-xs text-muted-foreground">{t.sector}</div>}
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground">{t.slug}</TableCell>
|
||||
<TableCell><Pill tone={STATUS_TONE[t.status]}>{STATUS_LABEL[t.status]}</Pill></TableCell>
|
||||
<TableCell className="text-muted-foreground">{t._count.users}</TableCell>
|
||||
<TableCell className="text-muted-foreground">{active > 0 ? `${active} Module` : "—"}</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{/* IM-D: E-Mail-Eingang für Vorfälle — Provisionierungsstatus + Review-Queue. */}
|
||||
{(pendingIntake.length > 0 || openReviews.length > 0) && (
|
||||
<div className="shadow-card mt-4 rounded-xl border bg-card p-5">
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<Siren className="size-4 text-[var(--primary)]" />
|
||||
<p className="font-heading text-sm font-semibold">E-Mail-Eingang für Vorfälle</p>
|
||||
</div>
|
||||
|
||||
{pendingIntake.length > 0 && (
|
||||
<div className="mb-4">
|
||||
<p className="mb-1.5 text-[12.5px] font-semibold text-muted-foreground">
|
||||
Weiterleitung ausstehend ({pendingIntake.length})
|
||||
</p>
|
||||
<ul className="space-y-1.5">
|
||||
{pendingIntake.map((p) => (
|
||||
<li key={p.tenantId} className="flex flex-wrap items-center gap-2 text-[12.5px]">
|
||||
<Pill tone="warn">ausstehend</Pill>
|
||||
<Link href={`/admin/${p.tenantId}`} className="font-semibold hover:underline">
|
||||
{pendingTenantNames.get(p.tenantId) ?? p.tenantId}
|
||||
</Link>
|
||||
<code className="font-mono text-muted-foreground">{intakeAddress(p.token)}</code>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<p className="mt-1.5 text-[11px] text-muted-foreground">
|
||||
Der Status wechselt automatisch auf „verifiziert“, sobald die erste weitergeleitete Test-Mail als Vorfall ankommt.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{openReviews.length > 0 && (
|
||||
<div className="border-t pt-3">
|
||||
<p className="mb-1.5 text-[12.5px] font-semibold text-muted-foreground">
|
||||
Zu prüfende Eingänge ({openReviews.length})
|
||||
</p>
|
||||
<ul className="space-y-2">
|
||||
{openReviews.map((r) => (
|
||||
<li key={r.id} className="flex flex-wrap items-center gap-2 rounded-lg border bg-muted/30 px-3 py-2 text-[12px]">
|
||||
<Pill tone="mut">{REVIEW_REASON_LABEL[r.reason] ?? r.reason}</Pill>
|
||||
<span className="font-mono">{r.sender}</span>
|
||||
<span className="text-muted-foreground">{r.subject || "(ohne Betreff)"}</span>
|
||||
{r.recipient && <span className="text-muted-foreground">→ {r.recipient}</span>}
|
||||
<span className="ml-auto flex gap-1.5">
|
||||
<form action={resolveInboundReview.bind(null, r.id, "zugeordnet")}>
|
||||
<Button type="submit" variant="outline" size="sm">Zugeordnet</Button>
|
||||
</form>
|
||||
<form action={resolveInboundReview.bind(null, r.id, "erledigt")}>
|
||||
<Button type="submit" variant="ghost" size="sm">Erledigt</Button>
|
||||
</form>
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<MailStatusPanel status={mailStatus} />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import Link from "next/link";
|
||||
import { prisma } from "@/server/db";
|
||||
import { requirePlatformSession } from "@/server/platform-auth";
|
||||
import { PageHead, Pill } from "@/components/mockup-ui";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { CreatePlatformAdminForm, ResetPlatformAdminPasswordForm } from "@/components/platform-admin-forms";
|
||||
import { setPlatformAdminRole, setPlatformAdminStatus } from "@/server/actions/platform-admins";
|
||||
|
||||
/**
|
||||
* SEC4: Verwaltung der Plattform-Administratoren. Zugriff (Plattform-Session + MFA-Pflicht)
|
||||
* wird im (platform)/layout erzwungen. Verwalten dürfen nur Voll-Admins; Read-only sieht nur.
|
||||
*/
|
||||
const STATUS_TONE: Record<string, "ok" | "warn" | "mut"> = { ACTIVE: "ok", LOCKED: "warn", DISABLED: "mut" };
|
||||
const STATUS_LABEL: Record<string, string> = { ACTIVE: "Aktiv", LOCKED: "Gesperrt", DISABLED: "Deaktiviert" };
|
||||
|
||||
export default async function PlatformAdminsPage() {
|
||||
const session = await requirePlatformSession();
|
||||
const me = await prisma.platformAdmin.findUnique({
|
||||
where: { id: session.user.id },
|
||||
select: { id: true, role: true, mfaEnrolledAt: true },
|
||||
});
|
||||
const isFull = me?.role === "full";
|
||||
const actingMfa = !!me?.mfaEnrolledAt;
|
||||
const admins = await prisma.platformAdmin.findMany({
|
||||
orderBy: { createdAt: "asc" },
|
||||
select: { id: true, email: true, name: true, role: true, status: true, mfaEnrolledAt: true, lastLoginAt: true },
|
||||
});
|
||||
|
||||
return (
|
||||
<main className="flex-1 p-6">
|
||||
<PageHead
|
||||
crumb="Plattform-Betrieb"
|
||||
title="Plattform-Administratoren"
|
||||
sub={`${admins.length} Konten · Ihre Rolle: ${isFull ? "Voll-Admin" : "Read-only"}`}
|
||||
actions={<Button variant="outline" size="sm" nativeButton={false} render={<Link href="/admin" />}>Mandanten</Button>}
|
||||
/>
|
||||
{!isFull && <p className="mt-4 text-[12.5px] text-muted-foreground">Read-only-Zugriff: Sie können Admins ansehen, aber nicht verwalten.</p>}
|
||||
|
||||
<div className="mt-4 space-y-3">
|
||||
{admins.map((a) => (
|
||||
<div key={a.id} className="shadow-card rounded-xl border bg-card p-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div>
|
||||
<p className="font-heading text-sm font-semibold">{a.name}{a.id === me?.id && <span className="text-muted-foreground"> (Sie)</span>}</p>
|
||||
<p className="text-[11.5px] text-muted-foreground">{a.email} · letzter Login {a.lastLoginAt ? a.lastLoginAt.toLocaleDateString("de-DE") : "—"}</p>
|
||||
</div>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Pill tone={a.role === "full" ? "info" : "mut"}>{a.role === "full" ? "Voll-Admin" : "Read-only"}</Pill>
|
||||
<Pill tone={STATUS_TONE[a.status] ?? "mut"}>{STATUS_LABEL[a.status] ?? a.status}</Pill>
|
||||
{a.mfaEnrolledAt && <Pill tone="ok">MFA</Pill>}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{isFull && (
|
||||
<div className="mt-3 flex flex-wrap items-end gap-2 border-t pt-3">
|
||||
<form action={setPlatformAdminRole.bind(null, a.id, a.role === "full" ? "readonly" : "full")} className="flex items-end gap-1.5">
|
||||
{actingMfa && <Input name="token" inputMode="numeric" autoComplete="one-time-code" placeholder="MFA" className="h-8 w-24" required />}
|
||||
<Button type="submit" size="sm" variant="outline">{a.role === "full" ? "→ Read-only" : "→ Voll-Admin"}</Button>
|
||||
</form>
|
||||
{a.status === "ACTIVE" ? (
|
||||
<>
|
||||
<form action={setPlatformAdminStatus.bind(null, a.id, "LOCKED")} className="flex items-end gap-1.5">
|
||||
{actingMfa && <Input name="token" inputMode="numeric" autoComplete="one-time-code" placeholder="MFA" className="h-8 w-24" required />}
|
||||
<Button type="submit" size="sm" variant="outline">Sperren</Button>
|
||||
</form>
|
||||
<form action={setPlatformAdminStatus.bind(null, a.id, "DISABLED")} className="flex items-end gap-1.5">
|
||||
{actingMfa && <Input name="token" inputMode="numeric" autoComplete="one-time-code" placeholder="MFA" className="h-8 w-24" required />}
|
||||
<Button type="submit" size="sm" variant="outline">Deaktivieren</Button>
|
||||
</form>
|
||||
</>
|
||||
) : (
|
||||
<form action={setPlatformAdminStatus.bind(null, a.id, "ACTIVE")} className="flex items-end gap-1.5">
|
||||
{actingMfa && <Input name="token" inputMode="numeric" autoComplete="one-time-code" placeholder="MFA" className="h-8 w-24" required />}
|
||||
<Button type="submit" size="sm" variant="outline">Reaktivieren</Button>
|
||||
</form>
|
||||
)}
|
||||
<ResetPlatformAdminPasswordForm adminId={a.id} mfaEnrolled={actingMfa} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{isFull && (
|
||||
<div className="shadow-card mt-6 max-w-2xl rounded-xl border bg-card p-5">
|
||||
<p className="mb-1 font-heading text-sm font-semibold">Neuen Plattform-Admin anlegen</p>
|
||||
<p className="mb-3 text-[12px] text-muted-foreground">Read-only-Admins können ansehen, aber nicht verändern. Voll-Admins verwalten Mandanten und Admins.</p>
|
||||
<CreatePlatformAdminForm mfaEnrolled={actingMfa} />
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
import { platformAuth } from "@/server/platform-auth";
|
||||
import { prisma } from "@/server/db";
|
||||
import { getPlatformSettings } from "@/server/platform-settings";
|
||||
import { platformSignOutAction } from "@/server/actions/platform";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { CertviaLogo } from "@/components/brand/certvia-logo";
|
||||
import { BRAND } from "@/lib/brand";
|
||||
|
||||
/**
|
||||
* Shell des Plattform-Betriebsbereichs (Phase-1-Härtung Paket 2). Zugriff nur mit
|
||||
* Plattform-Session (getrennte Auth-Domäne, kein Mandantenkontext) und aktivierter
|
||||
* MFA. Enthält bewusst KEINE Mandanten-Navigation — Plattform-Admins haben keinen
|
||||
* Zugriff auf Kundenfachdaten.
|
||||
*/
|
||||
export default async function PlatformLayout({ children }: Readonly<{ children: React.ReactNode }>) {
|
||||
const session = await platformAuth();
|
||||
if (!session?.user?.id) redirect("/platform/login");
|
||||
|
||||
const [admin, settings] = await Promise.all([
|
||||
prisma.platformAdmin.findUnique({ where: { id: session.user.id } }),
|
||||
getPlatformSettings(),
|
||||
]);
|
||||
if (!admin || admin.status !== "ACTIVE") redirect("/platform/login");
|
||||
// MFA ist optional (Paket C) — Enrollment nur erzwingen, wenn die Plattform-Policy es verlangt.
|
||||
if (settings.mfaRequired && !admin.mfaEnrolledAt) redirect("/platform/enroll-mfa");
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col">
|
||||
<header className="flex items-center justify-between border-b bg-[var(--panel)] px-6 py-3">
|
||||
<Link href="/admin" className="flex items-center gap-2.5 font-heading text-sm font-semibold">
|
||||
<CertviaLogo variant="mark" theme="dark" height={22} />
|
||||
{BRAND.name} · Plattform-Administration
|
||||
</Link>
|
||||
<div className="flex items-center gap-4 text-sm">
|
||||
<Link href="/templates" className="text-muted-foreground hover:text-foreground">Vorlagen</Link>
|
||||
<Link href="/admin/backup" className="text-muted-foreground hover:text-foreground">Backup-Ziel</Link>
|
||||
<span className="text-muted-foreground">
|
||||
{admin.name} · {admin.email}
|
||||
</span>
|
||||
<Link href="/profile" className="text-muted-foreground hover:text-foreground">Profil & Sicherheit</Link>
|
||||
<form action={platformSignOutAction}>
|
||||
<Button type="submit" variant="ghost" size="sm">Abmelden</Button>
|
||||
</form>
|
||||
</div>
|
||||
</header>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
import { platformAuth } from "@/server/platform-auth";
|
||||
import { prisma } from "@/server/db";
|
||||
import { getPlatformSettings } from "@/server/platform-settings";
|
||||
import { disablePlatformMfa, setPlatformMfaRequired } from "@/server/actions/platform";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { PageHead, Pill } from "@/components/mockup-ui";
|
||||
import { ChangeEmailForm, ChangePasswordSelfForm } from "@/components/auth-recovery-forms";
|
||||
import { describePasswordPolicy, resolvePasswordPolicy } from "@/lib/password-policy";
|
||||
|
||||
/**
|
||||
* Profil & Sicherheit des Plattform-Admins (Paket C): MFA ist optional und kann hier
|
||||
* freiwillig aktiviert/deaktiviert werden. Zusätzlich lässt sich die plattformweite
|
||||
* MFA-Pflicht (Policy-Flag) schalten, um die Erzwingung wiederherzustellen.
|
||||
*/
|
||||
export default async function PlatformProfilePage() {
|
||||
const session = await platformAuth();
|
||||
if (!session?.user?.id) redirect("/platform/login");
|
||||
const [admin, settings] = await Promise.all([
|
||||
prisma.platformAdmin.findUnique({ where: { id: session.user.id } }),
|
||||
getPlatformSettings(),
|
||||
]);
|
||||
if (!admin) redirect("/platform/login");
|
||||
|
||||
// SEC2: Plattform-Admins unterliegen der Standard-Passwort-Policy (kein Mandant).
|
||||
const policyHint = describePasswordPolicy(resolvePasswordPolicy(undefined));
|
||||
const enrolled = !!admin.mfaEnrolledAt;
|
||||
const recoveryLeft = Array.isArray(admin.recoveryCodes) ? admin.recoveryCodes.length : 0;
|
||||
|
||||
return (
|
||||
<main className="flex-1 p-6">
|
||||
<PageHead crumb="Plattform-Betrieb" title="Profil & Sicherheit" sub={`${admin.name} · ${admin.email}`} />
|
||||
|
||||
<div className="mt-4 grid gap-5 lg:grid-cols-2">
|
||||
{/* MFA (persönlich) */}
|
||||
<div className="shadow-card rounded-xl border bg-card p-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="font-heading text-sm font-semibold">Zwei-Faktor-Authentifizierung</p>
|
||||
<Pill tone={enrolled ? "ok" : "mut"}>{enrolled ? "Aktiv" : "Inaktiv"}</Pill>
|
||||
</div>
|
||||
<p className="mt-2 text-[12.5px] text-muted-foreground">
|
||||
MFA per TOTP ist {settings.mfaRequired ? "durch die Plattform-Policy verpflichtend" : "optional"}. Empfohlen für Betreiberzugänge.
|
||||
</p>
|
||||
{enrolled ? (
|
||||
<div className="mt-3 space-y-2">
|
||||
<p className="text-[12px] text-muted-foreground">Verbleibende Recovery-Codes: {recoveryLeft}</p>
|
||||
{/* Re-Authentifizierung (F-08): aktueller TOTP-Code zum Deaktivieren nötig. */}
|
||||
<form action={disablePlatformMfa} className="flex items-center gap-2">
|
||||
<input
|
||||
name="token"
|
||||
inputMode="numeric"
|
||||
autoComplete="one-time-code"
|
||||
placeholder="6-stelliger Code"
|
||||
className="h-8 w-36 rounded-md border bg-background px-2 text-sm"
|
||||
disabled={settings.mfaRequired}
|
||||
required
|
||||
/>
|
||||
<Button type="submit" variant="outline" size="sm" disabled={settings.mfaRequired}>MFA deaktivieren</Button>
|
||||
</form>
|
||||
{settings.mfaRequired && <p className="text-[11px] text-muted-foreground">Deaktivieren nicht möglich, solange die MFA-Pflicht aktiv ist.</p>}
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-3">
|
||||
<Button nativeButton={false} render={<Link href="/platform/enroll-mfa" />} size="sm">MFA aktivieren</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Plattform-Policy: MFA-Pflicht */}
|
||||
<div className="shadow-card rounded-xl border bg-card p-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="font-heading text-sm font-semibold">Plattform-Policy: MFA-Pflicht</p>
|
||||
<Pill tone={settings.mfaRequired ? "ok" : "mut"}>{settings.mfaRequired ? "Erzwungen" : "Aus"}</Pill>
|
||||
</div>
|
||||
<p className="mt-2 text-[12.5px] text-muted-foreground">
|
||||
Ist die Pflicht aktiv, müssen alle Plattform-Admins MFA einrichten (Enrollment wird beim Zugriff erzwungen). Default: aus.
|
||||
</p>
|
||||
<form action={setPlatformMfaRequired.bind(null, !settings.mfaRequired)} className="mt-3">
|
||||
<Button type="submit" variant="outline" size="sm">
|
||||
{settings.mfaRequired ? "MFA-Pflicht deaktivieren" : "MFA-Pflicht aktivieren"}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* SEC2: Passwort selbst aendern — meldet andere Sitzungen ab, behaelt die aktuelle. */}
|
||||
<div className="shadow-card rounded-xl border bg-card p-5">
|
||||
<p className="mb-1 font-heading text-sm font-semibold">Passwort ändern</p>
|
||||
<p className="mb-3 text-[12.5px] text-muted-foreground">
|
||||
Nach der Änderung werden alle anderen Sitzungen abgemeldet; diese bleibt bestehen.
|
||||
</p>
|
||||
<ChangePasswordSelfForm domain="platform" policyHint={policyHint} />
|
||||
</div>
|
||||
|
||||
{/* SEC2: E-Mail-Aenderung mit Bestaetigung der neuen Adresse. */}
|
||||
<div className="shadow-card rounded-xl border bg-card p-5">
|
||||
<p className="mb-1 font-heading text-sm font-semibold">E-Mail-Adresse ändern</p>
|
||||
<p className="mb-3 text-[12.5px] text-muted-foreground">
|
||||
Aktuell: {admin.email}. Die Adresse ist zugleich der Anmeldename am Betreiberzugang.
|
||||
</p>
|
||||
<ChangeEmailForm domain="platform" currentEmail={admin.email} />
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import { prisma } from "@/server/db";
|
||||
import { PageHead, Pill } from "@/components/mockup-ui";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { PolicyExpertEditor } from "@/components/policy-expert-editor";
|
||||
import { updateTemplateDoc } from "@/server/actions/policy-templates";
|
||||
|
||||
/**
|
||||
* Vorlagen-Dokument-Editor (Plattform, Phase 2). Bearbeitet ein Dokument im ENTWURF
|
||||
* (rawMarkdown, wie im Mandanten-Experten-Editor). Veröffentlichte Versionen: nur Lesen.
|
||||
*/
|
||||
export default async function TemplateDocEditor({ params, searchParams }: { params: Promise<{ locale: string; code: string }>; searchParams: Promise<{ framework?: string }> }) {
|
||||
const p = await params;
|
||||
const locale = p.locale === "en" ? "en" : "de";
|
||||
const code = decodeURIComponent(p.code);
|
||||
const framework = (await searchParams).framework === "ISO_27001" ? "ISO_27001" : "TISAX";
|
||||
|
||||
const [draft, published] = await Promise.all([
|
||||
prisma.policyTemplateVersion.findFirst({ where: { status: "DRAFT", framework }, orderBy: { createdAt: "desc" } }),
|
||||
prisma.policyTemplateVersion.findFirst({ where: { status: "PUBLISHED", framework }, orderBy: [{ publishedAt: "desc" }, { createdAt: "desc" }] }),
|
||||
]);
|
||||
const version = draft ?? published;
|
||||
if (!version) notFound();
|
||||
const doc = await prisma.policyTemplateDoc.findUnique({ where: { versionId_locale_code: { versionId: version.id, locale, code } } });
|
||||
if (!doc) notFound();
|
||||
const editable = version.status === "DRAFT";
|
||||
|
||||
const [vars, docs] = await Promise.all([
|
||||
prisma.policyTemplateVariable.findMany({ where: { versionId: version.id, locale }, orderBy: { orderIdx: "asc" }, select: { key: true, title: true } }),
|
||||
prisma.policyTemplateDoc.findMany({ where: { versionId: version.id, locale }, orderBy: { orderIdx: "asc" }, select: { code: true, title: true } }),
|
||||
]);
|
||||
|
||||
return (
|
||||
<main className="flex-1 p-6">
|
||||
<PageHead
|
||||
crumb={`Vorlagen · ${locale.toUpperCase()}`}
|
||||
title={`${doc.code} — ${doc.title}`}
|
||||
sub={editable
|
||||
? "Entwurf bearbeiten. Fehlende {{Variablen}} werden beim Speichern angelegt."
|
||||
: "Veröffentlichte Version — nur Lesen. Zum Bearbeiten einen Entwurf anlegen."}
|
||||
actions={
|
||||
<div className="flex items-center gap-2">
|
||||
{editable ? <Pill tone="warn">Entwurf</Pill> : <Pill tone="ok">Veröffentlicht</Pill>}
|
||||
<Button variant="outline" size="sm" nativeButton={false} render={<Link href={`/templates?locale=${locale}&framework=${framework}`} />}>
|
||||
<ArrowLeft className="mr-1 size-3.5" />Zurück
|
||||
</Button>
|
||||
{editable && <Button type="submit" form="tpl-doc" size="sm">Speichern</Button>}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<div className="shadow-card mt-4 rounded-xl border bg-card p-4">
|
||||
{editable ? (
|
||||
<PolicyExpertEditor
|
||||
formId="tpl-doc"
|
||||
saveAction={updateTemplateDoc.bind(null, doc.id)}
|
||||
initialMarkdown={doc.rawMarkdown}
|
||||
variables={vars}
|
||||
docs={docs}
|
||||
/>
|
||||
) : (
|
||||
<textarea
|
||||
readOnly
|
||||
value={doc.rawMarkdown}
|
||||
rows={32}
|
||||
className="w-full rounded-xl border border-input bg-transparent p-3 font-mono text-[12px] leading-relaxed outline-none"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { ArrowLeft, Trash2 } from "lucide-react";
|
||||
import { prisma } from "@/server/db";
|
||||
import { PageHead, Pill } from "@/components/mockup-ui";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { upsertTemplateRequirement, deleteTemplateRequirement } from "@/server/actions/policy-templates";
|
||||
|
||||
/**
|
||||
* Vorlagen-Anforderungen (Control-Mapping) je Sprache. Bearbeitbar nur im Entwurf.
|
||||
*/
|
||||
const inp = "h-8 w-full rounded-md border border-input bg-transparent px-2 text-[12.5px]";
|
||||
|
||||
export default async function TemplateRequirementsPage({ params, searchParams }: { params: Promise<{ locale: string }>; searchParams: Promise<{ framework?: string }> }) {
|
||||
const p = await params;
|
||||
const locale = p.locale === "en" ? "en" : "de";
|
||||
const framework = (await searchParams).framework === "ISO_27001" ? "ISO_27001" : "TISAX";
|
||||
|
||||
const [draft, published] = await Promise.all([
|
||||
prisma.policyTemplateVersion.findFirst({ where: { status: "DRAFT", framework }, orderBy: { createdAt: "desc" } }),
|
||||
prisma.policyTemplateVersion.findFirst({ where: { status: "PUBLISHED", framework }, orderBy: [{ publishedAt: "desc" }, { createdAt: "desc" }] }),
|
||||
]);
|
||||
const version = draft ?? published;
|
||||
if (!version) notFound();
|
||||
const editable = version.status === "DRAFT";
|
||||
const reqs = await prisma.policyTemplateRequirement.findMany({ where: { versionId: version.id, locale }, orderBy: { orderIdx: "asc" } });
|
||||
|
||||
return (
|
||||
<main className="flex-1 p-6">
|
||||
<PageHead
|
||||
crumb={`Vorlagen · ${locale.toUpperCase()}`}
|
||||
title="Anforderungen (Control-Mapping)"
|
||||
sub={editable ? "Entwurf bearbeiten — Anforderungen je Control." : "Veröffentlichte Version — nur Lesen."}
|
||||
actions={
|
||||
<div className="flex items-center gap-2">
|
||||
{editable ? <Pill tone="warn">Entwurf</Pill> : <Pill tone="ok">Veröffentlicht</Pill>}
|
||||
<Button variant="outline" size="sm" nativeButton={false} render={<Link href={`/templates?locale=${locale}&framework=${framework}`} />}>
|
||||
<ArrowLeft className="mr-1 size-3.5" />Zurück
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="shadow-card mt-4 rounded-xl border bg-card p-4">
|
||||
<p className="mb-3 font-heading text-sm font-semibold">{reqs.length} Anforderungen</p>
|
||||
{reqs.length === 0 && <p className="text-[13px] text-muted-foreground">Keine Anforderungen in dieser Sprache.</p>}
|
||||
<div className="space-y-1.5">
|
||||
{reqs.map((r) => (
|
||||
<details key={r.id} className="rounded-lg border">
|
||||
<summary className="flex cursor-pointer items-center gap-3 px-3 py-2 text-[13px]">
|
||||
<span className="font-mono text-[12px]">{r.reqId}</span>
|
||||
<span className="text-muted-foreground">{r.control}</span>
|
||||
<span className="truncate">{r.requirement}</span>
|
||||
<Pill tone={r.obligation === "MUSS" ? "warn" : "mut"}>{r.obligation}</Pill>
|
||||
</summary>
|
||||
{editable ? (
|
||||
<div className="border-t p-3">
|
||||
<form action={upsertTemplateRequirement} className="grid grid-cols-2 gap-2 lg:grid-cols-4">
|
||||
<input type="hidden" name="id" value={r.id} />
|
||||
<label className="text-[11.5px] text-muted-foreground">Control<input name="control" defaultValue={r.control} className={inp} /></label>
|
||||
<label className="text-[11.5px] text-muted-foreground">Pflicht
|
||||
<select name="obligation" defaultValue={r.obligation} className={inp}><option>MUSS</option><option>SOLL</option></select>
|
||||
</label>
|
||||
<label className="text-[11.5px] text-muted-foreground">Richtlinie (policyCode)<input name="policyCode" defaultValue={r.policyCode} className={inp} /></label>
|
||||
<label className="text-[11.5px] text-muted-foreground">Bedingung (Flag)<input name="condition" defaultValue={r.condition ?? ""} className={inp} /></label>
|
||||
<label className="col-span-2 text-[11.5px] text-muted-foreground lg:col-span-4">Anforderung<textarea name="requirement" defaultValue={r.requirement} rows={2} className="w-full rounded-md border border-input bg-transparent px-2 py-1 text-[12.5px]" /></label>
|
||||
<label className="col-span-2 text-[11.5px] text-muted-foreground lg:col-span-4">Umsetzung<textarea name="implementation" defaultValue={r.implementation} rows={2} className="w-full rounded-md border border-input bg-transparent px-2 py-1 text-[12.5px]" /></label>
|
||||
<label className="col-span-2 text-[11.5px] text-muted-foreground">Verfahren (VA, Komma)<input name="vaCodes" defaultValue={r.vaCodes.join(", ")} className={inp} /></label>
|
||||
<label className="col-span-2 text-[11.5px] text-muted-foreground">Nachweis-Link<input name="nachweisLink" defaultValue={r.nachweisLink ?? ""} className={inp} /></label>
|
||||
<input type="hidden" name="reqId" value={r.reqId} />
|
||||
<div className="col-span-2 mt-1 flex items-center gap-2 lg:col-span-4">
|
||||
<Button type="submit" size="sm">Speichern</Button>
|
||||
</div>
|
||||
</form>
|
||||
<form action={deleteTemplateRequirement.bind(null, r.id)} className="mt-2 border-t pt-2">
|
||||
<Button type="submit" variant="destructive" size="sm"><Trash2 className="mr-1 size-3.5" />Anforderung löschen</Button>
|
||||
</form>
|
||||
</div>
|
||||
) : (
|
||||
<div className="border-t p-3 text-[12.5px]">
|
||||
<p className="mb-1"><span className="text-muted-foreground">Anforderung:</span> {r.requirement}</p>
|
||||
<p><span className="text-muted-foreground">Umsetzung:</span> {r.implementation}</p>
|
||||
</div>
|
||||
)}
|
||||
</details>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{editable && (
|
||||
<form action={upsertTemplateRequirement} className="mt-4 grid grid-cols-2 gap-2 border-t pt-4 lg:grid-cols-4">
|
||||
<p className="col-span-2 font-heading text-[13px] font-semibold lg:col-span-4">Neue Anforderung</p>
|
||||
<input type="hidden" name="locale" value={locale} />
|
||||
<input type="hidden" name="framework" value={framework} />
|
||||
<label className="text-[11.5px] text-muted-foreground">Anforderungs-ID<input name="reqId" required placeholder="4.1.2-M1" className={inp} /></label>
|
||||
<label className="text-[11.5px] text-muted-foreground">Control<input name="control" placeholder="4.1.2" className={inp} /></label>
|
||||
<label className="text-[11.5px] text-muted-foreground">Pflicht<select name="obligation" className={inp}><option>MUSS</option><option>SOLL</option></select></label>
|
||||
<label className="text-[11.5px] text-muted-foreground">Richtlinie (policyCode)<input name="policyCode" placeholder="R08" className={inp} /></label>
|
||||
<label className="col-span-2 text-[11.5px] text-muted-foreground lg:col-span-4">Anforderung<textarea name="requirement" rows={2} className="w-full rounded-md border border-input bg-transparent px-2 py-1 text-[12.5px]" /></label>
|
||||
<label className="col-span-2 text-[11.5px] text-muted-foreground lg:col-span-4">Umsetzung<textarea name="implementation" rows={2} className="w-full rounded-md border border-input bg-transparent px-2 py-1 text-[12.5px]" /></label>
|
||||
<div className="col-span-2 lg:col-span-4"><Button type="submit" size="sm">Anforderung anlegen</Button></div>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { ArrowLeft, Trash2 } from "lucide-react";
|
||||
import { prisma } from "@/server/db";
|
||||
import { PageHead, Pill } from "@/components/mockup-ui";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { upsertTemplateVariable, deleteTemplateVariable } from "@/server/actions/policy-templates";
|
||||
|
||||
/**
|
||||
* Vorlagen-Variablen ({{Platzhalter}} / Feature-Flags) je Sprache. Bearbeitbar nur im Entwurf.
|
||||
*/
|
||||
const inp = "h-8 w-full rounded-md border border-input bg-transparent px-2 text-[12.5px]";
|
||||
|
||||
export default async function TemplateVariablesPage({ params, searchParams }: { params: Promise<{ locale: string }>; searchParams: Promise<{ framework?: string }> }) {
|
||||
const p = await params;
|
||||
const locale = p.locale === "en" ? "en" : "de";
|
||||
const framework = (await searchParams).framework === "ISO_27001" ? "ISO_27001" : "TISAX";
|
||||
|
||||
const [draft, published] = await Promise.all([
|
||||
prisma.policyTemplateVersion.findFirst({ where: { status: "DRAFT", framework }, orderBy: { createdAt: "desc" } }),
|
||||
prisma.policyTemplateVersion.findFirst({ where: { status: "PUBLISHED", framework }, orderBy: [{ publishedAt: "desc" }, { createdAt: "desc" }] }),
|
||||
]);
|
||||
const version = draft ?? published;
|
||||
if (!version) notFound();
|
||||
const editable = version.status === "DRAFT";
|
||||
const vars = await prisma.policyTemplateVariable.findMany({ where: { versionId: version.id, locale }, orderBy: [{ groupName: "asc" }, { orderIdx: "asc" }] });
|
||||
|
||||
return (
|
||||
<main className="flex-1 p-6">
|
||||
<PageHead
|
||||
crumb={`Vorlagen · ${locale.toUpperCase()}`}
|
||||
title="Variablen"
|
||||
sub={editable ? "Entwurf bearbeiten — Platzhalter und Feature-Flags." : "Veröffentlichte Version — nur Lesen."}
|
||||
actions={
|
||||
<div className="flex items-center gap-2">
|
||||
{editable ? <Pill tone="warn">Entwurf</Pill> : <Pill tone="ok">Veröffentlicht</Pill>}
|
||||
<Button variant="outline" size="sm" nativeButton={false} render={<Link href={`/templates?locale=${locale}&framework=${framework}`} />}>
|
||||
<ArrowLeft className="mr-1 size-3.5" />Zurück
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="shadow-card mt-4 rounded-xl border bg-card p-4">
|
||||
<p className="mb-3 font-heading text-sm font-semibold">{vars.length} Variablen</p>
|
||||
{vars.length === 0 && <p className="text-[13px] text-muted-foreground">Keine Variablen in dieser Sprache.</p>}
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-[12.5px]">
|
||||
<thead className="text-muted-foreground">
|
||||
<tr className="border-b">
|
||||
<th className="py-1.5 pr-2 text-left font-medium">Key</th>
|
||||
<th className="py-1.5 pr-2 text-left font-medium">Titel</th>
|
||||
<th className="py-1.5 pr-2 text-left font-medium">Typ</th>
|
||||
<th className="py-1.5 pr-2 text-left font-medium">Gruppe</th>
|
||||
<th className="py-1.5 pr-2 text-left font-medium">Default</th>
|
||||
{editable && <th className="py-1.5 pr-2"></th>}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y">
|
||||
{vars.map((v) => (
|
||||
<tr key={v.id}>
|
||||
{editable ? (
|
||||
<>
|
||||
<td className="py-1 pr-2 font-mono text-[12px] align-middle">{v.key}</td>
|
||||
<td className="py-1 pr-2" colSpan={4}>
|
||||
<form action={upsertTemplateVariable} className="flex flex-wrap items-center gap-1.5">
|
||||
<input type="hidden" name="id" value={v.id} />
|
||||
<input name="title" defaultValue={v.title} className={inp + " w-40"} />
|
||||
<select name="kind" defaultValue={v.kind} className={inp + " w-24"}><option value="string">string</option><option value="boolean">boolean</option></select>
|
||||
<input name="groupName" defaultValue={v.groupName ?? ""} className={inp + " w-32"} placeholder="Gruppe" />
|
||||
<input name="value" defaultValue={v.value} className={inp + " w-32"} placeholder="Default" />
|
||||
<label className="flex items-center gap-1 text-[11.5px] text-muted-foreground"><input type="checkbox" name="required" defaultChecked={v.required} />Pflicht</label>
|
||||
<Button type="submit" size="xs">Speichern</Button>
|
||||
</form>
|
||||
</td>
|
||||
<td className="py-1 pr-2 text-right align-middle">
|
||||
<form action={deleteTemplateVariable.bind(null, v.id)}>
|
||||
<Button type="submit" variant="ghost" size="icon-xs" title="Löschen"><Trash2 className="size-3.5" /></Button>
|
||||
</form>
|
||||
</td>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<td className="py-1.5 pr-2 font-mono text-[12px]">{v.key}</td>
|
||||
<td className="py-1.5 pr-2">{v.title}</td>
|
||||
<td className="py-1.5 pr-2 text-muted-foreground">{v.kind}</td>
|
||||
<td className="py-1.5 pr-2 text-muted-foreground">{v.groupName ?? "—"}</td>
|
||||
<td className="py-1.5 pr-2 font-mono text-[12px]">{v.value || "—"}</td>
|
||||
</>
|
||||
)}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{editable && (
|
||||
<form action={upsertTemplateVariable} className="mt-4 flex flex-wrap items-end gap-2 border-t pt-4">
|
||||
<p className="w-full font-heading text-[13px] font-semibold">Neue Variable</p>
|
||||
<input type="hidden" name="locale" value={locale} />
|
||||
<input type="hidden" name="framework" value={framework} />
|
||||
<label className="flex flex-col gap-1 text-[11.5px] text-muted-foreground">Key<input name="key" required placeholder="PW_MIN_LENGTH" className={inp + " w-44"} /></label>
|
||||
<label className="flex flex-col gap-1 text-[11.5px] text-muted-foreground">Titel<input name="title" className={inp + " w-44"} /></label>
|
||||
<label className="flex flex-col gap-1 text-[11.5px] text-muted-foreground">Typ<select name="kind" className={inp + " w-28"}><option value="string">string</option><option value="boolean">boolean</option></select></label>
|
||||
<label className="flex flex-col gap-1 text-[11.5px] text-muted-foreground">Gruppe<input name="groupName" className={inp + " w-36"} /></label>
|
||||
<label className="flex flex-col gap-1 text-[11.5px] text-muted-foreground">Default<input name="value" className={inp + " w-32"} /></label>
|
||||
<label className="flex items-center gap-1 pb-1.5 text-[11.5px] text-muted-foreground"><input type="checkbox" name="required" />Pflicht</label>
|
||||
<Button type="submit" size="sm">Variable anlegen</Button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
import Link from "next/link";
|
||||
import { FileText, Plus, Trash2, Pencil, Rocket } from "lucide-react";
|
||||
import { prisma } from "@/server/db";
|
||||
import { PageHead, Pill } from "@/components/mockup-ui";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { startDraftAction, discardDraft, createTemplateDoc, publishDraft } from "@/server/actions/policy-templates";
|
||||
|
||||
/**
|
||||
* Richtlinien-Vorlagen (Plattform, Phase 2). Bearbeitung des aktuellen ENTWURFS
|
||||
* (DRAFT); veröffentlichte Versionen sind unveränderlich. Sprache (de|en) per ?locale.
|
||||
* Zugriff (Plattform-Session + MFA) wird im (platform)/layout.tsx erzwungen.
|
||||
*/
|
||||
const TYPE_LABEL: Record<string, string> = {
|
||||
LEITLINIE: "Leitlinie", RICHTLINIE: "Richtlinie", VERFAHREN: "Verfahren",
|
||||
REGISTER: "Register", HANDBUCH: "Handbuch", EIGENES: "Eigenes",
|
||||
};
|
||||
const LOCALE_LABEL: Record<string, string> = { de: "Deutsch", en: "English" };
|
||||
const FRAMEWORK_LABEL: Record<string, string> = { TISAX: "TISAX / VDA ISA", ISO_27001: "ISO/IEC 27001" };
|
||||
|
||||
export default async function TemplatesPage({ searchParams }: { searchParams: Promise<{ locale?: string; framework?: string }> }) {
|
||||
const sp = await searchParams;
|
||||
const locale = sp.locale === "en" ? "en" : "de";
|
||||
// Editor-Framework (Default TISAX). ISO-Vorlagen sind jetzt genauso editier-/
|
||||
// versionierbar wie TISAX — nur im jeweils gewählten Namensraum.
|
||||
const framework = sp.framework === "ISO_27001" ? "ISO_27001" : "TISAX";
|
||||
|
||||
const [draft, published] = await Promise.all([
|
||||
prisma.policyTemplateVersion.findFirst({ where: { status: "DRAFT", framework }, orderBy: { createdAt: "desc" } }),
|
||||
prisma.policyTemplateVersion.findFirst({ where: { status: "PUBLISHED", framework }, orderBy: [{ publishedAt: "desc" }, { createdAt: "desc" }] }),
|
||||
]);
|
||||
const activeVersion = draft ?? published;
|
||||
const editable = !!draft;
|
||||
|
||||
const docs = activeVersion
|
||||
? await prisma.policyTemplateDoc.findMany({ where: { versionId: activeVersion.id, locale }, orderBy: { orderIdx: "asc" } })
|
||||
: [];
|
||||
|
||||
const tab = (l: string) => (
|
||||
<Button
|
||||
key={l}
|
||||
variant={l === locale ? "default" : "outline"}
|
||||
size="sm"
|
||||
nativeButton={false}
|
||||
render={<Link href={`/templates?locale=${l}&framework=${framework}`} />}
|
||||
>
|
||||
{LOCALE_LABEL[l]}
|
||||
</Button>
|
||||
);
|
||||
const fwTab = (f: "TISAX" | "ISO_27001") => (
|
||||
<Button
|
||||
key={f}
|
||||
variant={f === framework ? "default" : "outline"}
|
||||
size="sm"
|
||||
nativeButton={false}
|
||||
render={<Link href={`/templates?locale=${locale}&framework=${f}`} />}
|
||||
>
|
||||
{FRAMEWORK_LABEL[f]}
|
||||
</Button>
|
||||
);
|
||||
|
||||
return (
|
||||
<main className="flex-1 p-6">
|
||||
<PageHead
|
||||
crumb="Plattform"
|
||||
title="Richtlinien-Vorlagen"
|
||||
sub="Master-Vorlagen bearbeiten und versionieren. Neue Mandanten erhalten die veröffentlichte Version automatisch; bestehende werden informiert und übernehmen selbst."
|
||||
actions={
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="flex gap-1">{(["TISAX", "ISO_27001"] as const).map(fwTab)}</div>
|
||||
<span className="mx-1 h-5 w-px bg-border" />
|
||||
<div className="flex gap-1">{["de", "en"].map(tab)}</div>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
{!activeVersion ? (
|
||||
<div className="shadow-card mt-4 rounded-xl border bg-card p-6 text-[13px] text-muted-foreground">
|
||||
Es sind noch keine Vorlagen in der Datenbank. Bitte einmalig den Sync ausführen:
|
||||
<code className="mx-1 rounded bg-[var(--elevated)] px-1.5 py-0.5">npx tsx scripts/sync-policy-templates.ts</code>
|
||||
(überführt das Datei-Paket als veröffentlichte Version).
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-4 grid gap-4 lg:grid-cols-[320px_1fr]">
|
||||
{/* Version / Entwurf */}
|
||||
<div className="shadow-card h-fit rounded-xl border bg-card p-4 text-[13px]">
|
||||
<p className="mb-2 font-heading text-sm font-semibold">Version</p>
|
||||
<dl className="space-y-1.5">
|
||||
<div className="flex justify-between gap-2"><dt className="text-muted-foreground">Rahmenwerk</dt><dd className="font-medium">{FRAMEWORK_LABEL[framework]}</dd></div>
|
||||
<div className="flex justify-between gap-2"><dt className="text-muted-foreground">Veröffentlicht</dt><dd className="font-medium">{published?.version ?? "—"}</dd></div>
|
||||
<div className="flex justify-between gap-2"><dt className="text-muted-foreground">Entwurf</dt><dd className="font-medium">{draft?.version ?? "—"}</dd></div>
|
||||
<div className="flex justify-between gap-2"><dt className="text-muted-foreground">Sprache</dt><dd>{LOCALE_LABEL[locale]}</dd></div>
|
||||
</dl>
|
||||
<div className="mt-3">
|
||||
{editable
|
||||
? <Pill tone="warn">Entwurf bearbeitbar</Pill>
|
||||
: <Pill tone="ok">Nur Lesen (veröffentlicht)</Pill>}
|
||||
</div>
|
||||
<div className="mt-4 flex flex-col gap-2 border-t pt-3">
|
||||
{!draft ? (
|
||||
<form action={startDraftAction.bind(null, framework)}>
|
||||
<Button type="submit" className="w-full"><Pencil className="mr-1.5 size-4" />Entwurf anlegen</Button>
|
||||
</form>
|
||||
) : (
|
||||
<>
|
||||
<form action={publishDraft} className="flex flex-col gap-2">
|
||||
<input type="hidden" name="framework" value={framework} />
|
||||
<label className="flex flex-col gap-1 text-[11.5px] text-muted-foreground">
|
||||
Versionsnummer
|
||||
<input name="version" defaultValue={draft.version} className="h-8 rounded-md border border-input bg-transparent px-2 text-[13px]" />
|
||||
</label>
|
||||
<label className="flex flex-col gap-1 text-[11.5px] text-muted-foreground">
|
||||
Änderungshinweis (für Mandanten)
|
||||
<textarea name="notes" rows={2} placeholder="Was ändert sich?" className="rounded-md border border-input bg-transparent px-2 py-1 text-[12.5px]" />
|
||||
</label>
|
||||
<Button type="submit" className="w-full"><Rocket className="mr-1.5 size-4" />Veröffentlichen</Button>
|
||||
</form>
|
||||
<p className="text-[11.5px] text-muted-foreground">Neue Mandanten erhalten die Version automatisch; bestehende sehen ein Update und übernehmen selbst (nicht-destruktiv).</p>
|
||||
<form action={discardDraft.bind(null, framework)}>
|
||||
<Button type="submit" variant="outline" size="sm" className="w-full"><Trash2 className="mr-1.5 size-4" />Entwurf verwerfen</Button>
|
||||
</form>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-3 flex flex-col gap-1.5 border-t pt-3 text-[12.5px]">
|
||||
<Link className="text-muted-foreground hover:text-foreground" href={`/templates/${locale}/requirements?framework=${framework}`}>Anforderungen (Control-Mapping) →</Link>
|
||||
<Link className="text-muted-foreground hover:text-foreground" href={`/templates/${locale}/variables?framework=${framework}`}>Variablen →</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Dokumente */}
|
||||
<div className="shadow-card rounded-xl border bg-card p-4">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<p className="font-heading text-sm font-semibold">Dokumente ({docs.length})</p>
|
||||
</div>
|
||||
|
||||
{docs.length === 0 ? (
|
||||
<p className="text-[13px] text-muted-foreground">
|
||||
Keine Dokumente in dieser Sprache. {locale === "en" && "Englische Inhalte müssen angelegt werden — "}
|
||||
{editable ? "Neues Dokument unten anlegen." : "Zum Bearbeiten zuerst einen Entwurf anlegen."}
|
||||
</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-[13px]">
|
||||
<thead className="text-muted-foreground">
|
||||
<tr className="border-b"><th className="py-1.5 pr-3 text-left font-medium">Code</th><th className="py-1.5 pr-3 text-left font-medium">Titel</th><th className="py-1.5 pr-3 text-left font-medium">Typ</th><th className="py-1.5 pr-3"></th></tr>
|
||||
</thead>
|
||||
<tbody className="divide-y">
|
||||
{docs.map((d) => (
|
||||
<tr key={d.id}>
|
||||
<td className="py-1.5 pr-3 font-mono text-[12px]">{d.code}</td>
|
||||
<td className="py-1.5 pr-3">{d.title}</td>
|
||||
<td className="py-1.5 pr-3 text-muted-foreground">{TYPE_LABEL[d.type] ?? d.type}</td>
|
||||
<td className="py-1.5 pr-3 text-right">
|
||||
<Button variant="outline" size="sm" nativeButton={false} render={<Link href={`/templates/${locale}/${encodeURIComponent(d.code)}?framework=${framework}`} />}>
|
||||
<FileText className="mr-1 size-3.5" />{editable ? "Bearbeiten" : "Ansehen"}
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{editable && (
|
||||
<form action={createTemplateDoc} className="mt-4 flex flex-wrap items-end gap-2 border-t pt-4">
|
||||
<input type="hidden" name="locale" value={locale} />
|
||||
<input type="hidden" name="framework" value={framework} />
|
||||
<label className="flex flex-col gap-1 text-[12px]">
|
||||
<span className="text-muted-foreground">Code</span>
|
||||
<input name="code" required placeholder="R15 / VA-21 / EIG-1" className="h-9 w-36 rounded-md border border-input bg-transparent px-2 text-[13px]" />
|
||||
</label>
|
||||
<label className="flex flex-col gap-1 text-[12px]">
|
||||
<span className="text-muted-foreground">Typ</span>
|
||||
<select name="type" className="h-9 rounded-md border border-input bg-transparent px-2 text-[13px]">
|
||||
<option value="RICHTLINIE">Richtlinie</option>
|
||||
<option value="VERFAHREN">Verfahren</option>
|
||||
<option value="LEITLINIE">Leitlinie</option>
|
||||
<option value="REGISTER">Register</option>
|
||||
<option value="EIGENES">Eigenes</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="flex flex-1 flex-col gap-1 text-[12px]">
|
||||
<span className="text-muted-foreground">Titel</span>
|
||||
<input name="title" required placeholder="Titel des Dokuments" className="h-9 w-full min-w-40 rounded-md border border-input bg-transparent px-2 text-[13px]" />
|
||||
</label>
|
||||
<Button type="submit" size="sm"><Plus className="mr-1 size-4" />Dokument anlegen</Button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user