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>
215 lines
10 KiB
TypeScript
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 „Export jetzt“ ausfü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>
|
|
);
|
|
}
|