From 72229cf14c40dadb3dbf7e139ab65fb13adad9de Mon Sep 17 00:00:00 2001 From: mboehmlaender Date: Thu, 6 Aug 2026 08:22:33 +0200 Subject: [PATCH] Add setup and restore flow --- .env.docker-external.example | 4 - .env.docker-postgres.example | 4 - .env.example | 5 +- backend/src/index.ts | 110 +++++++++++++++- backend/src/migrations.ts | 69 +++++----- compose.postgres.yml | 3 - compose.yml | 3 - docs/docker-release.md | 13 +- docs/user-guide.md | 19 ++- frontend/src/App.tsx | 30 ++++- frontend/src/api.ts | 25 ++++ frontend/src/help-content.ts | 20 +++ frontend/src/views/AdminUsersPage.tsx | 62 ++++++++- frontend/src/views/FaqPage.tsx | 62 +++++---- frontend/src/views/SetupPage.tsx | 176 ++++++++++++++++++++++++++ 15 files changed, 511 insertions(+), 94 deletions(-) create mode 100644 frontend/src/views/SetupPage.tsx diff --git a/.env.docker-external.example b/.env.docker-external.example index 155c33b..2c2c3dd 100644 --- a/.env.docker-external.example +++ b/.env.docker-external.example @@ -4,9 +4,5 @@ APP_PORT=3910 DATABASE_URL=postgresql://tickettracker:change-me@10.10.10.23:5432/tickettracker DB_POOL_SIZE=10 -ADMIN_USERNAME=admin -ADMIN_PASSWORD=change-me-now -ADMIN_DISPLAY_NAME=Admin - COOKIE_SECURE=false SESSION_MAX_AGE_MS=1209600000 diff --git a/.env.docker-postgres.example b/.env.docker-postgres.example index 4a70fdf..7709e9e 100644 --- a/.env.docker-postgres.example +++ b/.env.docker-postgres.example @@ -6,9 +6,5 @@ POSTGRES_USER=tickettracker POSTGRES_PASSWORD=change-me-db-password DB_POOL_SIZE=10 -ADMIN_USERNAME=admin -ADMIN_PASSWORD=change-me-now -ADMIN_DISPLAY_NAME=Admin - COOKIE_SECURE=false SESSION_MAX_AGE_MS=1209600000 diff --git a/.env.example b/.env.example index 8e84357..c99ad6b 100644 --- a/.env.example +++ b/.env.example @@ -1,8 +1,5 @@ DATABASE_URL=postgresql://tickettracker:change-me@10.10.10.23:5432/tickettracker -ADMIN_USERNAME=admin -ADMIN_PASSWORD=change-me-now -ADMIN_DISPLAY_NAME=Admin APP_PORT=3910 TICKETTRACKER_IMAGE=git.d-razz.de/michael/tickettracker:latest COOKIE_SECURE=false -DB_POOL_SIZE=10 \ No newline at end of file +DB_POOL_SIZE=10 diff --git a/backend/src/index.ts b/backend/src/index.ts index d539748..4162308 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -27,7 +27,7 @@ const frontendDist = path.resolve(__dirname, "../../frontend/dist"); const serveFrontend = process.env.SERVE_FRONTEND !== "false"; app.use(cors()); -app.use(express.json({ limit: "1mb" })); +app.use(express.json({ limit: "50mb" })); app.get("/api/health", async (_req, res) => { await query("SELECT 1"); @@ -84,6 +84,11 @@ function parseOptionalBilledMinutes(value: unknown) { return minutes; } +async function hasAdminUser() { + const result = await query<{ exists: boolean }>("SELECT EXISTS (SELECT 1 FROM users WHERE role = 'admin') AS exists;"); + return Boolean(result.rows[0]?.exists); +} + function parseZammadBaseUrl(value: unknown) { const raw = requireString(value, "baseUrl").replace(/\/+$/, ""); @@ -1162,6 +1167,63 @@ async function getPeriodTicket(config: PeriodConfig, period: ParsedPeriod, ticke }; } +app.get("/api/setup/status", async (_req, res) => { + res.json({ required: !(await hasAdminUser()) }); +}); + +app.post("/api/setup/admin", async (req, res) => { + if (await hasAdminUser()) { + res.status(409).json({ error: "Setup wurde bereits abgeschlossen" }); + return; + } + + const username = parseUsername(req.body.username); + const displayName = requireString(req.body.displayName, "displayName"); + const password = requireString(req.body.password, "password"); + + if (password.length < 6) { + throw badRequest("password must be at least 6 characters"); + } + + try { + const result = await query<{ + id: string; + username: string; + display_name: string; + role: "admin" | "user"; + active: boolean; + }>( + ` + INSERT INTO users (username, display_name, password_hash, role, active) + VALUES ($1, $2, $3, 'admin', true) + RETURNING id, username, display_name, role, active; + `, + [username, displayName, await hashPassword(password)] + ); + + await createAuthSession(res, result.rows[0].id); + res.status(201).json({ user: result.rows[0] }); + } catch (error: any) { + if (error?.code === "23505") { + res.status(409).json({ error: "Benutzername ist bereits vergeben" }); + return; + } + + throw error; + } +}); + +app.post("/api/setup/restore", async (req, res) => { + if (await hasAdminUser()) { + res.status(409).json({ error: "Setup wurde bereits abgeschlossen. Restore ist im Adminbereich verfügbar." }); + return; + } + + const sql = requireString(req.body.sql, "sql"); + const result = await restoreTicketTrackerBackup(sql); + res.status(201).json(result); +}); + app.post("/api/auth/login", async (req, res) => { const username = requireString(req.body.username, "username"); const password = requireString(req.body.password, "password"); @@ -1671,6 +1733,40 @@ async function exportTableSql(table: string) { return lines; } +function managedRestoreBody(sql: string) { + const trimmed = sql.replace(/^\uFEFF/, "").trim(); + + if (!trimmed.includes("TicketTracker Datenbankexport")) { + throw badRequest("Nur TicketTracker-SQL-Backups können eingespielt werden"); + } + + return trimmed + .replace(/^\s*BEGIN\s*;\s*/im, "") + .replace(/\s*COMMIT\s*;\s*$/im, "") + .replace(/TRUNCATE\s+TABLE[\s\S]*?;\s*/i, "") + .trim(); +} + +async function restoreTicketTrackerBackup(sql: string) { + const body = managedRestoreBody(sql); + + await withTransaction(async (client) => { + await client.query(`TRUNCATE TABLE ${[...databaseExportTables].reverse().map(quoteIdentifier).join(", ")} RESTART IDENTITY CASCADE;`); + + if (body) { + await client.query(body); + } + + const adminResult = await client.query<{ count: string }>("SELECT COUNT(*) AS count FROM users WHERE role = 'admin';"); + + if (Number(adminResult.rows[0]?.count ?? 0) !== 1) { + throw badRequest("Restore muss genau einen Admin-Benutzer enthalten"); + } + }); + + return { ok: true }; +} + app.get("/api/admin/export/database", requireAdmin, async (_req, res) => { const lines = [ "-- TicketTracker Datenbankexport", @@ -1692,6 +1788,18 @@ app.get("/api/admin/export/database", requireAdmin, async (_req, res) => { res.send(lines.join("\n")); }); +app.post("/api/admin/restore/database", requireAdmin, async (req, res) => { + const sql = requireString(req.body.sql, "sql"); + const result = await restoreTicketTrackerBackup(sql); + + res.clearCookie("tickettracker_session", { + sameSite: "lax", + secure: process.env.COOKIE_SECURE === "true", + path: "/" + }); + res.json(result); +}); + function csvCell(value: unknown) { if (value === null || value === undefined) { return ""; diff --git a/backend/src/migrations.ts b/backend/src/migrations.ts index 4e73653..99d1c0f 100644 --- a/backend/src/migrations.ts +++ b/backend/src/migrations.ts @@ -1,42 +1,25 @@ import { query } from "./db.js"; -import { hashPassword } from "./auth.js"; - -async function ensureMigrationAdmin() { - const username = process.env.ADMIN_USERNAME?.trim() || "admin"; - const password = process.env.ADMIN_PASSWORD ?? "admin"; - const displayName = process.env.ADMIN_DISPLAY_NAME?.trim() || "Admin"; - - const existingUsers = await query<{ count: string }>("SELECT COUNT(*) AS count FROM users;"); - - if (Number(existingUsers.rows[0]?.count ?? 0) === 0) { - await query( - ` - INSERT INTO users (username, display_name, password_hash, role) - VALUES ($1, $2, $3, 'admin'); - `, - [username, displayName, await hashPassword(password)] - ); - - if (!process.env.ADMIN_PASSWORD) { - console.warn("Initialer Admin wurde mit admin/admin angelegt. Bitte Passwort über ADMIN_PASSWORD setzen oder im Adminbereich ändern."); - } - } +async function findMigrationAdmin() { const adminResult = await query<{ id: string }>("SELECT id FROM users WHERE role = 'admin' ORDER BY id ASC LIMIT 1;"); - - if (adminResult.rows.length === 0) { - throw new Error("At least one admin user is required"); - } - - return adminResult.rows[0].id; + return adminResult.rows[0]?.id ?? null; } -async function ensureClosureOwnership(table: string, column: string, primaryKeyColumns: string[], adminId: string) { +async function ensureClosureOwnership(table: string, column: string, primaryKeyColumns: string[], adminId: string | null) { await query(`ALTER TABLE ${table} ADD COLUMN IF NOT EXISTS user_id BIGINT REFERENCES users(id);`); - await query(`UPDATE ${table} SET user_id = $1 WHERE user_id IS NULL;`, [adminId]); - await query(`ALTER TABLE ${table} ALTER COLUMN user_id SET NOT NULL;`); - await query(`ALTER TABLE ${table} DROP CONSTRAINT IF EXISTS ${table}_pkey;`); - await query(`ALTER TABLE ${table} ADD CONSTRAINT ${table}_pkey PRIMARY KEY (${primaryKeyColumns.join(", ")}, user_id);`); + if (adminId) { + await query(`UPDATE ${table} SET user_id = $1 WHERE user_id IS NULL;`, [adminId]); + } + await query(` + DO $$ + BEGIN + IF NOT EXISTS (SELECT 1 FROM ${table} WHERE user_id IS NULL) THEN + ALTER TABLE ${table} ALTER COLUMN user_id SET NOT NULL; + ALTER TABLE ${table} DROP CONSTRAINT IF EXISTS ${table}_pkey; + ALTER TABLE ${table} ADD CONSTRAINT ${table}_pkey PRIMARY KEY (${primaryKeyColumns.join(", ")}, user_id); + END IF; + END $$; + `); await query(`CREATE INDEX IF NOT EXISTS idx_${table}_${column}_user ON ${table}(${column}, user_id);`); } @@ -74,9 +57,12 @@ export async function migrate() { await query("CREATE INDEX IF NOT EXISTS idx_auth_sessions_user ON auth_sessions(user_id);"); await query("CREATE INDEX IF NOT EXISTS idx_auth_sessions_expires ON auth_sessions(expires_at);"); - const adminId = await ensureMigrationAdmin(); + const adminId = await findMigrationAdmin(); + + if (adminId) { + await query("UPDATE users SET role = 'user', updated_at = now() WHERE role = 'admin' AND id <> $1;", [adminId]); + } - await query("UPDATE users SET role = 'user', updated_at = now() WHERE role = 'admin' AND id <> $1;", [adminId]); await query("CREATE UNIQUE INDEX IF NOT EXISTS idx_users_single_admin ON users ((role)) WHERE role = 'admin';"); await query(` @@ -170,8 +156,17 @@ export async function migrate() { await query("ALTER TABLE sessions ADD COLUMN IF NOT EXISTS recurring_billing_slot_id BIGINT;"); await query("ALTER TABLE sessions ADD COLUMN IF NOT EXISTS recurring_occurrence_date DATE;"); await query("ALTER TABLE sessions ADD COLUMN IF NOT EXISTS recurring_occurrence_key TEXT;"); - await query("UPDATE sessions SET user_id = $1 WHERE user_id IS NULL;", [adminId]); - await query("ALTER TABLE sessions ALTER COLUMN user_id SET NOT NULL;"); + if (adminId) { + await query("UPDATE sessions SET user_id = $1 WHERE user_id IS NULL;", [adminId]); + } + await query(` + DO $$ + BEGIN + IF NOT EXISTS (SELECT 1 FROM sessions WHERE user_id IS NULL) THEN + ALTER TABLE sessions ALTER COLUMN user_id SET NOT NULL; + END IF; + END $$; + `); await query(` CREATE TABLE IF NOT EXISTS recurring_billings ( diff --git a/compose.postgres.yml b/compose.postgres.yml index 55ba6a0..2d295f6 100644 --- a/compose.postgres.yml +++ b/compose.postgres.yml @@ -31,9 +31,6 @@ services: API_PROXY_TARGET: http://127.0.0.1:3001 SERVE_FRONTEND: "false" NEXT_TELEMETRY_DISABLED: "1" - ADMIN_USERNAME: ${ADMIN_USERNAME:-admin} - ADMIN_PASSWORD: ${ADMIN_PASSWORD:-} - ADMIN_DISPLAY_NAME: ${ADMIN_DISPLAY_NAME:-Admin} SESSION_MAX_AGE_MS: ${SESSION_MAX_AGE_MS:-1209600000} COOKIE_SECURE: ${COOKIE_SECURE:-false} DB_POOL_SIZE: ${DB_POOL_SIZE:-10} diff --git a/compose.yml b/compose.yml index a4ab895..2a06427 100644 --- a/compose.yml +++ b/compose.yml @@ -12,9 +12,6 @@ services: API_PROXY_TARGET: http://127.0.0.1:3001 SERVE_FRONTEND: "false" NEXT_TELEMETRY_DISABLED: "1" - ADMIN_USERNAME: ${ADMIN_USERNAME:-admin} - ADMIN_PASSWORD: ${ADMIN_PASSWORD:-} - ADMIN_DISPLAY_NAME: ${ADMIN_DISPLAY_NAME:-Admin} SESSION_MAX_AGE_MS: ${SESSION_MAX_AGE_MS:-1209600000} COOKIE_SECURE: ${COOKIE_SECURE:-false} DB_POOL_SIZE: ${DB_POOL_SIZE:-10} diff --git a/docs/docker-release.md b/docs/docker-release.md index 496fe0c..72c4297 100644 --- a/docs/docker-release.md +++ b/docs/docker-release.md @@ -14,7 +14,6 @@ cp .env.docker-external.example .env.docker-external ```bash DATABASE_URL=postgresql://tickettracker:PASSWORT@10.10.10.23:5432/tickettracker -ADMIN_PASSWORD=ein-sicheres-passwort TICKETTRACKER_IMAGE=git.d-razz.de/michael/tickettracker:latest APP_PORT=3910 ``` @@ -37,7 +36,6 @@ cp .env.docker-postgres.example .env.docker-postgres ```bash POSTGRES_PASSWORD=ein-sicheres-db-passwort -ADMIN_PASSWORD=ein-sicheres-admin-passwort TICKETTRACKER_IMAGE=git.d-razz.de/michael/tickettracker:latest APP_PORT=3910 ``` @@ -50,6 +48,17 @@ docker compose --env-file .env.docker-postgres -f compose.postgres.yml up -d Die Datenbankdaten liegen im Docker-Volume `tickettracker_postgres_data`. +## Erstes Setup + +Admin-Zugangsdaten werden nicht per `.env` gesetzt. Nach dem ersten Start oeffnest du TicketTracker im Browser und landest automatisch auf der Setup-Seite. + +Dort gibt es zwei Wege: + +- `Admin anlegen`: festen Admin direkt erstellen und anmelden. +- `Backup einspielen`: ein TicketTracker-SQL-Backup einspielen. Danach meldest du dich mit einem Benutzer aus dem Backup an. + +Ein Restore ersetzt den kompletten Systemstand. Vor dem Einspielen werden alle TicketTracker-Tabellen geleert; danach gelten ausschliesslich Benutzer, Passwoerter, Sessions, Settings, Organisationen, fixe Abrechnungen und Abschluesse aus dem Backup. + ## Image bauen und in Gitea pushen Einmal am Docker/Gitea-Registry anmelden: diff --git a/docs/user-guide.md b/docs/user-guide.md index 801b687..fbfe68e 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -2,6 +2,17 @@ Diese Anleitung beschreibt die Nutzung von TicketTracker im Arbeitsalltag: Zeiten erfassen, Sessions bewerten, Teamspace-Zeiten eintragen, fixe Abrechnungen verwalten, Statistiken lesen und den Monatsabschluss durchfuehren. +## Erstes Setup + +Wenn noch kein Admin existiert, zeigt TicketTracker beim Aufruf automatisch die Setup-Seite. + +Dort gibt es zwei Moeglichkeiten: + +- `Admin anlegen`: festen Admin mit Benutzername, vollem Namen und Passwort erstellen. Danach ist der Admin direkt angemeldet. +- `Backup einspielen`: ein TicketTracker-SQL-Backup aus einem frueheren Systemstand einspielen. Danach meldet man sich mit einem Benutzer aus dem Backup an. + +Ein Restore ersetzt den kompletten Systemstand. Vor dem Einspielen werden alle TicketTracker-Tabellen geleert. Danach gilt ausschliesslich der Stand des Backups, inklusive aller Benutzer, Passwoerter, Sessions, Einstellungen, Organisationen, fixen Abrechnungen und Abschluesse. + ## Grundprinzip TicketTracker ersetzt nicht das Ticketsystem und nicht das CRM. Die App ist die Arbeitszeiterfassung und Auswertung daneben. @@ -26,17 +37,17 @@ An vielen Stellen in der Oberflaeche gibt es ein Fragezeichen-Icon. Ein Klick da 2. Mit Benutzername und Passwort anmelden. 3. Nach der Anmeldung ist die Seitenleiste sichtbar. -Normale Benutzer sehen ihre eigenen Zeiten, Auswertungen, fixen Abrechnungen und ihr Profil. Administratoren sehen zusaetzlich den Adminbereich. +Normale Benutzer sehen ihre eigenen Zeiten, Auswertungen, fixen Abrechnungen und ihr Profil. Der feste Admin sieht nur Administration, Profil und FAQ. Die Navigation ist in Workflows gegliedert: -- Timer +- Session starten - Auswertung - Monatsabschluss - Statistiken - Fixe Abrechnung - Profil -- Benutzer, nur fuer Admins +- Administration, nur fuer Admin - FAQ Nach `Auswertung`, nach `Monatsabschluss` und vor `FAQ` trennt eine Linie die Bereiche optisch. @@ -432,6 +443,8 @@ Der Datenbankexport im Adminbereich erzeugt eine SQL-Datei fuer einen kompletten Der Export enthaelt alle Tabellen, die fuer den Stand des Systems notwendig sind: Benutzer, Einstellungen, Organisationen, Tickets, Sessions, Teamspace-Werte, fixe Abrechnungen, Ausnahmen und Monatsabschluesse. Er ist kein reiner CSV-Auszug, sondern ein Restore-Backup. +Im laufenden System kann der Admin ein SQL-Backup ebenfalls im Adminbereich wieder einspielen. Vor dem Restore erscheint ein Hinweis, weil alle aktuellen Daten ersetzt werden. Nach dem Restore ist eine neue Anmeldung mit den Zugangsdaten aus dem Backup notwendig. + ## Zammad Sync Der Zammad-Sync ist nur fuer Administratoren sichtbar. diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 3de0845..22279dd 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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(null); const [authLoading, setAuthLoading] = useState(true); + const [setupRequired, setSetupRequired] = useState(false); const [timers, setTimers] = useState([]); const [timersLoaded, setTimersLoaded] = useState(false); const [timerOwnerId, setTimerOwnerId] = useState(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 ( + { + setSetupRequired(false); + setCurrentUser(user); + navigate("/admin/users"); + }} + onRestoreComplete={() => { + setSetupRequired(false); + setCurrentUser(null); + navigate("/"); + }} + /> + ); + } + if (!currentUser) { return ; } diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 7abf7db..4b24c26 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -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" diff --git a/frontend/src/help-content.ts b/frontend/src/help-content.ts index 19af00c..8b480bc 100644 --- a/frontend/src/help-content.ts +++ b/frontend/src/help-content.ts @@ -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." ] } diff --git a/frontend/src/views/AdminUsersPage.tsx b/frontend/src/views/AdminUsersPage.tsx index ce243e5..f653bad 100644 --- a/frontend/src/views/AdminUsersPage.tsx +++ b/frontend/src/views/AdminUsersPage.tsx @@ -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(null); const [creating, setCreating] = useState(false); @@ -229,6 +233,40 @@ export function AdminUsersPage({ currentUser }: { currentUser: AuthUser }) { } } + async function readRestoreFile(event: ChangeEvent) { + 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 }) {
- Datenbankexport + Backup und Restore
- +
- SQL-Backup für einen vollständigen Restore auf einer frischen TicketTracker-Installation. + SQL-Backup herunterladen oder einen kompletten Systemstand wiederherstellen.

