Add setup and restore flow
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
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
|
||||
|
||||
+109
-1
@@ -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 "";
|
||||
|
||||
+29
-34
@@ -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 ?? null;
|
||||
}
|
||||
|
||||
return adminResult.rows[0].id;
|
||||
}
|
||||
|
||||
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);`);
|
||||
if (adminId) {
|
||||
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);`);
|
||||
}
|
||||
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("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;");
|
||||
if (adminId) {
|
||||
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;");
|
||||
}
|
||||
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 (
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
+11
-2
@@ -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:
|
||||
|
||||
+16
-3
@@ -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.
|
||||
|
||||
+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,13 +69,18 @@ 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">
|
||||
<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">
|
||||
@@ -98,8 +104,9 @@ export function FaqPage() {
|
||||
</ul>
|
||||
</div>
|
||||
{section.workflow ? <WorkflowList items={section.workflow} /> : null}
|
||||
</div>
|
||||
{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