syncova-backup/apps/web/src/features/restores/RestoreWizard.tsx
Jerrit Fritzsche b50ad2b9bc Sitzung ueberlebt Neuladen, Umlaute, Fehlergrenze
**Sitzung.** Die Tokens lagen nur im Arbeitsspeicher — jedes Neuladen warf den
Betreiber auf die Anmeldemaske. Das war die sicherste Variante und praktisch
unbrauchbar; mitten in einer Stoerung ist es kein Sicherheitsgewinn, sondern ein
Hindernis. Jetzt `sessionStorage` (nicht `localStorage`: stirbt mit dem Tab),
begrenzt durch zwei Uhren:

- **Harte Obergrenze** von 30 Minuten ab Anmeldung, durch keine Interaktion
  verschiebbar. Sonst waere "30 Minuten" keine Zusage.
- **Untaetigkeitsgrenze** von 30 Minuten.
- Der sofortige serverseitige Widerruf bleibt die eigentliche Absicherung — die
  Tokens sind opak, kein JWT, und genau dafuer wurden sie gewaehlt.

Dazu die Sitzungsuhr oben rechts neben "Abmelden", unter fuenf Minuten
auffaellig. Umgesetzt mit `useSyncExternalStore`: Die Restzeit haengt an der Uhr
und am Speicher, also an zwei Dingen ausserhalb von React. Sie beim Rendern
auszurechnen waere ein unreiner Aufruf, sie in einem Effekt zu setzen eine
zweite Renderrunde je Sekunde — beides hat der Linter gemeldet.

Sechs Tests halten die Grenzen fest, zwei davon durch Mutation als fangend
bestaetigt (Obergrenze mitverschieben schlaegt fehl).

**Fehlergrenze.** Ein Fehler in einer Komponente riss bisher den gesamten Baum
ab; uebrig blieb eine leere Seite — im dunklen Thema ein schwarzer Bildschirm
ohne jeden Hinweis. Die Grenze sitzt **um den Inhalt**: Menue und Kopfzeile
bleiben stehen, der Fehlertext ist lesbar und kopierbar.

**Umlaute.** Die Oberflaeche schrieb durchgehend ae/oe/ue/ss. Jetzt aeoeuess.

Dabei ein selbst verursachter Schaden, gefunden und behoben: Eine Regel
"ue → ü" ist falsch, weil die Buchstabenfolge nicht immer ein Umlaut ist. Sie
machte aus "Quelle" ein "Qülle", aus "neue" ein "neü", aus "aktuell" ein
"aktüll", aus "Dauer" ein "Daür". Die Abbildung laeuft jetzt ueber eine
gepruefte Wortliste mit Ausschluss englischer Bezeichner (`value`, `message`,
`session`, `queued`, `true`); die 23 zerstoerten Woerter sind einzeln
zurueckgesetzt. Ein alter Tippfehler ("geprueter") ist dabei mit aufgefallen.

82 Tests gruen, tsc sauber, eslint ohne Warnung.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 14:42:37 +02:00

561 lines
20 KiB
TypeScript

