Validate backups before restore
This commit is contained in:
@@ -32,6 +32,12 @@ type DeleteSessionResult = {
|
||||
};
|
||||
};
|
||||
|
||||
export type RestoreValidationResult = {
|
||||
valid: boolean;
|
||||
checkedTables: number;
|
||||
issues: string[];
|
||||
};
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
@@ -107,6 +113,13 @@ export function restoreSetupBackup(sql: string) {
|
||||
});
|
||||
}
|
||||
|
||||
export function validateSetupBackup(sql: string) {
|
||||
return request<RestoreValidationResult>("/api/setup/restore/validate", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ sql })
|
||||
});
|
||||
}
|
||||
|
||||
export function getAdminUsers() {
|
||||
return request<{ users: AdminUser[] }>("/api/admin/users");
|
||||
}
|
||||
@@ -164,6 +177,13 @@ export function restoreDatabaseBackup(sql: string) {
|
||||
});
|
||||
}
|
||||
|
||||
export function validateDatabaseBackup(sql: string) {
|
||||
return request<RestoreValidationResult>("/api/admin/restore/database/validate", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ sql })
|
||||
});
|
||||
}
|
||||
|
||||
export async function downloadMonthExport(month: string) {
|
||||
const response = await fetch(`/api/export/months/${month}`, {
|
||||
credentials: "same-origin"
|
||||
|
||||
@@ -27,24 +27,6 @@ export const helpSections: HelpSection[] = [
|
||||
"Der Monatsabschluss sperrt den Monat gegen versehentliche Änderungen. Muss doch noch etwas korrigiert werden, kann der Monat wieder geöffnet werden."
|
||||
]
|
||||
},
|
||||
{
|
||||
id: "erstes-setup",
|
||||
title: "Erstes Setup",
|
||||
description: "Wenn noch kein Admin existiert, startet TicketTracker mit einer Setup-Seite.",
|
||||
quickItems: [
|
||||
"Admin direkt im Browser anlegen.",
|
||||
"Alternativ ein TicketTracker-SQL-Backup einspielen.",
|
||||
"Nach Restore gelten ausschließlich die Daten aus dem Backup."
|
||||
],
|
||||
details: [
|
||||
"Admin-Zugangsdaten werden nicht mehr über .env gesetzt.",
|
||||
"Beim ersten Start ohne Admin zeigt TicketTracker automatisch die Setup-Seite.",
|
||||
"Über Admin anlegen erstellst du den festen Admin und wirst direkt angemeldet.",
|
||||
"Über Backup einspielen spielst du ein restore-fähiges SQL-Backup aus TicketTracker ein.",
|
||||
"Beim Restore werden zuerst alle TicketTracker-Tabellen geleert. Danach gelten Benutzer, Passwörter, Sessions, Teamspace-Werte, Settings und Abschlüsse aus dem Backup.",
|
||||
"Nach einem Restore meldest du dich mit einem Benutzer aus dem Backup an."
|
||||
]
|
||||
},
|
||||
{
|
||||
id: "monatsworkflow",
|
||||
title: "Monatsworkflow",
|
||||
@@ -99,6 +81,26 @@ export const helpSections: HelpSection[] = [
|
||||
"Monat abschließen"
|
||||
]
|
||||
},
|
||||
{
|
||||
id: "erstes-setup",
|
||||
title: "Erstes Setup",
|
||||
description: "Wenn noch kein Admin existiert, startet TicketTracker mit einer Setup-Seite.",
|
||||
quickItems: [
|
||||
"Admin direkt im Browser anlegen.",
|
||||
"Alternativ ein TicketTracker-SQL-Backup prüfen und einspielen.",
|
||||
"Nach Restore gelten ausschließlich die Daten aus dem Backup."
|
||||
],
|
||||
details: [
|
||||
"Admin-Zugangsdaten werden nicht mehr über .env gesetzt.",
|
||||
"Beim ersten Start ohne Admin zeigt TicketTracker automatisch die Setup-Seite.",
|
||||
"Über Admin anlegen erstellst du den festen Admin und wirst direkt angemeldet.",
|
||||
"Über Backup einspielen lädst du ein restore-fähiges SQL-Backup aus TicketTracker hoch.",
|
||||
"Nach dem Hochladen prüft TicketTracker die Dump-Struktur gegen die aktuelle Datenbankstruktur.",
|
||||
"Der Restore-Button wird erst aktiv, wenn keine Felder fehlen und keine unbekannten Felder enthalten sind.",
|
||||
"Beim Restore werden zuerst alle TicketTracker-Tabellen geleert. Danach gelten Benutzer, Passwörter, Sessions, Teamspace-Werte, Settings und Abschlüsse aus dem Backup.",
|
||||
"Nach einem Restore meldest du dich mit einem Benutzer aus dem Backup an."
|
||||
]
|
||||
},
|
||||
{
|
||||
id: "timer",
|
||||
title: "Timer",
|
||||
@@ -466,7 +468,9 @@ export const helpSections: HelpSection[] = [
|
||||
"Der feste Admin-Zugang kann nicht deaktiviert oder zur normalen User-Rolle herabgestuft werden.",
|
||||
"Der Data-Sync speichert Zammad-Zugangsdaten und synchronisiert Organisationen in die lokale Datenbank.",
|
||||
"Der Datenbankexport erzeugt eine SQL-Datei mit Benutzern, Einstellungen, Organisationen, Tickets, Sessions, Teamspace-Werten, fixen Abrechnungen und Abschlüssen.",
|
||||
"Der Restore im Adminbereich leert zuerst alle TicketTracker-Tabellen und spielt danach den Backup-Stand ein.",
|
||||
"Der Restore im Adminbereich prüft zuerst, ob die Dump-Struktur zur aktuellen Datenbank passt.",
|
||||
"Der Restore-Button wird erst nach erfolgreicher Prüfung aktiv.",
|
||||
"Beim Einspielen leert TicketTracker zuerst alle Tabellen und spielt danach den Backup-Stand ein.",
|
||||
"Nach einem Restore ist eine neue Anmeldung mit den Zugangsdaten aus dem Backup notwendig.",
|
||||
"Für einen Restore lässt du auf dem Zielsystem zuerst die TicketTracker-Migrationen laufen und spielst danach die SQL-Datei mit psql ein."
|
||||
]
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ChangeEvent, FormEvent, useEffect, useState } from "react";
|
||||
import { DatabaseBackup, DatabaseZap, Save, Shield, Upload, UserPlus } from "lucide-react";
|
||||
import { CheckCircle2, DatabaseBackup, DatabaseZap, Save, Shield, Upload, UserPlus } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
@@ -18,7 +18,9 @@ import {
|
||||
restoreDatabaseBackup,
|
||||
saveZammadSettings,
|
||||
syncZammadOrganizations,
|
||||
updateAdminUser
|
||||
updateAdminUser,
|
||||
validateDatabaseBackup,
|
||||
type RestoreValidationResult
|
||||
} from "../api";
|
||||
import type { AdminUser, AuthUser } from "../types";
|
||||
|
||||
@@ -56,6 +58,8 @@ export function AdminUsersPage({ currentUser }: { currentUser: AuthUser }) {
|
||||
const [savingZammadSettings, setSavingZammadSettings] = useState(false);
|
||||
const [exportingDatabase, setExportingDatabase] = useState(false);
|
||||
const [restoreSql, setRestoreSql] = useState("");
|
||||
const [restoreValidation, setRestoreValidation] = useState<RestoreValidationResult | null>(null);
|
||||
const [validatingRestore, setValidatingRestore] = useState(false);
|
||||
const [restoringDatabase, setRestoringDatabase] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [savingId, setSavingId] = useState<string | null>(null);
|
||||
@@ -240,12 +244,57 @@ export function AdminUsersPage({ currentUser }: { currentUser: AuthUser }) {
|
||||
return;
|
||||
}
|
||||
|
||||
setRestoreSql(await file.text());
|
||||
const sql = await file.text();
|
||||
setRestoreSql(sql);
|
||||
setRestoreValidation(null);
|
||||
await validateRestore(sql);
|
||||
}
|
||||
|
||||
async function validateRestore(sql = restoreSql) {
|
||||
if (!sql.trim()) {
|
||||
setRestoreValidation(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setValidatingRestore(true);
|
||||
|
||||
try {
|
||||
const result = await validateDatabaseBackup(sql);
|
||||
setRestoreValidation(result);
|
||||
|
||||
if (result.valid) {
|
||||
toast.success("Backup-Struktur passt", {
|
||||
description: `${result.checkedTables} Tabelle(n) geprüft.`
|
||||
});
|
||||
} else {
|
||||
toast.error("Backup-Struktur passt nicht", {
|
||||
description: result.issues[0] ?? "Unbekannter Strukturfehler"
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
setRestoreValidation({
|
||||
valid: false,
|
||||
checkedTables: 0,
|
||||
issues: [error instanceof Error ? error.message : "Unbekannter Fehler"]
|
||||
});
|
||||
toast.error("Backup-Prüfung fehlgeschlagen", {
|
||||
description: error instanceof Error ? error.message : "Unbekannter Fehler"
|
||||
});
|
||||
} finally {
|
||||
setValidatingRestore(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function restoreDatabase(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
|
||||
if (!restoreValidation?.valid) {
|
||||
toast.error("Backup zuerst prüfen", {
|
||||
description: "Der Restore kann erst nach erfolgreicher Strukturprüfung ausgeführt werden."
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!window.confirm("Restore wirklich einspielen? Alle aktuellen TicketTracker-Daten werden durch den Stand des Backups ersetzt.")) {
|
||||
return;
|
||||
}
|
||||
@@ -276,8 +325,8 @@ export function AdminUsersPage({ currentUser }: { currentUser: AuthUser }) {
|
||||
<p className="text-sm text-muted-foreground">Systemverwaltung für Benutzer, Zammad-Organisationen und restore-fähige Backups.</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 xl:grid-cols-[1.35fr_0.65fr]">
|
||||
<Card>
|
||||
<div className="grid min-w-0 gap-4 2xl:grid-cols-[minmax(0,1.2fr)_minmax(340px,0.8fr)]">
|
||||
<Card className="min-w-0">
|
||||
<CardHeader className="p-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -289,7 +338,7 @@ export function AdminUsersPage({ currentUser }: { currentUser: AuthUser }) {
|
||||
<CardDescription>Zammad-Zugang speichern und Organisationen in die lokale TicketTracker-Datenbank übernehmen.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="px-4 pb-4">
|
||||
<form className="grid gap-3 lg:grid-cols-[minmax(220px,1fr)_minmax(220px,1fr)_auto_auto] lg:items-end" onSubmit={syncOrganizations}>
|
||||
<form className="grid gap-3 xl:grid-cols-2" onSubmit={syncOrganizations}>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium" htmlFor="zammad-base-url">Zammad URL</label>
|
||||
<Input
|
||||
@@ -305,25 +354,26 @@ export function AdminUsersPage({ currentUser }: { currentUser: AuthUser }) {
|
||||
<Input
|
||||
id="zammad-api-key"
|
||||
type="password"
|
||||
placeholder={hasZammadApiKey ? "Gespeicherter API-Key wird verwendet" : ""}
|
||||
placeholder={hasZammadApiKey ? "••••••••••••••••" : ""}
|
||||
value={zammadApiKey}
|
||||
onChange={(event) => setZammadApiKey(event.currentTarget.value)}
|
||||
/>
|
||||
{hasZammadApiKey ? <p className="text-xs text-muted-foreground">API-Key ist gespeichert. Leer lassen, um ihn weiter zu verwenden.</p> : null}
|
||||
</div>
|
||||
<Button type="button" variant="secondary" disabled={savingZammadSettings} onClick={() => void saveZammadAccess()}>
|
||||
<Save className="size-4" />
|
||||
{savingZammadSettings ? "Speichert..." : "Zugang speichern"}
|
||||
</Button>
|
||||
<Button type="submit" disabled={syncingOrganizations}>
|
||||
<DatabaseZap className="size-4" />
|
||||
{syncingOrganizations ? "Synchronisiert..." : "Organisationen synchronisieren"}
|
||||
</Button>
|
||||
<div className="flex flex-wrap gap-2 xl:col-span-2">
|
||||
<Button type="button" variant="secondary" disabled={savingZammadSettings} onClick={() => void saveZammadAccess()}>
|
||||
<Save className="size-4" />
|
||||
{savingZammadSettings ? "Speichert..." : "Zugang speichern"}
|
||||
</Button>
|
||||
<Button type="submit" disabled={syncingOrganizations}>
|
||||
<DatabaseZap className="size-4" />
|
||||
{syncingOrganizations ? "Synchronisiert..." : "Organisationen synchronisieren"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<Card className="min-w-0">
|
||||
<CardHeader className="p-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -351,10 +401,27 @@ export function AdminUsersPage({ currentUser }: { currentUser: AuthUser }) {
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<Input type="file" accept=".sql,text/sql,text/plain" onChange={(event) => void readRestoreFile(event)} />
|
||||
<Button className="w-full" type="submit" variant="destructive" disabled={restoringDatabase || !restoreSql.trim()}>
|
||||
<Upload className="size-4" />
|
||||
{restoringDatabase ? "Spielt ein..." : "SQL-Backup einspielen"}
|
||||
</Button>
|
||||
{restoreValidation ? (
|
||||
<Alert variant={restoreValidation.valid ? "success" : "destructive"}>
|
||||
{restoreValidation.valid ? <CheckCircle2 className="size-4" /> : <Upload className="size-4" />}
|
||||
<AlertTitle>{restoreValidation.valid ? "Strukturprüfung erfolgreich" : "Strukturprüfung fehlgeschlagen"}</AlertTitle>
|
||||
<AlertDescription>
|
||||
{restoreValidation.valid
|
||||
? `${restoreValidation.checkedTables} Tabelle(n) passen zum aktuellen Datenbankschema.`
|
||||
: restoreValidation.issues.slice(0, 4).join(" ")}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
<Button type="button" variant="secondary" disabled={validatingRestore || !restoreSql.trim()} onClick={() => void validateRestore()}>
|
||||
<CheckCircle2 className="size-4" />
|
||||
{validatingRestore ? "Prüft..." : "Struktur prüfen"}
|
||||
</Button>
|
||||
<Button type="submit" variant="destructive" disabled={restoringDatabase || !restoreValidation?.valid}>
|
||||
<Upload className="size-4" />
|
||||
{restoringDatabase ? "Spielt ein..." : "SQL-Backup einspielen"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ChangeEvent, FormEvent, useState } from "react";
|
||||
import { DatabaseBackup, KeyRound, Upload } from "lucide-react";
|
||||
import { CheckCircle2, DatabaseBackup, KeyRound, Upload } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
@@ -8,7 +8,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { createSetupAdmin, restoreSetupBackup } from "../api";
|
||||
import { createSetupAdmin, restoreSetupBackup, validateSetupBackup, type RestoreValidationResult } from "../api";
|
||||
import type { AuthUser } from "../types";
|
||||
|
||||
type SetupPageProps = {
|
||||
@@ -22,7 +22,9 @@ export function SetupPage({ onAdminCreated, onRestoreComplete }: SetupPageProps)
|
||||
const [password, setPassword] = useState("");
|
||||
const [passwordRepeat, setPasswordRepeat] = useState("");
|
||||
const [restoreSql, setRestoreSql] = useState("");
|
||||
const [restoreValidation, setRestoreValidation] = useState<RestoreValidationResult | null>(null);
|
||||
const [creatingAdmin, setCreatingAdmin] = useState(false);
|
||||
const [validatingRestore, setValidatingRestore] = useState(false);
|
||||
const [restoring, setRestoring] = useState(false);
|
||||
|
||||
async function createAdmin(event: FormEvent) {
|
||||
@@ -55,11 +57,57 @@ export function SetupPage({ onAdminCreated, onRestoreComplete }: SetupPageProps)
|
||||
return;
|
||||
}
|
||||
|
||||
setRestoreSql(await file.text());
|
||||
const sql = await file.text();
|
||||
setRestoreSql(sql);
|
||||
setRestoreValidation(null);
|
||||
await validateRestore(sql);
|
||||
}
|
||||
|
||||
async function validateRestore(sql = restoreSql) {
|
||||
if (!sql.trim()) {
|
||||
setRestoreValidation(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setValidatingRestore(true);
|
||||
|
||||
try {
|
||||
const result = await validateSetupBackup(sql);
|
||||
setRestoreValidation(result);
|
||||
|
||||
if (result.valid) {
|
||||
toast.success("Backup-Struktur passt", {
|
||||
description: `${result.checkedTables} Tabelle(n) geprüft.`
|
||||
});
|
||||
} else {
|
||||
toast.error("Backup-Struktur passt nicht", {
|
||||
description: result.issues[0] ?? "Unbekannter Strukturfehler"
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
setRestoreValidation({
|
||||
valid: false,
|
||||
checkedTables: 0,
|
||||
issues: [error instanceof Error ? error.message : "Unbekannter Fehler"]
|
||||
});
|
||||
toast.error("Backup-Prüfung fehlgeschlagen", {
|
||||
description: error instanceof Error ? error.message : "Unbekannter Fehler"
|
||||
});
|
||||
} finally {
|
||||
setValidatingRestore(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function restoreBackup(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
|
||||
if (!restoreValidation?.valid) {
|
||||
toast.error("Backup zuerst prüfen", {
|
||||
description: "Der Restore kann erst nach erfolgreicher Strukturprüfung ausgeführt werden."
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setRestoring(true);
|
||||
|
||||
try {
|
||||
@@ -156,15 +204,35 @@ export function SetupPage({ onAdminCreated, onRestoreComplete }: SetupPageProps)
|
||||
id="setup-restore-sql"
|
||||
className="min-h-44 font-mono text-xs"
|
||||
value={restoreSql}
|
||||
onChange={(event) => setRestoreSql(event.currentTarget.value)}
|
||||
onChange={(event) => {
|
||||
setRestoreSql(event.currentTarget.value);
|
||||
setRestoreValidation(null);
|
||||
}}
|
||||
placeholder="SQL-Backup hier einfügen oder Datei auswählen"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<Button className="w-full" type="submit" variant="destructive" disabled={restoring || !restoreSql.trim()}>
|
||||
<DatabaseBackup className="size-4" />
|
||||
{restoring ? "Spielt ein..." : "Backup einspielen"}
|
||||
</Button>
|
||||
{restoreValidation ? (
|
||||
<Alert variant={restoreValidation.valid ? "success" : "destructive"}>
|
||||
{restoreValidation.valid ? <CheckCircle2 className="size-4" /> : <Upload className="size-4" />}
|
||||
<AlertTitle>{restoreValidation.valid ? "Strukturprüfung erfolgreich" : "Strukturprüfung fehlgeschlagen"}</AlertTitle>
|
||||
<AlertDescription>
|
||||
{restoreValidation.valid
|
||||
? `${restoreValidation.checkedTables} Tabelle(n) passen zum aktuellen Datenbankschema.`
|
||||
: restoreValidation.issues.slice(0, 4).join(" ")}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
<Button type="button" variant="secondary" disabled={validatingRestore || !restoreSql.trim()} onClick={() => void validateRestore()}>
|
||||
<CheckCircle2 className="size-4" />
|
||||
{validatingRestore ? "Prüft..." : "Struktur prüfen"}
|
||||
</Button>
|
||||
<Button type="submit" variant="destructive" disabled={restoring || !restoreValidation?.valid}>
|
||||
<DatabaseBackup className="size-4" />
|
||||
{restoring ? "Spielt ein..." : "Backup einspielen"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
Reference in New Issue
Block a user