Add setup and restore flow

This commit is contained in:
2026-08-06 08:22:33 +02:00
parent 19e4621aeb
commit 72229cf14c
15 changed files with 511 additions and 94 deletions
+29 -1
View File
@@ -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} />;
}
+25
View File
@@ -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"
+20
View File
@@ -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."
]
}
+57 -5
View File
@@ -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>
+35 -27
View File
@@ -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>
);
+176
View File
@@ -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>
);
}