/**
* Wiederherstellungs-Assistent — die Kernfunktion des Produkts.
*
* Bis hierher ließ sich eine Wiederherstellung über die Oberfläche gar nicht
* auslösen. Wer im Ernstfall Daten zurückbrauchte, musste `curl` bemühen —
* ausgerechnet in der Lage, in der niemand experimentieren will.
*
* Der Assistent hat vier Schritte, und der dritte ist der Grund für seine
* Existenz: **Die Vorabprüfung schreibt nichts und stellt fest, ob die
* Wiederherstellung gelingen kann** — insbesondere, ob jeder benötigte Block
* noch da ist. Ein Manifest allein belegt nur, dass jemand einmal etwas
* gesichert hat.
*
* Die drei Hürden vor dem Überschreiben sind hier sichtbar umgesetzt:
*
* 1. Das Kennzeichen `overwrite_existing` muss gesetzt werden.
* 2. Die Berechtigung `restores.overwrite` prüft der Server; sie steckt nicht
* in `restores.execute`.
* 3. `confirm_overwrite` verlangt den **wörtlich wiederholten Zielpfad**.
*
* Läuft die dritte Hürde ins Leere — weil das Ziel leer ist und nichts
* überschrieben wird —, entfällt sie. Ein Ritual ohne Anlass gewöhnt das
* Wegklicken an, und dann wirkt es dort nicht mehr, wo es zählt.
*/
import { AlertTriangle, ArrowLeft, ArrowRight, Check, ShieldAlert } from 'lucide-react';
import { useState } from 'react';
import { describeApiError, useMutation } from '@/api/useMutation';
import {
Button,
Callout,
CheckboxField,
DialogBody,
DialogContent,
DialogFooter,
DialogHeader,
DialogRoot,
Field,
StatusBadge,
TextInput,
useToast,
} from '@/components/ui';
import { cn, formatBytes, formatDuration } from '@/lib/utils';
import {
createRestore,
validateRestore,
type CreateRestoreRequest,
type ValidationResponse,
} from './restoresApi';
/** Die vier Schritte in Reihenfolge. */
const WIZARD_STEPS = [
{ key: 'ziel', label: 'Ziel' },
{ key: 'umfang', label: 'Umfang' },
{ key: 'prüfung', label: 'Vorabprüfung' },
{ key: 'ausführen', label: 'Ausführen' },
] as const;
export function RestoreWizard({
backupIdentifier,
backupLabel,
grantedPermissions,
onClose,
onStarted,
}: {
readonly backupIdentifier: string;
/** Sprechende Bezeichnung des Wiederherstellungspunkts. */
readonly backupLabel?: string;
readonly grantedPermissions: readonly string[];
readonly onClose: () => void;
readonly onStarted: (restoreIdentifier: string) => void;
}) {
const toast = useToast();
const [currentStepIndex, setCurrentStepIndex] = useState(0);
// Der Entwurf lebt in einem Zustand, nicht in den Eingabefeldern — sonst
// wäre jeder Blick zurück ein Datenverlust (wie im Backup-Assistenten).
const [targetPath, setTargetPath] = useState('');
const [pathPrefix, setPathPrefix] = useState('');
const [overwriteExisting, setOverwriteExisting] = useState(false);
const [skipPermissions, setSkipPermissions] = useState(false);
const [skipDeepCheck, setSkipDeepCheck] = useState(false);
const [typedConfirmation, setTypedConfirmation] = useState('');
const [validationResult, setValidationResult] = useState<ValidationResponse | null>(null);
const mayOverwrite =
grantedPermissions.includes('*') || grantedPermissions.includes('restores.overwrite');
/** Baut die Anfrage aus dem Entwurf. */
function buildRequest(includeConfirmation: boolean): CreateRestoreRequest {
const request: CreateRestoreRequest = {
backup_id: backupIdentifier,
target_type: 'filesystem',
target_path: targetPath.trim(),
};
if (pathPrefix.trim()) {
request.path_prefix = pathPrefix.trim();
}
if (overwriteExisting) {
request.overwrite_existing = true;
}
if (skipPermissions) {
request.skip_permissions = true;
}
if (skipDeepCheck) {
request.skip_deep_check = true;
}
if (includeConfirmation && typedConfirmation) {
request.confirm_overwrite = typedConfirmation;
}
return request;
}
const validationMutation = useMutation(
() => validateRestore(buildRequest(false)),
{
onSuccess: (result) => {
setValidationResult(result);
setCurrentStepIndex(2);
},
onError: (apiError) =>
toast.showError(
'Die Vorabprüfung ist fehlgeschlagen',
describeApiError(apiError),
apiError.requestId,
),
},
);
const createMutation = useMutation(() => createRestore(buildRequest(true)), {
onSuccess: (session) => {
toast.showSuccess(
'Wiederherstellung gestartet',
'Der Fortschritt erscheint in der Übersicht.',
);
onStarted(session.id);
},
onError: (apiError) =>
toast.showError(
'Die Wiederherstellung ließ sich nicht starten',
describeApiError(apiError),
apiError.requestId,
),
});
const targetIsValid = targetPath.trim().length > 0 && targetPath.trim().startsWith('/');
// Die wörtliche Bestätigung wird nur verlangt, wenn tatsächlich
// überschrieben wird — der Server sagt das nach der Prüfung.
const confirmationRequired = validationResult?.requires_overwrite_confirmation ?? false;
const confirmationSatisfied =
!confirmationRequired || typedConfirmation.trim() === targetPath.trim();
return (
<DialogRoot open onOpenChange={(isOpen) => !isOpen && onClose()}>
<DialogContent size="lg">
<DialogHeader
title="Wiederherstellen"
description={backupLabel ?? backupIdentifier}
/>
<StepIndicator currentStepIndex={currentStepIndex} />
<DialogBody className="min-h-64">
{currentStepIndex === 0 ? (
<div className="space-y-4">
<TextInput
label="Zielverzeichnis"
required
placeholder="/srv/wiederherstellung"
value={targetPath}
onChange={(changeEvent) => setTargetPath(changeEvent.target.value)}
hint="Absoluter Pfad auf dem Server. Systemverzeichnisse wie /etc oder /usr werden abgelehnt."
error={
targetPath.trim() && !targetPath.trim().startsWith('/')
? 'Der Pfad muss absolut sein und mit / beginnen.'
: undefined
}
/>
<Callout tone="info" title="Wohin am besten?">
In ein <strong>leeres Verzeichnis</strong>. Zurück an den
Ursprungsort zu schreiben überschreibt genau die Daten, deren
Zustand Sie vielleicht noch brauchen — etwa um zu vergleichen,
was gefehlt hat.
</Callout>
</div>
) : null}
{currentStepIndex === 1 ? (
<div className="space-y-4">
<TextInput
label="Nur ein Teilbaum (optional)"
placeholder="daten/projekte"
value={pathPrefix}
onChange={(changeEvent) => setPathPrefix(changeEvent.target.value)}
hint="Leer lassen, um alles zurückzuschreiben. Ein Teilbaum-Restore hebt die Einstufung des Wiederherstellungspunkts nicht — er prüft einen Teil, nicht das Backup."
/>
<div className="space-y-3 border-t border-line pt-4">
<CheckboxField
checked={overwriteExisting}
onCheckedChange={setOverwriteExisting}
disabled={!mayOverwrite}
label="Vorhandene Dateien überschreiben"
hint={
mayOverwrite
? 'Ohne dieses Kennzeichen wird ein nicht leeres Ziel abgelehnt. Zusätzlich verlangt der Server danach den wörtlich wiederholten Zielpfad.'
: 'Ihrer Rolle fehlt die Berechtigung restores.overwrite. Sie steckt bewusst nicht in restores.execute.'
}
/>
<CheckboxField
checked={skipPermissions}
onCheckedChange={setSkipPermissions}
label="Rechte nicht zurückschreiben"
hint="Dateien entstehen mit den Standardrechten des Dienstkontos statt mit den gesicherten."
/>
<CheckboxField
checked={skipDeepCheck}
onCheckedChange={setSkipDeepCheck}
label="Blockprüfung überspringen"
hint="Beschleunigt die Vorabprüfung und senkt ihre Aussagekraft: Ohne sie ist nicht belegt, dass jeder benötigte Block noch vorhanden ist."
/>
</div>
{skipDeepCheck ? (
<Callout tone="warning">
Ohne Blockprüfung sagt die Vorabprüfung nur, dass das
Manifest lesbar ist — nicht, dass die Daten dazu noch
existieren. Der Bericht weist das aus.
</Callout>
) : null}
</div>
) : null}
{currentStepIndex === 2 ? (
<ValidationSummary
validationResult={validationResult}
isRunning={validationMutation.isRunning}
/>
) : null}
{currentStepIndex === 3 ? (
<div className="space-y-4">
<Callout tone={confirmationRequired ? 'warning' : 'info'}>
{confirmationRequired ? (
<>
Im Zielverzeichnis liegen bereits Daten. Sie werden{' '}
<strong>überschrieben</strong>.
</>
) : (
<>
Das Ziel ist leer. Es wird nichts überschrieben.
</>
)}
</Callout>
<dl className="grid gap-3 rounded-md border border-line bg-sunken p-3 sm:grid-cols-2">
<div>
<dt className="text-xs text-fg-subtle">Ziel</dt>
<dd className="break-all font-mono text-sm text-fg">{targetPath}</dd>
</div>
<div>
<dt className="text-xs text-fg-subtle">Umfang</dt>
<dd className="text-sm text-fg">
{validationResult?.report
? `${validationResult.report.file_count.toLocaleString('de-DE')} Dateien, ${formatBytes(validationResult.report.total_bytes)}`
: '—'}
</dd>
</div>
</dl>
{confirmationRequired ? (
<Field
label="Zielpfad zur Bestätigung wiederholen"
hint="Wörtlich. Ein versehentlich gesetztes Kennzeichen in einem Skript reicht damit nicht aus."
required
htmlFor="restore-confirm"
>
<TextInput
id="restore-confirm"
autoComplete="off"
className="font-mono"
placeholder={targetPath}
value={typedConfirmation}
onChange={(changeEvent) => setTypedConfirmation(changeEvent.target.value)}
/>
</Field>
) : null}
</div>
) : null}
</DialogBody>
<DialogFooter>
{currentStepIndex > 0 ? (
<Button
variant="ghost"
onClick={() => setCurrentStepIndex((stepIndex) => stepIndex - 1)}
disabled={validationMutation.isRunning || createMutation.isRunning}
>
<ArrowLeft />
Zurück
</Button>
) : (
<Button variant="ghost" onClick={onClose}>
Abbrechen
</Button>
)}
{currentStepIndex === 0 ? (
<Button
variant="primary"
disabled={!targetIsValid}
onClick={() => setCurrentStepIndex(1)}
>
Weiter
<ArrowRight />
</Button>
) : null}
{currentStepIndex === 1 ? (
<Button
variant="primary"
isLoading={validationMutation.isRunning}
onClick={() => void validationMutation.run()}
>
Vorabprüfung starten
<ArrowRight />
</Button>
) : null}
{currentStepIndex === 2 ? (
<Button
variant="primary"
disabled={!validationResult?.can_proceed}
onClick={() => setCurrentStepIndex(3)}
>
Weiter
<ArrowRight />
</Button>
) : null}
{currentStepIndex === 3 ? (
<Button
variant={confirmationRequired ? 'destructive' : 'primary'}
disabled={!confirmationSatisfied}
isLoading={createMutation.isRunning}
onClick={() => void createMutation.run()}
>
<Check />
Wiederherstellung starten
</Button>
) : null}
</DialogFooter>
</DialogContent>
</DialogRoot>
);
}
/** Schrittanzeige am Kopf des Assistenten. */
function StepIndicator({ currentStepIndex }: { readonly currentStepIndex: number }) {
return (
<ol className="flex items-center gap-1 border-b border-line px-5 py-3">
{WIZARD_STEPS.map((step, stepIndex) => (
<li key={step.key} className="flex items-center gap-1">
<span
className={cn(
'flex items-center gap-1.5 rounded-md px-2 py-1 text-xs',
stepIndex === currentStepIndex
? 'bg-accent-subtle font-medium text-accent'
: stepIndex < currentStepIndex
? 'text-healthy'
: 'text-fg-subtle',
)}
>
{stepIndex < currentStepIndex ? (
<Check className="size-3" aria-hidden />
) : (
<span className="tabular">{stepIndex + 1}.</span>
)}
{step.label}
</span>
{stepIndex < WIZARD_STEPS.length - 1 ? (
<span className="text-fg-subtle" aria-hidden>
/
</span>
) : null}
</li>
))}
</ol>
);
}
/**
* Ergebnis der Vorabprüfung.
*
* Fehlende Blöcke stehen ganz oben und in Rot. Sie sind der einzige Befund,
* bei dem feststeht, dass die Wiederherstellung **nicht vollständig** gelingen
* kann — alles andere ist ein Hinweis.
*/
function ValidationSummary({
validationResult,
isRunning,
}: {
readonly validationResult: ValidationResponse | null;
readonly isRunning: boolean;
}) {
if (isRunning || !validationResult) {
return (
<div className="py-10 text-center text-sm text-fg-muted">
Die Vorabprüfung läuft. Bei aktiver Blockprüfung wird jeder
benötigte Block gesucht — das dauert bei großen Beständen.
</div>
);
}
const report = validationResult.report;
const blockingFindings =
report?.findings.filter((finding) => finding.severity === 'blocking') ?? [];
const otherFindings =
report?.findings.filter((finding) => finding.severity !== 'blocking') ?? [];
return (
<div className="space-y-4">
<div
className={cn(
'flex items-start gap-3 rounded-md border p-3',
validationResult.can_proceed
? 'border-healthy/30 bg-healthy-bg'
: 'border-critical/30 bg-critical-bg',
)}
>
{validationResult.can_proceed ? (
<Check className="mt-0.5 size-5 shrink-0 text-healthy" aria-hidden />
) : (
<ShieldAlert className="mt-0.5 size-5 shrink-0 text-critical" aria-hidden />
)}
<div className="min-w-0">
<p
className={cn(
'text-sm font-medium',
validationResult.can_proceed ? 'text-healthy' : 'text-critical',
)}
>
{validationResult.can_proceed
? 'Die Wiederherstellung kann beginnen'
: 'Die Wiederherstellung ist nicht möglich'}
</p>
<p className="mt-0.5 text-sm text-fg">{validationResult.summary}</p>
</div>
</div>
{report ? (
<dl className="grid grid-cols-2 gap-3 rounded-md border border-line bg-sunken p-3 sm:grid-cols-4">
<div>
<dt className="text-xs text-fg-subtle">Dateien</dt>
<dd className="tabular text-sm text-fg">
{report.file_count.toLocaleString('de-DE')}
</dd>
</div>
<div>
<dt className="text-xs text-fg-subtle">Datenmenge</dt>
<dd className="tabular text-sm text-fg">{formatBytes(report.total_bytes)}</dd>
</div>
<div>
<dt className="text-xs text-fg-subtle">Freier Platz am Ziel</dt>
<dd className="tabular text-sm text-fg">
{/* -1 bedeutet unbekannt. Als 0 zu zeigen wäre das Gegenteil
der Wahrheit — es sähe nach "voll" aus. */}
{report.available_target_bytes < 0
? 'unbekannt'
: formatBytes(report.available_target_bytes)}
</dd>
</div>
<div>
<dt className="text-xs text-fg-subtle">Dauer der Prüfung</dt>
<dd className="tabular text-sm text-fg">
{formatDuration(report.duration_seconds)}
</dd>
</div>
</dl>
) : null}
{report && report.missing_chunk_count > 0 ? (
<Callout tone="critical" title={`${report.missing_chunk_count} Blöcke fehlen`}>
Von {report.unique_chunk_count.toLocaleString('de-DE')} benötigten
Blöcken sind {report.missing_chunk_count.toLocaleString('de-DE')} nicht
auffindbar. Die Wiederherstellung wäre unvollständig. Prüfen Sie das
Repository mit einem Integritätslauf.
</Callout>
) : null}
{blockingFindings.length > 0 ? (
<div className="space-y-2">
<p className="text-xs font-medium uppercase tracking-wide text-critical">
Verhindernde Befunde
</p>
{blockingFindings.map((finding, findingIndex) => (
<FindingRow key={`${finding.code}-${findingIndex}`} finding={finding} />
))}
</div>
) : null}
{otherFindings.length > 0 ? (
<div className="space-y-2">
<p className="text-xs font-medium uppercase tracking-wide text-fg-subtle">
Hinweise
</p>
{otherFindings.map((finding, findingIndex) => (
<FindingRow key={`${finding.code}-${findingIndex}`} finding={finding} />
))}
</div>
) : null}
</div>
);
}
/** Ein einzelner Befund mit Code, Meldung und betroffenem Objekt. */
function FindingRow({ finding }: { readonly finding: import('./restoresApi').ValidationFinding }) {
return (
<div className="flex items-start gap-2.5 rounded-md border border-line p-2.5">
<AlertTriangle
className={cn(
'mt-0.5 size-4 shrink-0',
finding.severity === 'blocking'
? 'text-critical'
: finding.severity === 'warning'
? 'text-warning'
: 'text-info',
)}
aria-hidden
/>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<StatusBadge
status={finding.severity === 'blocking' ? 'critical' : finding.severity}
showDot={false}
/>
<code className="font-mono text-xs text-fg-muted">{finding.code}</code>
</div>
<p className="mt-1 text-sm text-fg">{finding.message}</p>
{/* Der Befund nennt die betroffene Datei, nicht nur eine Zahl. */}
{finding.detail ? (
<p className="mt-0.5 break-all font-mono text-xs text-fg-muted">
{finding.detail}
</p>
) : null}
</div>
</div>
);
}