Files
Calbook/components/admin/backup-panel.tsx

313 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import { useRef, useState } from "react";
import {
AlertTriangle,
CheckCircle2,
Download,
HardDrive,
Loader2,
Upload,
XCircle
} from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
type ImportStep = {
label: string;
status: "ok" | "error" | "skipped";
detail: string;
};
type ImportResult = {
message: string;
importedAt: string;
steps: ImportStep[];
};
export function BackupPanel() {
const [downloading, setDownloading] = useState(false);
const [importing, setImporting] = useState(false);
const [importResult, setImportResult] = useState<ImportResult | null>(null);
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [preview, setPreview] = useState<Record<string, number> | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
async function handleDownload() {
setDownloading(true);
try {
const res = await fetch("/api/admin/backup");
if (!res.ok) {
const data = await res.json().catch(() => ({}));
toast.error(data?.message ?? "Backup konnte nicht erstellt werden.");
return;
}
const blob = await res.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `calbook-backup-${new Date().toISOString().slice(0, 10)}.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
window.URL.revokeObjectURL(url);
toast.success("Backup heruntergeladen.");
} catch {
toast.error("Backup konnte nicht erstellt werden.");
} finally {
setDownloading(false);
}
}
function handleFileSelected(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (!file) return;
setSelectedFile(file);
setImportResult(null);
const reader = new FileReader();
reader.onload = () => {
try {
const data = JSON.parse(reader.result as string);
const counts: Record<string, number> = {};
if (Array.isArray(data.settings)) counts["Settings"] = data.settings.length;
if (Array.isArray(data.users)) counts["Benutzer"] = data.users.length;
if (Array.isArray(data.calendarConns)) counts["Kalender"] = data.calendarConns.length;
if (Array.isArray(data.appointments)) counts["Termine"] = data.appointments.length;
if (Array.isArray(data.busyBlocks)) counts["Sync-Daten"] = data.busyBlocks.length;
if (Array.isArray(data.deliveryIssues)) counts["Zustellfehler"] = data.deliveryIssues.length;
if (Array.isArray(data.syncRuns)) counts["Sync-Logs"] = data.syncRuns.length;
setPreview(counts);
} catch {
setPreview(null);
toast.error("Ungültige Backup-Datei.");
}
};
reader.readAsText(file);
}
async function handleImport() {
if (!selectedFile) return;
setImporting(true);
setImportResult(null);
try {
const text = await selectedFile.text();
let data: unknown;
try {
data = JSON.parse(text);
} catch {
toast.error("Ungültige Backup-Datei.");
setImporting(false);
return;
}
const res = await fetch("/api/admin/backup", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data)
});
const result = await res.json();
if (!res.ok) {
toast.error(result?.message ?? "Import fehlgeschlagen.");
return;
}
setImportResult(result);
setSelectedFile(null);
if (fileInputRef.current) fileInputRef.current.value = "";
toast.success("Import abgeschlossen.");
} catch {
toast.error("Import fehlgeschlagen.");
} finally {
setImporting(false);
}
}
return (
<div className="max-w-6xl mx-auto">
<div className="mb-6">
<h1 className="text-3xl font-black tracking-tight text-slate-950">Backup</h1>
<p className="mt-1 text-sm font-medium text-slate-500">Daten exportieren und wiederherstellen</p>
</div>
{/* Export */}
<div className="rounded-2xl border border-slate-200 bg-white shadow-sm overflow-hidden mb-6">
<div className="border-b border-slate-100 px-5 py-3 flex items-center gap-2">
<Download className="h-4 w-4 text-slate-400" />
<h2 className="text-sm font-black text-slate-900">Export</h2>
</div>
<div className="p-5">
<p className="text-sm text-slate-600 mb-4">
Lädt alle Daten (Settings, Benutzer, Kalender, Termine) als JSON-Datei herunter.
Benutzer-Passwörter werden als bcrypt-Hashes gesichert nach einem Import sind alle Logins wieder funktionsfähig.
</p>
<Button type="button" onClick={() => void handleDownload()} disabled={downloading}>
<Download className="mr-2 h-4 w-4" />
{downloading ? "Wird erstellt..." : "Backup herunterladen"}
</Button>
</div>
</div>
{/* Import */}
<div className="rounded-2xl border border-slate-200 bg-white shadow-sm overflow-hidden">
<div className="border-b border-slate-100 px-5 py-3 flex items-center gap-2">
<Upload className="h-4 w-4 text-slate-400" />
<h2 className="text-sm font-black text-slate-900">Import</h2>
</div>
<div className="p-5 space-y-4">
<p className="text-sm text-slate-600">
Wähle eine zuvor exportierte Backup-Datei (.json) aus, um die Daten wiederherzustellen.
Bestehende Einträge werden aktualisiert, neue hinzugefügt. Keine Daten werden gelöscht.
</p>
{/* File picker */}
<div
className={cn(
"rounded-xl border-2 border-dashed p-6 text-center transition-colors",
selectedFile ? "border-indigo-300 bg-indigo-50" : "border-slate-200 hover:border-slate-300"
)}
>
<input
ref={fileInputRef}
type="file"
accept=".json"
onChange={handleFileSelected}
className="hidden"
id="backup-file-input"
/>
<label htmlFor="backup-file-input" className="cursor-pointer">
<HardDrive className="mx-auto h-8 w-8 text-slate-300 mb-2" />
{selectedFile ? (
<p className="text-sm font-bold text-indigo-700">{selectedFile.name}</p>
) : (
<p className="text-sm font-bold text-slate-500">
Klicke hier oder ziehe eine Backup-Datei
</p>
)}
<p className="text-xs text-slate-400 mt-1">.json</p>
</label>
</div>
{/* Preview */}
{preview && (
<div className="rounded-xl border border-slate-200 bg-slate-50 p-4">
<p className="text-xs font-black uppercase tracking-widest text-slate-400 mb-2">
Vorschau {Object.values(preview).reduce((a, b) => a + b, 0)} Einträge gefunden
</p>
<div className="grid grid-cols-2 gap-2 text-xs">
{Object.entries(preview).map(([label, count]) => (
<div key={label} className="flex items-center justify-between rounded-lg bg-white px-3 py-1.5 border border-slate-100">
<span className="text-slate-600">{label}</span>
<span className="font-bold text-slate-900">{count}</span>
</div>
))}
</div>
</div>
)}
<Button
type="button"
onClick={() => void handleImport()}
disabled={!selectedFile || importing}
className="w-full md:w-auto"
>
<Upload className="mr-2 h-4 w-4" />
{importing ? "Importiert..." : "Backup importieren"}
</Button>
{/* Result */}
{importing && (
<div className="rounded-xl border border-blue-200 bg-blue-50 p-4 flex items-center gap-3 text-sm text-blue-800">
<Loader2 className="h-5 w-5 animate-spin" />
Import läuft...
</div>
)}
{importResult && (
<div className="animate-in fade-in zoom-in-95 duration-200">
<div className={cn(
"rounded-xl border p-4 mb-3",
importResult.message.includes("Fehler") ? "border-amber-200 bg-amber-50" : "border-emerald-200 bg-emerald-50"
)}>
<div className={cn(
"flex items-center gap-2 mb-1",
importResult.message.includes("Fehler") ? "text-amber-800" : "text-emerald-800"
)}>
{importResult.message.includes("Fehler") ? (
<AlertTriangle className="h-5 w-5" />
) : (
<CheckCircle2 className="h-5 w-5" />
)}
<p className="font-bold">{importResult.message}</p>
</div>
</div>
<div className="space-y-1">
{importResult.steps.map((step, i) => (
<div
key={i}
className={cn(
"flex items-center gap-3 rounded-lg border px-3 py-2 text-xs",
step.status === "ok" && "border-emerald-100 bg-emerald-50/50",
step.status === "error" && "border-red-100 bg-red-50/50",
step.status === "skipped" && "border-slate-100 bg-slate-50/50"
)}
>
<div className="shrink-0">
{step.status === "ok" ? (
<CheckCircle2 className="h-4 w-4 text-emerald-600" />
) : step.status === "error" ? (
<XCircle className="h-4 w-4 text-red-500" />
) : (
<div className="h-4 w-4 rounded-full border-2 border-slate-300" />
)}
</div>
<div className="flex-1 min-w-0">
<p className={cn(
"font-bold",
step.status === "ok" && "text-emerald-900",
step.status === "error" && "text-red-800",
step.status === "skipped" && "text-slate-400"
)}>
{step.label}
</p>
<p className={cn(
"truncate",
step.status === "ok" && "text-emerald-700",
step.status === "error" && "text-red-600",
step.status === "skipped" && "text-slate-400"
)}>
{step.detail}
</p>
</div>
<span className={cn(
"shrink-0 text-[10px] font-black uppercase tracking-wider rounded-full px-1.5 py-0.5",
step.status === "ok" && "bg-emerald-100 text-emerald-700",
step.status === "error" && "bg-red-100 text-red-700",
step.status === "skipped" && "bg-slate-100 text-slate-400"
)}>
{step.status === "ok" ? "OK" : step.status === "error" ? "FEHLER" : ""}
</span>
</div>
))}
</div>
</div>
)}
{importResult === null && !importing && selectedFile && !preview && (
<div className="rounded-xl border border-red-200 bg-red-50 p-3 flex items-center gap-2 text-sm text-red-800">
<XCircle className="h-4 w-4" /> Datei konnte nicht als Backup erkannt werden.
</div>
)}
</div>
</div>
</div>
);
}