Add setup and restore flow
This commit is contained in:
1 parent
19e4621aeb
commit
72229cf14c
15 files changed
+511
-94
No files matched your search
@@ -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