Files
craftvia/src/components/backup-admin-panel.tsx
T
msolarczekandClaude Opus 5 c8e6f30a27
CI / build-and-check (push) Canceled after 0s
CI / audit (push) Canceled after 0s
CI / sbom (push) Canceled after 0s
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>
2026-09-14 11:05:39 +02:00

215 lines
10 KiB
TypeScript

"use client";
import { useActionState, useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import type { BackupActionState } from "@/server/actions/backup-admin";
/**
* Client-Formulare des Betreiber-Portals für Datensicherung/DSGVO. Rendern in
* den <Modal>-Popups der Mandantenseite (?restore=1 / ?dsgvo=1). Die Server-
* Actions kommen als (gebundene) Props; die Sicherheitskontrollen (Voll-Admin,
* MFA-Step-up, getippte Bestätigung) werden serverseitig erzwungen — die UI
* spiegelt sie nur wider.
*/
type Action = (prev: BackupActionState, formData: FormData) => Promise<BackupActionState>;
export interface SnapshotOption {
snapshotId: string;
snapshotAt: string | null;
totalRows: number | null;
artifactTenantSlug: string | null;
tenantMismatch: boolean;
tables: { model: string; rows: number }[];
}
function Feedback({ state }: { state: BackupActionState }) {
if (state.status === "error") {
return <p className="rounded-lg border border-destructive/40 bg-destructive/10 px-3 py-2 text-[12.5px] text-destructive">{state.message}</p>;
}
if (state.status === "done") {
return <p className="rounded-lg border border-emerald-500/40 bg-emerald-500/10 px-3 py-2 text-[12.5px] text-emerald-700 dark:text-emerald-300">{state.message}</p>;
}
return null;
}
/** Portal-Restore: Auswahl + Dry-run-Vorschau + TOTP + getippte Bestätigung. */
export function RestoreModalBody({
tenantId,
tenantSlug,
snapshots,
action,
mfaEnrolled,
storeError = false,
}: {
tenantId: string;
tenantSlug: string;
snapshots: SnapshotOption[];
action: Action;
mfaEnrolled: boolean;
/** true = Sicherungsspeicher (S3/MinIO) nicht erreichbar → anderer Hinweis als „keine Sicherung". */
storeError?: boolean;
}) {
const [state, formAction, pending] = useActionState<BackupActionState, FormData>(action, { status: "idle" });
const [selected, setSelected] = useState<string>(snapshots[0]?.snapshotId ?? "");
const preview = snapshots.find((s) => s.snapshotId === selected) ?? null;
const expected = `RESTORE ${tenantSlug}`;
return (
<form action={formAction} className="space-y-4 p-5">
<input type="hidden" name="tenantId" value={tenantId} />
<div className="rounded-lg border border-amber-500/40 bg-amber-500/10 px-3 py-2 text-[12px] text-amber-800 dark:text-amber-200">
<strong>Destruktiv:</strong> Der Mandant wird gesperrt, sein Datenbestand ersetzt (Wipe + Reinsert).
Ein Pre-Restore-Sicherheitsschnappschuss wird automatisch erstellt (reversibel). Ausführung im Worker.
</div>
{storeError ? (
<p className="rounded-lg border border-destructive/40 bg-destructive/10 px-3 py-2 text-[12.5px] text-destructive">
Sicherungsspeicher nicht erreichbar (S3/MinIO nicht konfiguriert oder Bucket fehlt). Bitte die S3_*-Variablen und das Backup-Bucket prüfen.
</p>
) : snapshots.length === 0 ? (
<p className="text-[12.5px] text-muted-foreground">Kein Sicherungspunkt vorhanden. Zuerst &bdquo;Export jetzt&ldquo; ausf&uuml;hren.</p>
) : (
<>
<div className="space-y-1.5">
<Label>Sicherungspunkt</Label>
<div className="max-h-40 space-y-1 overflow-y-auto rounded-lg border p-1.5">
{snapshots.map((s) => (
<label key={s.snapshotId} className="flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-[12.5px] hover:bg-muted">
<input type="radio" name="snapshotId" value={s.snapshotId} checked={selected === s.snapshotId} onChange={() => setSelected(s.snapshotId)} />
<span className="font-mono">{s.snapshotId}</span>
<span className="text-muted-foreground">{s.snapshotAt ? new Date(s.snapshotAt).toLocaleString() : "—"} · {s.totalRows ?? "?"} Zeilen</span>
</label>
))}
</div>
</div>
{/* Read-only Dry-run/Vorschau VOR der Bestätigung */}
{preview && (
<div className="rounded-lg border bg-muted/40 p-3 text-[12px]">
<p className="mb-1 font-semibold">Vorschau (Dry-run)</p>
<p className="text-muted-foreground">
Zielmandant: <span className="font-mono">{tenantSlug}</span> · Artefakt-Mandant: <span className="font-mono">{preview.artifactTenantSlug ?? "?"}</span>
{preview.tenantMismatch && <span className="ml-1 font-semibold text-destructive">(Mismatch — würde abgewiesen)</span>}
</p>
<p className="text-muted-foreground">Snapshot: {preview.snapshotAt ? new Date(preview.snapshotAt).toLocaleString() : "—"} · {preview.totalRows ?? "?"} Zeilen gesamt</p>
{preview.tables.length > 0 && (
<p className="mt-1 text-muted-foreground">
{preview.tables.slice(0, 8).map((t) => `${t.model}: ${t.rows}`).join(" · ")}
{preview.tables.length > 8 ? " …" : ""}
</p>
)}
</div>
)}
{mfaEnrolled && (
<div className="space-y-1.5">
<Label htmlFor="restore-token">MFA-Code (Step-up)</Label>
<Input id="restore-token" name="token" inputMode="numeric" autoComplete="one-time-code" placeholder="6-stelliger Code" required />
</div>
)}
<div className="space-y-1.5">
<Label htmlFor="restore-confirm">Bestätigung — exakt <span className="font-mono">{expected}</span> eingeben</Label>
<Input id="restore-confirm" name="confirm" placeholder={expected} autoComplete="off" required />
</div>
<Feedback state={state} />
<div className="flex justify-end">
<Button type="submit" variant="destructive" size="sm" disabled={pending || !selected || (preview?.tenantMismatch ?? false)}>
{pending ? "Stelle ein…" : "Restore einstellen"}
</Button>
</div>
</>
)}
</form>
);
}
/** „Export jetzt" — On-demand-Sicherung. */
export function ExportModalBody({ tenantId, action, mfaEnrolled }: { tenantId: string; action: Action; mfaEnrolled: boolean }) {
const [state, formAction, pending] = useActionState<BackupActionState, FormData>(action, { status: "idle" });
return (
<div className="space-y-5 p-5">
<form action={formAction} className="space-y-4">
<input type="hidden" name="tenantId" value={tenantId} />
<p className="text-[12.5px] text-muted-foreground">Erstellt einen neuen, verschlüsselten Sicherungspunkt im <strong>Objektspeicher</strong> (Worker) — für Restore und regelmäßige Server-Backups.</p>
{mfaEnrolled && (
<div className="space-y-1.5">
<Label htmlFor="export-token">MFA-Code (Step-up)</Label>
<Input id="export-token" name="token" inputMode="numeric" autoComplete="one-time-code" placeholder="6-stelliger Code" required />
</div>
)}
<Feedback state={state} />
<div className="flex justify-end">
<Button type="submit" size="sm" disabled={pending}>{pending ? "Stelle ein…" : "Export jetzt einstellen"}</Button>
</div>
</form>
<div className="space-y-2 border-t pt-4">
<p className="text-[12.5px] text-muted-foreground">Oder die Sicherung <strong>direkt auf deinen Rechner</strong> herunterladen — als verschlüsselte <code>.cvb</code>-Datei. Läuft inline (kein Worker/S3 nötig).</p>
<a href={`/api/platform/backup/download?tenant=${tenantId}`} download className="inline-flex">
<Button type="button" variant="outline" size="sm">Sicherung herunterladen</Button>
</a>
</div>
</div>
);
}
export interface SubjectOption { identityId: string; label: string }
/** DSGVO-Zustellung: Per-Mandant oder Per-Person, Ergebnis als signierter Link. */
export function DsgvoModalBody({
tenantId,
action,
subjects,
mfaEnrolled,
}: {
tenantId: string;
action: Action;
subjects: SubjectOption[];
mfaEnrolled: boolean;
}) {
const [state, formAction, pending] = useActionState<BackupActionState, FormData>(action, { status: "idle" });
const [scope, setScope] = useState<"tenant" | "person">("tenant");
return (
<form action={formAction} className="space-y-4 p-5">
<input type="hidden" name="tenantId" value={tenantId} />
<div className="space-y-1.5">
<Label>Umfang</Label>
<div className="flex gap-4 text-[12.5px]">
<label className="flex items-center gap-2"><input type="radio" name="scope" value="tenant" checked={scope === "tenant"} onChange={() => setScope("tenant")} /> Gesamter Mandant (Art. 20)</label>
<label className="flex items-center gap-2"><input type="radio" name="scope" value="person" checked={scope === "person"} onChange={() => setScope("person")} /> Einzelperson (Art. 15/20)</label>
</div>
</div>
{scope === "person" && (
<div className="space-y-1.5">
<Label htmlFor="dsgvo-subject">Betroffene Person</Label>
<select id="dsgvo-subject" name="subjectIdentityId" className="h-8 w-full rounded-lg border border-input bg-transparent px-2.5 text-[12.5px]" required={scope === "person"}>
<option value="">— auswählen —</option>
{subjects.map((s) => (
<option key={s.identityId} value={s.identityId}>{s.label}</option>
))}
</select>
</div>
)}
{mfaEnrolled && (
<div className="space-y-1.5">
<Label htmlFor="dsgvo-token">MFA-Code (Step-up)</Label>
<Input id="dsgvo-token" name="token" inputMode="numeric" autoComplete="one-time-code" placeholder="6-stelliger Code" required />
</div>
)}
<p className="text-[11px] text-muted-foreground">Der Download-Link ist zeitlich begrenzt und nur im Betreiber-Portal (angemeldet) abrufbar.</p>
<Feedback state={state} />
<div className="flex justify-end">
<Button type="submit" size="sm" disabled={pending}>{pending ? "Stelle ein…" : "DSGVO-Export einstellen"}</Button>
</div>
</form>
);
}