Add setup and restore flow
This commit is contained in:
+29
-1
@@ -53,7 +53,7 @@ import {
|
||||
import { usePreferencesStore } from "@/stores/preferences/preferences-provider";
|
||||
|
||||
import { cn } from "./lib/utils";
|
||||
import { ApiError, getCurrentUser, logout, lookupTicket } from "./api";
|
||||
import { ApiError, getCurrentUser, getSetupStatus, logout, lookupTicket } from "./api";
|
||||
import { formatTimer } from "./format";
|
||||
import { activeElapsedMs, pauseEntry, readStoredTimers, resumeEntry, storageKeyForUser, ticketPattern, type TimerEntry } from "./timers";
|
||||
import { AdminUsersPage } from "./views/AdminUsersPage";
|
||||
@@ -64,6 +64,7 @@ import { MonthlyClosePage } from "./views/MonthlyClosePage";
|
||||
import { ProfilePage } from "./views/ProfilePage";
|
||||
import { RecurringBillingsPage } from "./views/RecurringBillingsPage";
|
||||
import { StatisticsPage } from "./views/StatisticsPage";
|
||||
import { SetupPage } from "./views/SetupPage";
|
||||
import { TicketDetailPage } from "./views/TicketDetailPage";
|
||||
import { TimerPage } from "./views/TimerPage";
|
||||
import type { AuthUser, PeriodType } from "./types";
|
||||
@@ -345,6 +346,7 @@ export function App() {
|
||||
const { path, route, navHandler, navigate } = useAppRouter();
|
||||
const [currentUser, setCurrentUser] = useState<AuthUser | null>(null);
|
||||
const [authLoading, setAuthLoading] = useState(true);
|
||||
const [setupRequired, setSetupRequired] = useState(false);
|
||||
const [timers, setTimers] = useState<TimerEntry[]>([]);
|
||||
const [timersLoaded, setTimersLoaded] = useState(false);
|
||||
const [timerOwnerId, setTimerOwnerId] = useState<string | null>(null);
|
||||
@@ -354,6 +356,15 @@ export function App() {
|
||||
useEffect(() => {
|
||||
async function loadUser() {
|
||||
try {
|
||||
const setup = await getSetupStatus();
|
||||
|
||||
if (setup.required) {
|
||||
setSetupRequired(true);
|
||||
setCurrentUser(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setSetupRequired(false);
|
||||
const result = await getCurrentUser();
|
||||
setCurrentUser(result.user);
|
||||
} catch (error) {
|
||||
@@ -554,6 +565,23 @@ export function App() {
|
||||
);
|
||||
}
|
||||
|
||||
if (setupRequired) {
|
||||
return (
|
||||
<SetupPage
|
||||
onAdminCreated={(user) => {
|
||||
setSetupRequired(false);
|
||||
setCurrentUser(user);
|
||||
navigate("/admin/users");
|
||||
}}
|
||||
onRestoreComplete={() => {
|
||||
setSetupRequired(false);
|
||||
setCurrentUser(null);
|
||||
navigate("/");
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (!currentUser) {
|
||||
return <LoginPage onLogin={setCurrentUser} />;
|
||||
}
|
||||
|
||||
@@ -89,6 +89,24 @@ export function updateCurrentUser(payload: {
|
||||
});
|
||||
}
|
||||
|
||||
export function getSetupStatus() {
|
||||
return request<{ required: boolean }>("/api/setup/status");
|
||||
}
|
||||
|
||||
export function createSetupAdmin(payload: { username: string; displayName: string; password: string }) {
|
||||
return request<{ user: AuthUser }>("/api/setup/admin", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
}
|
||||
|
||||
export function restoreSetupBackup(sql: string) {
|
||||
return request<{ ok: boolean }>("/api/setup/restore", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ sql })
|
||||
});
|
||||
}
|
||||
|
||||
export function getAdminUsers() {
|
||||
return request<{ users: AdminUser[] }>("/api/admin/users");
|
||||
}
|
||||
@@ -139,6 +157,13 @@ export async function downloadDatabaseExport() {
|
||||
};
|
||||
}
|
||||
|
||||
export function restoreDatabaseBackup(sql: string) {
|
||||
return request<{ ok: boolean }>("/api/admin/restore/database", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ sql })
|
||||
});
|
||||
}
|
||||
|
||||
export async function downloadMonthExport(month: string) {
|
||||
const response = await fetch(`/api/export/months/${month}`, {
|
||||
credentials: "same-origin"
|
||||
|
||||
@@ -27,6 +27,24 @@ 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",
|
||||
@@ -448,6 +466,8 @@ 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.",
|
||||
"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,7 +1,8 @@
|
||||
import { FormEvent, useEffect, useState } from "react";
|
||||
import { DatabaseBackup, DatabaseZap, Save, Shield, UserPlus } from "lucide-react";
|
||||
import { ChangeEvent, FormEvent, useEffect, useState } from "react";
|
||||
import { DatabaseBackup, DatabaseZap, Save, Shield, Upload, UserPlus } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
@@ -14,6 +15,7 @@ import {
|
||||
downloadDatabaseExport,
|
||||
getAdminUsers,
|
||||
getZammadSettings,
|
||||
restoreDatabaseBackup,
|
||||
saveZammadSettings,
|
||||
syncZammadOrganizations,
|
||||
updateAdminUser
|
||||
@@ -53,6 +55,8 @@ export function AdminUsersPage({ currentUser }: { currentUser: AuthUser }) {
|
||||
const [syncingOrganizations, setSyncingOrganizations] = useState(false);
|
||||
const [savingZammadSettings, setSavingZammadSettings] = useState(false);
|
||||
const [exportingDatabase, setExportingDatabase] = useState(false);
|
||||
const [restoreSql, setRestoreSql] = useState("");
|
||||
const [restoringDatabase, setRestoringDatabase] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [savingId, setSavingId] = useState<string | null>(null);
|
||||
const [creating, setCreating] = useState(false);
|
||||
@@ -229,6 +233,40 @@ export function AdminUsersPage({ currentUser }: { currentUser: AuthUser }) {
|
||||
}
|
||||
}
|
||||
|
||||
async function readRestoreFile(event: ChangeEvent<HTMLInputElement>) {
|
||||
const file = event.currentTarget.files?.[0];
|
||||
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
|
||||
setRestoreSql(await file.text());
|
||||
}
|
||||
|
||||
async function restoreDatabase(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
|
||||
if (!window.confirm("Restore wirklich einspielen? Alle aktuellen TicketTracker-Daten werden durch den Stand des Backups ersetzt.")) {
|
||||
return;
|
||||
}
|
||||
|
||||
setRestoringDatabase(true);
|
||||
|
||||
try {
|
||||
await restoreDatabaseBackup(restoreSql);
|
||||
toast.success("Restore eingespielt", {
|
||||
description: "Melde dich jetzt mit einem Benutzer aus dem Backup neu an."
|
||||
});
|
||||
window.location.href = "/";
|
||||
} catch (error) {
|
||||
toast.error("Restore fehlgeschlagen", {
|
||||
description: error instanceof Error ? error.message : "Unbekannter Fehler"
|
||||
});
|
||||
} finally {
|
||||
setRestoringDatabase(false);
|
||||
}
|
||||
}
|
||||
|
||||
const regularUserCount = users.filter((user) => user.role === "user").length;
|
||||
|
||||
return (
|
||||
@@ -290,11 +328,11 @@ export function AdminUsersPage({ currentUser }: { currentUser: AuthUser }) {
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<DatabaseBackup className="size-5 text-muted-foreground" />
|
||||
<CardTitle>Datenbankexport</CardTitle>
|
||||
<CardTitle>Backup und Restore</CardTitle>
|
||||
</div>
|
||||
<HelpLink anchor="admin" label="Hilfe zum Datenbankexport" />
|
||||
<HelpLink anchor="admin" label="Hilfe zu Backup und Restore" />
|
||||
</div>
|
||||
<CardDescription>SQL-Backup für einen vollständigen Restore auf einer frischen TicketTracker-Installation.</CardDescription>
|
||||
<CardDescription>SQL-Backup herunterladen oder einen kompletten Systemstand wiederherstellen.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3 px-4 pb-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
@@ -304,6 +342,20 @@ export function AdminUsersPage({ currentUser }: { currentUser: AuthUser }) {
|
||||
<DatabaseBackup className="size-4" />
|
||||
{exportingDatabase ? "Exportiert..." : "SQL-Backup herunterladen"}
|
||||
</Button>
|
||||
<form className="space-y-3 rounded-md border bg-muted/20 p-3" onSubmit={restoreDatabase}>
|
||||
<Alert variant="warning">
|
||||
<Upload className="size-4" />
|
||||
<AlertTitle>Restore ersetzt alles</AlertTitle>
|
||||
<AlertDescription>
|
||||
Vor dem Einspielen werden alle Tabellen geleert. Danach gilt nur noch der Stand des Backups, inklusive aller User, Passwörter, Sessions und Settings.
|
||||
</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>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { helpSections } from "../help-content";
|
||||
import { cn } from "../lib/utils";
|
||||
|
||||
function WorkflowList({ items }: { items: string[] }) {
|
||||
return (
|
||||
@@ -68,38 +69,44 @@ export function FaqPage() {
|
||||
</Card>
|
||||
|
||||
<div className="grid gap-3 xl:grid-cols-2">
|
||||
{helpSections.map((section) => (
|
||||
<Card key={section.id} id={section.id} className="scroll-mt-16">
|
||||
{helpSections.map((section) => {
|
||||
const isWide = section.id === "grundprinzip" || section.id === "monatsworkflow";
|
||||
const isWorkflow = section.id === "monatsworkflow";
|
||||
|
||||
return (
|
||||
<Card key={section.id} id={section.id} className={cn("scroll-mt-16", isWide ? "xl:col-span-2" : "")}>
|
||||
<CardHeader className="p-4">
|
||||
<CardTitle>{section.title}</CardTitle>
|
||||
<CardDescription>{section.description}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4 px-4 pb-4">
|
||||
<div className="rounded-md border bg-muted/20 p-3">
|
||||
<p className="mb-2 text-sm font-medium">Kurzfassung</p>
|
||||
<ul className="space-y-2 text-sm text-muted-foreground">
|
||||
{section.quickItems.map((item) => (
|
||||
<li key={item} className="flex gap-2">
|
||||
<span className="mt-2 size-1.5 shrink-0 rounded-full bg-primary" />
|
||||
<span>{item}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<CardContent className={cn("px-4 pb-4", isWorkflow ? "grid gap-5 lg:grid-cols-[minmax(0,1fr)_minmax(320px,0.9fr)]" : "space-y-4")}>
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-md border bg-muted/20 p-3">
|
||||
<p className="mb-2 text-sm font-medium">Kurzfassung</p>
|
||||
<ul className="space-y-2 text-sm text-muted-foreground">
|
||||
{section.quickItems.map((item) => (
|
||||
<li key={item} className="flex gap-2">
|
||||
<span className="mt-2 size-1.5 shrink-0 rounded-full bg-primary" />
|
||||
<span>{item}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<p className="mb-2 text-sm font-medium">Details</p>
|
||||
<ul className="space-y-2 text-sm text-muted-foreground">
|
||||
{section.details.map((item) => (
|
||||
<li key={item} className="flex gap-2">
|
||||
<span className="mt-2 size-1.5 shrink-0 rounded-full bg-primary/70" />
|
||||
<span>{item}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
{section.workflow ? <WorkflowList items={section.workflow} /> : null}
|
||||
</div>
|
||||
<div>
|
||||
<p className="mb-2 text-sm font-medium">Details</p>
|
||||
<ul className="space-y-2 text-sm text-muted-foreground">
|
||||
{section.details.map((item) => (
|
||||
<li key={item} className="flex gap-2">
|
||||
<span className="mt-2 size-1.5 shrink-0 rounded-full bg-primary/70" />
|
||||
<span>{item}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
{section.workflow ? <WorkflowList items={section.workflow} /> : null}
|
||||
{section.diagram ? <FlowDiagram items={section.diagram} /> : null}
|
||||
<div className="flex justify-end">
|
||||
<div className={cn("flex justify-end", isWorkflow ? "lg:col-span-2" : "")}>
|
||||
<Button asChild variant="ghost" size="sm">
|
||||
<a href="#top">
|
||||
<ArrowUp className="size-4" />
|
||||
@@ -109,7 +116,8 @@ export function FaqPage() {
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
import { ChangeEvent, FormEvent, useState } from "react";
|
||||
import { DatabaseBackup, KeyRound, Upload } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
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 type { AuthUser } from "../types";
|
||||
|
||||
type SetupPageProps = {
|
||||
onAdminCreated: (user: AuthUser) => void;
|
||||
onRestoreComplete: () => void;
|
||||
};
|
||||
|
||||
export function SetupPage({ onAdminCreated, onRestoreComplete }: SetupPageProps) {
|
||||
const [username, setUsername] = useState("admin");
|
||||
const [displayName, setDisplayName] = useState("Admin");
|
||||
const [password, setPassword] = useState("");
|
||||
const [passwordRepeat, setPasswordRepeat] = useState("");
|
||||
const [restoreSql, setRestoreSql] = useState("");
|
||||
const [creatingAdmin, setCreatingAdmin] = useState(false);
|
||||
const [restoring, setRestoring] = useState(false);
|
||||
|
||||
async function createAdmin(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
|
||||
if (password !== passwordRepeat) {
|
||||
toast.error("Passwörter stimmen nicht überein");
|
||||
return;
|
||||
}
|
||||
|
||||
setCreatingAdmin(true);
|
||||
|
||||
try {
|
||||
const result = await createSetupAdmin({ username, displayName, password });
|
||||
toast.success("Admin angelegt");
|
||||
onAdminCreated(result.user);
|
||||
} catch (error) {
|
||||
toast.error("Admin konnte nicht angelegt werden", {
|
||||
description: error instanceof Error ? error.message : "Unbekannter Fehler"
|
||||
});
|
||||
} finally {
|
||||
setCreatingAdmin(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function readBackupFile(event: ChangeEvent<HTMLInputElement>) {
|
||||
const file = event.currentTarget.files?.[0];
|
||||
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
|
||||
setRestoreSql(await file.text());
|
||||
}
|
||||
|
||||
async function restoreBackup(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
setRestoring(true);
|
||||
|
||||
try {
|
||||
await restoreSetupBackup(restoreSql);
|
||||
toast.success("Backup eingespielt", {
|
||||
description: "Melde dich jetzt mit einem Benutzer aus dem Backup an."
|
||||
});
|
||||
onRestoreComplete();
|
||||
} catch (error) {
|
||||
toast.error("Restore fehlgeschlagen", {
|
||||
description: error instanceof Error ? error.message : "Unbekannter Fehler"
|
||||
});
|
||||
} finally {
|
||||
setRestoring(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-background p-4">
|
||||
<div className="mx-auto flex min-h-[calc(100vh-2rem)] w-full max-w-4xl flex-col justify-center gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-normal">TicketTracker Setup</h1>
|
||||
<p className="text-sm text-muted-foreground">Es wurde noch kein Admin gefunden. Lege einen Admin an oder spiele ein bestehendes Backup ein.</p>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="admin" className="w-full">
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsTrigger value="admin">Admin anlegen</TabsTrigger>
|
||||
<TabsTrigger value="restore">Backup einspielen</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="admin">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<KeyRound className="size-5 text-muted-foreground" />
|
||||
<CardTitle>Festen Admin erstellen</CardTitle>
|
||||
</div>
|
||||
<CardDescription>Dieser Account ist nur für Systemverwaltung, Benutzer, Zammad-Sync und Backups vorgesehen.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form className="grid gap-4 sm:grid-cols-2" onSubmit={createAdmin}>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium" htmlFor="setup-username">Benutzername</label>
|
||||
<Input id="setup-username" value={username} onChange={(event) => setUsername(event.currentTarget.value)} autoComplete="username" autoFocus required />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium" htmlFor="setup-display-name">Voller Name</label>
|
||||
<Input id="setup-display-name" value={displayName} onChange={(event) => setDisplayName(event.currentTarget.value)} required />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium" htmlFor="setup-password">Passwort</label>
|
||||
<Input id="setup-password" type="password" value={password} onChange={(event) => setPassword(event.currentTarget.value)} autoComplete="new-password" minLength={6} required />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium" htmlFor="setup-password-repeat">Passwort wiederholen</label>
|
||||
<Input id="setup-password-repeat" type="password" value={passwordRepeat} onChange={(event) => setPasswordRepeat(event.currentTarget.value)} autoComplete="new-password" minLength={6} required />
|
||||
</div>
|
||||
<Button className="sm:col-span-2" type="submit" disabled={creatingAdmin}>
|
||||
<KeyRound className="size-4" />
|
||||
{creatingAdmin ? "Erstellt..." : "Admin erstellen und anmelden"}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="restore">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<DatabaseBackup className="size-5 text-muted-foreground" />
|
||||
<CardTitle>Backup einspielen</CardTitle>
|
||||
</div>
|
||||
<CardDescription>Nutze ein SQL-Backup aus dem TicketTracker-Adminbereich.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form className="space-y-4" onSubmit={restoreBackup}>
|
||||
<Alert variant="warning">
|
||||
<Upload className="size-4" />
|
||||
<AlertTitle>Restore ersetzt den kompletten Systemstand</AlertTitle>
|
||||
<AlertDescription>
|
||||
Vor dem Einspielen werden alle TicketTracker-Tabellen geleert. Danach gilt ausschließlich der Stand des Backups, inklusive aller Benutzer,
|
||||
Passwörter, Sessions, Teamspace-Werte, Einstellungen und Abschlüsse.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium" htmlFor="setup-restore-file">SQL-Datei</label>
|
||||
<Input id="setup-restore-file" type="file" accept=".sql,text/sql,text/plain" onChange={(event) => void readBackupFile(event)} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium" htmlFor="setup-restore-sql">Backup-Inhalt</label>
|
||||
<Textarea
|
||||
id="setup-restore-sql"
|
||||
className="min-h-44 font-mono text-xs"
|
||||
value={restoreSql}
|
||||
onChange={(event) => setRestoreSql(event.currentTarget.value)}
|
||||
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>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user