@@ -304,6 +342,20 @@ export function AdminUsersPage({ currentUser }: { currentUser: AuthUser }) { {exportingDatabase ? "Exportiert..." : "SQL-Backup herunterladen"} +

+ + + Restore ersetzt alles + + Vor dem Einspielen werden alle Tabellen geleert. Danach gilt nur noch der Stand des Backups, inklusive aller User, Passwörter, Sessions und Settings. + + + void readRestoreFile(event)} /> + +
diff --git a/frontend/src/views/FaqPage.tsx b/frontend/src/views/FaqPage.tsx index 76bc5e6..15a5709 100644 --- a/frontend/src/views/FaqPage.tsx +++ b/frontend/src/views/FaqPage.tsx @@ -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() {
- {helpSections.map((section) => ( - + {helpSections.map((section) => { + const isWide = section.id === "grundprinzip" || section.id === "monatsworkflow"; + const isWorkflow = section.id === "monatsworkflow"; + + return ( + {section.title} {section.description} - -
-

Kurzfassung

-
    - {section.quickItems.map((item) => ( -
  • - - {item} -
  • - ))} -
+ +
+
+

Kurzfassung

+
    + {section.quickItems.map((item) => ( +
  • + + {item} +
  • + ))} +
+
+
+

Details

+
    + {section.details.map((item) => ( +
  • + + {item} +
  • + ))} +
+
+ {section.workflow ? : null}
-
-

Details

-
    - {section.details.map((item) => ( -
  • - - {item} -
  • - ))} -
-
- {section.workflow ? : null} {section.diagram ? : null} -
); diff --git a/frontend/src/views/SetupPage.tsx b/frontend/src/views/SetupPage.tsx new file mode 100644 index 0000000..d1c43bd --- /dev/null +++ b/frontend/src/views/SetupPage.tsx @@ -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) { + 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 ( +
+
+
+

TicketTracker Setup

+

Es wurde noch kein Admin gefunden. Lege einen Admin an oder spiele ein bestehendes Backup ein.

+
+ + + + Admin anlegen + Backup einspielen + + + + + +
+ + Festen Admin erstellen +
+ Dieser Account ist nur für Systemverwaltung, Benutzer, Zammad-Sync und Backups vorgesehen. +
+ +
+
+ + setUsername(event.currentTarget.value)} autoComplete="username" autoFocus required /> +
+
+ + setDisplayName(event.currentTarget.value)} required /> +
+
+ + setPassword(event.currentTarget.value)} autoComplete="new-password" minLength={6} required /> +
+
+ + setPasswordRepeat(event.currentTarget.value)} autoComplete="new-password" minLength={6} required /> +
+ +
+
+
+
+ + + + +
+ + Backup einspielen +
+ Nutze ein SQL-Backup aus dem TicketTracker-Adminbereich. +
+ +
+ + + Restore ersetzt den kompletten Systemstand + + 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. + + +
+ + void readBackupFile(event)} /> +
+
+ +