From 19e4621aeb6d24fd7600dab748332860fde50a19 Mon Sep 17 00:00:00 2001 From: mboehmlaender Date: Thu, 6 Aug 2026 08:07:26 +0200 Subject: [PATCH] Restrict admin to system management --- backend/src/auth.ts | 9 + backend/src/index.ts | 558 +++++++++++------------- backend/src/migrations.ts | 3 + docs/user-guide.md | 19 +- frontend/src/App.tsx | 162 ++++--- frontend/src/api.ts | 64 +-- frontend/src/help-content.ts | 27 +- frontend/src/types.ts | 23 - frontend/src/views/AdminUsersPage.tsx | 556 +++++------------------ frontend/src/views/AnalysisPage.tsx | 57 ++- frontend/src/views/MonthlyClosePage.tsx | 28 +- frontend/src/views/TicketDetailPage.tsx | 7 + frontend/src/views/TimerPage.tsx | 3 +- 13 files changed, 634 insertions(+), 882 deletions(-) diff --git a/backend/src/auth.ts b/backend/src/auth.ts index 76e2b66..49af5e5 100644 --- a/backend/src/auth.ts +++ b/backend/src/auth.ts @@ -155,6 +155,15 @@ export function requireAdmin(req: Request, res: Response, next: NextFunction) { next(); } +export function requireUser(req: Request, res: Response, next: NextFunction) { + if (req.user?.role !== "user") { + res.status(403).json({ error: "Dieser Bereich ist nur für Benutzerkonten vorgesehen" }); + return; + } + + next(); +} + export function currentUser(req: Request) { if (!req.user) { throw Object.assign(new Error("Nicht angemeldet"), { status: 401 }); diff --git a/backend/src/index.ts b/backend/src/index.ts index 8c4080d..d539748 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -5,7 +5,7 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; import { migrate } from "./migrations.js"; import { pool, query, withTransaction } from "./db.js"; -import { clearAuthSession, createAuthSession, currentUser, hashPassword, requireAdmin, requireAuth, verifyPassword, type UserRole } from "./auth.js"; +import { clearAuthSession, createAuthSession, currentUser, hashPassword, requireAdmin, requireAuth, requireUser, verifyPassword } from "./auth.js"; import { badRequest, parseDay, @@ -84,14 +84,6 @@ function parseOptionalBilledMinutes(value: unknown) { return minutes; } -function parseUserRole(value: unknown) { - if (value !== "admin" && value !== "user") { - throw badRequest("role must be admin or user"); - } - - return value; -} - function parseZammadBaseUrl(value: unknown) { const raw = requireString(value, "baseUrl").replace(/\/+$/, ""); @@ -1178,7 +1170,7 @@ app.post("/api/auth/login", async (req, res) => { id: string; username: string; display_name: string; - role: UserRole; + role: "admin" | "user"; active: boolean; password_hash: string; }>( @@ -1295,7 +1287,6 @@ app.post("/api/admin/users", requireAdmin, async (req, res) => { const username = parseUsername(req.body.username); const displayName = requireString(req.body.displayName, "displayName"); const password = requireString(req.body.password, "password"); - const role = parseUserRole(req.body.role ?? "user"); const active = optionalBoolean(req.body.active, true); if (password.length < 6) { @@ -1309,7 +1300,7 @@ app.post("/api/admin/users", requireAdmin, async (req, res) => { VALUES ($1, $2, $3, $4, $5) RETURNING id, username, display_name, role, active, created_at, updated_at; `, - [username, displayName, await hashPassword(password), role, active] + [username, displayName, await hashPassword(password), "user", active] ); res.status(201).json({ user: result.rows[0] }); @@ -1332,7 +1323,6 @@ app.patch("/api/admin/users/:userId", requireAdmin, async (req, res) => { const userId = parsePositiveInteger(req.params.userId, "userId"); const username = parseUsername(req.body.username); const displayName = requireString(req.body.displayName, "displayName"); - const role = parseUserRole(req.body.role); const active = optionalBoolean(req.body.active, true); const password = optionalString(req.body.password); @@ -1340,8 +1330,21 @@ app.patch("/api/admin/users/:userId", requireAdmin, async (req, res) => { throw badRequest("password must be at least 6 characters"); } - if (currentUser(req).id === String(userId) && (!active || role !== "admin")) { - res.status(409).json({ error: "Du kannst deinen eigenen Admin-Zugang nicht deaktivieren oder herabstufen" }); + const existingResult = await query<{ id: string; role: "admin" | "user" }>("SELECT id, role FROM users WHERE id = $1;", [userId]); + const existingUser = existingResult.rows[0] ?? null; + + if (!existingUser) { + res.status(404).json({ error: "User not found" }); + return; + } + + if (existingUser.role === "admin" && currentUser(req).id !== String(userId)) { + res.status(409).json({ error: "Der feste Admin kann nicht über die Benutzerliste bearbeitet werden" }); + return; + } + + if (existingUser.role === "admin" && !active) { + res.status(409).json({ error: "Der feste Admin kann nicht deaktiviert werden" }); return; } @@ -1351,14 +1354,14 @@ app.patch("/api/admin/users/:userId", requireAdmin, async (req, res) => { UPDATE users SET username = $1, display_name = $2, - role = $3, - active = $4, - password_hash = COALESCE($5, password_hash), + role = role, + active = $3, + password_hash = COALESCE($4, password_hash), updated_at = now() - WHERE id = $6 + WHERE id = $5 RETURNING id, username, display_name, role, active, created_at, updated_at; `, - [username, displayName, role, active, password ? await hashPassword(password) : null, userId] + [username, displayName, active, password ? await hashPassword(password) : null, userId] ); if (result.rowCount === 0) { @@ -1588,6 +1591,202 @@ app.post("/api/admin/zammad/organizations/sync", requireAdmin, async (req, res) }); }); +const databaseExportTables = [ + "users", + "auth_sessions", + "app_settings", + "organizations", + "tickets", + "recurring_billings", + "recurring_billing_slots", + "recurring_billing_exceptions", + "sessions", + "ticket_month_closures", + "month_closures", + "ticket_day_closures", + "day_closures", + "ticket_day_billings", + "ticket_day_billing_acknowledgements" +] as const; + +function quoteIdentifier(value: string) { + return `"${value.replace(/"/g, '""')}"`; +} + +function quoteLiteral(value: unknown) { + if (value === null || value === undefined) { + return "NULL"; + } + + if (value instanceof Date) { + return `'${value.toISOString().replace(/'/g, "''")}'`; + } + + if (Buffer.isBuffer(value)) { + return `decode('${value.toString("hex")}', 'hex')`; + } + + return `'${String(value).replace(/'/g, "''")}'`; +} + +async function exportTableSql(table: string) { + const columnsResult = await query<{ column_name: string }>( + ` + SELECT column_name + FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name = $1 + ORDER BY ordinal_position ASC; + `, + [table] + ); + const columns = columnsResult.rows.map((row) => row.column_name); + + if (columns.length === 0) { + return [`-- Tabelle ${table} existiert nicht im aktuellen Schema.`]; + } + + const rowsResult = await query>( + ` + SELECT ${columns.map(quoteIdentifier).join(", ")} + FROM ${quoteIdentifier(table)} + ORDER BY ${columns.map(quoteIdentifier).join(", ")}; + ` + ); + + const lines = [`-- ${table}: ${rowsResult.rows.length} Datensatz/Datensätze`]; + + for (const row of rowsResult.rows) { + lines.push( + `INSERT INTO ${quoteIdentifier(table)} (${columns.map(quoteIdentifier).join(", ")}) VALUES (${columns.map((column) => quoteLiteral(row[column])).join(", ")});` + ); + } + + if (columns.includes("id")) { + lines.push( + `SELECT setval(pg_get_serial_sequence(${quoteLiteral(table)}, 'id'), COALESCE((SELECT MAX("id") FROM ${quoteIdentifier(table)}), 1), COALESCE((SELECT MAX("id") FROM ${quoteIdentifier(table)}), 0) > 0);` + ); + } + + return lines; +} + +app.get("/api/admin/export/database", requireAdmin, async (_req, res) => { + const lines = [ + "-- TicketTracker Datenbankexport", + `-- Erstellt: ${new Date().toISOString()}`, + "-- Restore: frische TicketTracker-Datenbank migrieren lassen, danach diese Datei mit psql einspielen.", + "BEGIN;", + `TRUNCATE TABLE ${[...databaseExportTables].reverse().map(quoteIdentifier).join(", ")} RESTART IDENTITY CASCADE;` + ]; + + for (const table of databaseExportTables) { + lines.push("", ...(await exportTableSql(table))); + } + + lines.push("", "COMMIT;", ""); + + const timestamp = new Date().toISOString().replace(/[:.]/g, "-"); + res.setHeader("Content-Type", "application/sql; charset=utf-8"); + res.setHeader("Content-Disposition", `attachment; filename="tickettracker-backup-${timestamp}.sql"`); + res.send(lines.join("\n")); +}); + +function csvCell(value: unknown) { + if (value === null || value === undefined) { + return ""; + } + + const text = String(value); + + if (/[;"\n\r]/.test(text)) { + return `"${text.replace(/"/g, '""')}"`; + } + + return text; +} + +app.get("/api/export/months/:month", requireUser, async (req, res) => { + const month = parseMonth(String(req.params.month)); + const user = currentUser(req); + + await ensureRecurringSessionsForUserPeriod(user.id, month.startIso, month.endIso); + + const result = await query<{ + ticket_number: string; + organization_name: string; + work_type: string; + day: string; + start_time: string; + end_time: string; + rounded_minutes: number; + billing_status: string | null; + teamspace_minutes: number | null; + source: string; + activity: string; + }>( + ` + SELECT + t.ticket_number, + COALESCE(so.name, ot.name, s.customer_name, t.customer_name, '') AS organization_name, + s.work_type, + to_char(s.started_at AT TIME ZONE 'Europe/Berlin', 'YYYY-MM-DD') AS day, + to_char(s.started_at AT TIME ZONE 'Europe/Berlin', 'HH24:MI') AS start_time, + to_char(s.ended_at AT TIME ZONE 'Europe/Berlin', 'HH24:MI') AS end_time, + s.rounded_minutes, + s.billing_status, + tdb.billed_minutes AS teamspace_minutes, + CASE WHEN s.recurring_billing_id IS NULL THEN 'manuell' ELSE 'fix' END AS source, + s.activity + FROM sessions s + JOIN tickets t ON t.id = s.ticket_id + LEFT JOIN organizations so ON so.id = s.organization_id + LEFT JOIN organizations ot ON ot.id = t.organization_id + LEFT JOIN ticket_day_billings tdb + ON tdb.ticket_id = s.ticket_id + AND tdb.user_id = s.user_id + AND tdb.day = (s.started_at AT TIME ZONE 'Europe/Berlin')::date + WHERE s.started_at >= $1::timestamptz + AND s.started_at < $2::timestamptz + AND s.user_id = $3 + ORDER BY s.started_at ASC, t.ticket_number ASC, s.id ASC; + `, + [month.startIso, month.endIso, user.id] + ); + + const header = [ + "Ticket", + "Organisation", + "Art", + "Datum", + "Von", + "Bis", + "Session-Minuten", + "Bewertung", + "Teamspace-Minuten", + "Quelle", + "Taetigkeit" + ]; + const rows = result.rows.map((row) => [ + row.ticket_number, + row.organization_name, + row.work_type === "support" ? "Support" : "Consulting", + row.day, + row.start_time, + row.end_time, + row.rounded_minutes, + row.billing_status === "billed" ? "Abgerechnet" : row.billing_status === "non_billable" ? "Nicht abrechenbar" : "Offen", + row.teamspace_minutes ?? "", + row.source === "fix" ? "Fixe Abrechnung" : "Manuell", + row.activity + ]); + const csv = [`\uFEFF${header.map(csvCell).join(";")}`, ...rows.map((row) => row.map(csvCell).join(";"))].join("\n"); + + res.setHeader("Content-Type", "text/csv; charset=utf-8"); + res.setHeader("Content-Disposition", `attachment; filename="tickettracker-${user.username}-${month.label}.csv"`); + res.send(csv); +}); + function parseRecurrenceType(value: unknown) { if (value !== "weekly" && value !== "every_n_weeks") { throw badRequest("recurrenceType must be weekly or every_n_weeks"); @@ -1722,11 +1921,11 @@ async function recurringBillingResponse(userId: string) { return result.rows; } -app.get("/api/recurring-billings", async (req, res) => { +app.get("/api/recurring-billings", requireUser, async (req, res) => { res.json({ recurringBillings: await recurringBillingResponse(currentUser(req).id) }); }); -app.post("/api/recurring-billings", async (req, res) => { +app.post("/api/recurring-billings", requireUser, async (req, res) => { const userId = currentUser(req).id; const payload = parseRecurringBillingPayload(req.body); @@ -1789,7 +1988,7 @@ app.post("/api/recurring-billings", async (req, res) => { res.status(201).json({ recurringBillings: await recurringBillingResponse(userId) }); }); -app.patch("/api/recurring-billings/:billingId", async (req, res) => { +app.patch("/api/recurring-billings/:billingId", requireUser, async (req, res) => { const billingId = parsePositiveInteger(req.params.billingId, "billingId"); const userId = currentUser(req).id; const active = optionalBoolean(req.body.active, true); @@ -2045,7 +2244,7 @@ app.patch("/api/recurring-billings/:billingId", async (req, res) => { res.json({ recurringBillings: await recurringBillingResponse(userId) }); }); -app.delete("/api/recurring-billings/:billingId", async (req, res) => { +app.delete("/api/recurring-billings/:billingId", requireUser, async (req, res) => { const billingId = parsePositiveInteger(req.params.billingId, "billingId"); const userId = currentUser(req).id; const result = await withTransaction(async (client) => { @@ -2097,247 +2296,18 @@ app.delete("/api/recurring-billings/:billingId", async (req, res) => { }); app.get("/api/admin/sessions", requireAdmin, async (_req, res) => { - const result = await query( - ` - SELECT - s.id, - s.ticket_id, - s.user_id, - owner.username AS owner_username, - owner.display_name AS owner_display_name, - t.ticket_number, - s.organization_id, - so.name AS organization_name, - COALESCE(so.name, s.customer_name) AS customer_name, - s.activity, - s.work_type, - s.started_at, - s.ended_at, - s.duration_seconds, - s.rounded_minutes, - s.billing_status, - s.created_at, - s.recurring_billing_id, - s.recurring_billing_slot_id, - s.recurring_occurrence_date - FROM sessions s - JOIN tickets t ON t.id = s.ticket_id - JOIN users owner ON owner.id = s.user_id - LEFT JOIN organizations so ON so.id = s.organization_id - ORDER BY s.started_at DESC - LIMIT 500; - ` - ); - - res.json({ sessions: result.rows }); + res.status(410).json({ error: "Session-Verwaltung ist für den festen Admin deaktiviert" }); }); -app.post("/api/admin/sessions", requireAdmin, async (req, res) => { - const userId = parsePositiveInteger(req.body.userId, "userId"); - const ticketNumber = parseTicketNumber(req.body.ticketNumber); - const organizationId = parseOrganizationId(req.body.organizationId); - const activity = requireString(req.body.activity, "activity"); - const workType = parseWorkType(req.body.workType); - const startedAt = parseIsoDate(req.body.startedAt, "startedAt"); - const endedAt = parseIsoDate(req.body.endedAt, "endedAt"); - - if (endedAt <= startedAt) { - throw badRequest("endedAt must be after startedAt"); - } - - const durationSeconds = Math.floor((endedAt.getTime() - startedAt.getTime()) / 1000); - const roundedMinutes = Math.max(1, Math.round(durationSeconds / 60)); - - const created = await withTransaction(async (client) => { - const userResult = await client.query<{ id: string }>( - ` - SELECT id - FROM users - WHERE id = $1 - AND active = true; - `, - [userId] - ); - - if (userResult.rows.length === 0) { - return null; - } - - const organization = await getOrganizationById(client, organizationId); - - if (!organization) { - return "organization-not-found" as const; - } - - const ticketResult = await client.query<{ id: string; ticket_number: string; organization_id: string | null; customer_name: string | null; work_type: string | null }>( - ` - INSERT INTO tickets (ticket_number, organization_id, customer_name, work_type) - VALUES ($1, $2, $3, $4) - ON CONFLICT (ticket_number) - DO UPDATE SET - organization_id = COALESCE(tickets.organization_id, EXCLUDED.organization_id), - customer_name = COALESCE(tickets.customer_name, EXCLUDED.customer_name), - work_type = COALESCE(tickets.work_type, EXCLUDED.work_type) - RETURNING id, ticket_number, organization_id, customer_name, work_type; - `, - [ticketNumber, organization.id, organization.name, workType] - ); - - const sessionResult = await client.query<{ id: string }>( - ` - INSERT INTO sessions ( - ticket_id, - organization_id, - customer_name, - activity, - work_type, - user_id, - started_at, - ended_at, - duration_seconds, - rounded_minutes - ) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) - RETURNING id; - `, - [ - ticketResult.rows[0].id, - organization.id, - organization.name, - activity, - workType, - userId, - startedAt.toISOString(), - endedAt.toISOString(), - durationSeconds, - roundedMinutes - ] - ); - - await reopenPeriodsForSession(client, ticketResult.rows[0].id, startedAt, String(userId)); - - return sessionResult.rows[0].id; - }); - - if (created === "organization-not-found") { - res.status(404).json({ error: "Organisation nicht gefunden oder inaktiv" }); - return; - } - - if (!created) { - res.status(404).json({ error: "Zielbenutzer nicht gefunden oder inaktiv" }); - return; - } - - const result = await query( - ` - SELECT - s.id, - s.ticket_id, - s.user_id, - owner.username AS owner_username, - owner.display_name AS owner_display_name, - t.ticket_number, - s.organization_id, - so.name AS organization_name, - COALESCE(so.name, s.customer_name) AS customer_name, - s.activity, - s.work_type, - s.started_at, - s.ended_at, - s.duration_seconds, - s.rounded_minutes, - s.billing_status, - s.created_at, - s.recurring_billing_id, - s.recurring_billing_slot_id, - s.recurring_occurrence_date - FROM sessions s - JOIN tickets t ON t.id = s.ticket_id - JOIN users owner ON owner.id = s.user_id - LEFT JOIN organizations so ON so.id = s.organization_id - WHERE s.id = $1; - `, - [created] - ); - - res.status(201).json({ session: result.rows[0] }); +app.post("/api/admin/sessions", requireAdmin, async (_req, res) => { + res.status(410).json({ error: "Der feste Admin erfasst keine Sessions" }); }); -app.patch("/api/admin/sessions/:sessionId/owner", requireAdmin, async (req, res) => { - const sessionId = parsePositiveInteger(req.params.sessionId, "sessionId"); - const nextUserId = parsePositiveInteger(req.body.userId, "userId"); - - const reassigned = await withTransaction(async (client) => { - const targetResult = await client.query<{ id: string }>( - ` - SELECT id - FROM users - WHERE id = $1 - AND active = true; - `, - [nextUserId] - ); - - if (targetResult.rows.length === 0) { - return { status: "target-not-found" as const }; - } - - const currentResult = await client.query<{ - id: string; - ticket_id: string; - user_id: string; - started_at: string; - }>( - ` - SELECT id, ticket_id, user_id, started_at - FROM sessions - WHERE id = $1 - FOR UPDATE; - `, - [sessionId] - ); - - if (currentResult.rows.length === 0) { - return { status: "session-not-found" as const }; - } - - const session = currentResult.rows[0]; - - if (session.user_id === String(nextUserId)) { - return { status: "ok" as const, session }; - } - - await reopenPeriodsForSession(client, session.ticket_id, new Date(session.started_at), session.user_id); - await reopenPeriodsForSession(client, session.ticket_id, new Date(session.started_at), String(nextUserId)); - - const updatedResult = await client.query( - ` - UPDATE sessions - SET user_id = $1 - WHERE id = $2 - RETURNING *; - `, - [nextUserId, sessionId] - ); - - return { status: "ok" as const, session: updatedResult.rows[0] }; - }); - - if (reassigned.status === "target-not-found") { - res.status(404).json({ error: "Zielbenutzer nicht gefunden oder inaktiv" }); - return; - } - - if (reassigned.status === "session-not-found") { - res.status(404).json({ error: "Session not found" }); - return; - } - - res.json({ session: reassigned.session }); +app.patch("/api/admin/sessions/:sessionId/owner", requireAdmin, async (_req, res) => { + res.status(410).json({ error: "Session-Verwaltung ist für den festen Admin deaktiviert" }); }); -app.post("/api/sessions", async (req, res) => { +app.post("/api/sessions", requireUser, async (req, res) => { const user = currentUser(req); const ticketNumber = parseTrackableTicketNumber(req.body.ticketNumber); const activity = requireString(req.body.activity, "activity"); @@ -2438,17 +2408,17 @@ app.post("/api/sessions", async (req, res) => { res.status(201).json(created); }); -app.get("/api/periods/:periodType/:period/overview", async (req, res) => { - const { config, period } = parsePeriod(req.params.periodType, req.params.period); +app.get("/api/periods/:periodType/:period/overview", requireUser, async (req, res) => { + const { config, period } = parsePeriod(String(req.params.periodType), String(req.params.period)); res.json(await getPeriodOverview(config, period, currentUser(req).id)); }); -app.get("/api/statistics/months/:month", async (req, res) => { - const month = parseMonth(req.params.month); +app.get("/api/statistics/months/:month", requireUser, async (req, res) => { + const month = parseMonth(String(req.params.month)); res.json(await getStatisticsOverview(month, currentUser(req).id)); }); -app.get("/api/tickets/lookup", async (req, res) => { +app.get("/api/tickets/lookup", requireUser, async (req, res) => { const ticketNumber = parseTicketNumber(req.query.ticketNumber); const result = await query( ` @@ -2471,7 +2441,7 @@ app.get("/api/tickets/lookup", async (req, res) => { }); }); -app.patch("/api/tickets/:ticketId", async (req, res) => { +app.patch("/api/tickets/:ticketId", requireUser, async (req, res) => { const ticketId = parsePositiveInteger(req.params.ticketId, "ticketId"); const ticketNumber = parseTrackableTicketNumber(req.body.ticketNumber); const organizationId = parseOrganizationId(req.body.organizationId); @@ -2537,8 +2507,8 @@ app.patch("/api/tickets/:ticketId", async (req, res) => { } }); -app.get("/api/periods/:periodType/:period/tickets/:ticketId", async (req, res) => { - const { config, period } = parsePeriod(req.params.periodType, req.params.period); +app.get("/api/periods/:periodType/:period/tickets/:ticketId", requireUser, async (req, res) => { + const { config, period } = parsePeriod(String(req.params.periodType), String(req.params.period)); const ticketId = parsePositiveInteger(req.params.ticketId, "ticketId"); const result = await getPeriodTicket(config, period, ticketId, currentUser(req).id); @@ -2550,9 +2520,9 @@ app.get("/api/periods/:periodType/:period/tickets/:ticketId", async (req, res) = res.json(result); }); -app.patch("/api/tickets/:ticketId/day-billings/:day", async (req, res) => { +app.patch("/api/tickets/:ticketId/day-billings/:day", requireUser, async (req, res) => { const ticketId = parsePositiveInteger(req.params.ticketId, "ticketId"); - const day = parseDay(req.params.day); + const day = parseDay(String(req.params.day)); const billedMinutes = parseOptionalBilledMinutes(req.body.billedMinutes); const userId = currentUser(req).id; @@ -2612,9 +2582,9 @@ app.patch("/api/tickets/:ticketId/day-billings/:day", async (req, res) => { res.json({ dayBilling: result.rows[0] }); }); -app.patch("/api/tickets/:ticketId/day-billings/:day/acknowledgement", async (req, res) => { +app.patch("/api/tickets/:ticketId/day-billings/:day/acknowledgement", requireUser, async (req, res) => { const ticketId = parsePositiveInteger(req.params.ticketId, "ticketId"); - const day = parseDay(req.params.day); + const day = parseDay(String(req.params.day)); const acknowledged = req.body.acknowledged !== false; const userId = currentUser(req).id; @@ -2664,20 +2634,20 @@ app.patch("/api/tickets/:ticketId/day-billings/:day/acknowledgement", async (req res.json({ acknowledged: true, acknowledgement: result.rows[0] }); }); -app.post("/api/periods/:periodType/:period/tickets/:ticketId/close", async (req, res) => { - parsePeriod(req.params.periodType, req.params.period); +app.post("/api/periods/:periodType/:period/tickets/:ticketId/close", requireUser, async (req, res) => { + parsePeriod(String(req.params.periodType), String(req.params.period)); parsePositiveInteger(req.params.ticketId, "ticketId"); res.status(410).json({ error: "Ticketabschlüsse werden nicht verwendet. Bitte Sessions bewerten und den Monat abschließen." }); }); -app.post("/api/periods/:periodType/:period/tickets/:ticketId/reopen", async (req, res) => { - parsePeriod(req.params.periodType, req.params.period); +app.post("/api/periods/:periodType/:period/tickets/:ticketId/reopen", requireUser, async (req, res) => { + parsePeriod(String(req.params.periodType), String(req.params.period)); parsePositiveInteger(req.params.ticketId, "ticketId"); res.status(410).json({ error: "Ticketabschlüsse werden nicht verwendet. Bitte den Monat öffnen." }); }); -app.post("/api/periods/:periodType/:period/close", async (req, res) => { - const { config, period } = parsePeriod(req.params.periodType, req.params.period); +app.post("/api/periods/:periodType/:period/close", requireUser, async (req, res) => { + const { config, period } = parsePeriod(String(req.params.periodType), String(req.params.period)); const userId = currentUser(req).id; if (config.type !== "month") { @@ -2727,8 +2697,8 @@ app.post("/api/periods/:periodType/:period/close", async (req, res) => { res.json({ closure: result.rows[0] }); }); -app.post("/api/periods/:periodType/:period/reopen", async (req, res) => { - const { config, period } = parsePeriod(req.params.periodType, req.params.period); +app.post("/api/periods/:periodType/:period/reopen", requireUser, async (req, res) => { + const { config, period } = parsePeriod(String(req.params.periodType), String(req.params.period)); const userId = currentUser(req).id; if (config.type !== "month") { @@ -2752,8 +2722,8 @@ app.use("/api/months", (_req, res) => { res.status(410).json({ error: "Legacy month API removed. Use /api/periods/months instead." }); }); -app.get("/api/months/:month/overview", async (req, res) => { - const month = parseMonth(req.params.month); +app.get("/api/months/:month/overview", requireUser, async (req, res) => { + const month = parseMonth(String(req.params.month)); const [ticketsResult, openResult, monthClosureResult] = await Promise.all([ query( @@ -2824,8 +2794,8 @@ app.get("/api/months/:month/overview", async (req, res) => { }); }); -app.get("/api/months/:month/tickets/:ticketId", async (req, res) => { - const month = parseMonth(req.params.month); +app.get("/api/months/:month/tickets/:ticketId", requireUser, async (req, res) => { + const month = parseMonth(String(req.params.month)); const ticketId = parsePositiveInteger(req.params.ticketId, "ticketId"); const ticketResult = await query( @@ -2887,7 +2857,7 @@ app.get("/api/months/:month/tickets/:ticketId", async (req, res) => { }); }); -app.patch("/api/sessions/:sessionId/billing", async (req, res) => { +app.patch("/api/sessions/:sessionId/billing", requireUser, async (req, res) => { const sessionId = parsePositiveInteger(req.params.sessionId, "sessionId"); const billingStatus = parseBillingStatus(req.body.billingStatus); const userId = currentUser(req).id; @@ -2949,7 +2919,7 @@ app.patch("/api/sessions/:sessionId/billing", async (req, res) => { res.json({ session: result.rows[0] }); }); -app.patch("/api/sessions/:sessionId/details", async (req, res) => { +app.patch("/api/sessions/:sessionId/details", requireUser, async (req, res) => { const sessionId = parsePositiveInteger(req.params.sessionId, "sessionId"); const userId = currentUser(req).id; const organizationId = parseOrganizationId(req.body.organizationId); @@ -3042,7 +3012,7 @@ app.patch("/api/sessions/:sessionId/details", async (req, res) => { res.json({ session: updated }); }); -app.delete("/api/sessions/:sessionId", async (req, res) => { +app.delete("/api/sessions/:sessionId", requireUser, async (req, res) => { const sessionId = parsePositiveInteger(req.params.sessionId, "sessionId"); const userId = currentUser(req).id; @@ -3125,8 +3095,8 @@ app.delete("/api/sessions/:sessionId", async (req, res) => { res.json({ deleted }); }); -app.post("/api/months/:month/tickets/:ticketId/close", async (req, res) => { - const month = parseMonth(req.params.month); +app.post("/api/months/:month/tickets/:ticketId/close", requireUser, async (req, res) => { + const month = parseMonth(String(req.params.month)); const ticketId = parsePositiveInteger(req.params.ticketId, "ticketId"); const statusResult = await query( @@ -3168,8 +3138,8 @@ app.post("/api/months/:month/tickets/:ticketId/close", async (req, res) => { res.json({ closure: closureResult.rows[0] }); }); -app.post("/api/months/:month/tickets/:ticketId/reopen", async (req, res) => { - const month = parseMonth(req.params.month); +app.post("/api/months/:month/tickets/:ticketId/reopen", requireUser, async (req, res) => { + const month = parseMonth(String(req.params.month)); const ticketId = parsePositiveInteger(req.params.ticketId, "ticketId"); const result = await withTransaction(async (client) => { @@ -3200,8 +3170,8 @@ app.post("/api/months/:month/tickets/:ticketId/reopen", async (req, res) => { res.json(result); }); -app.post("/api/months/:month/close", async (req, res) => { - const month = parseMonth(req.params.month); +app.post("/api/months/:month/close", requireUser, async (req, res) => { + const month = parseMonth(String(req.params.month)); const blockersResult = await query( ` @@ -3244,8 +3214,8 @@ app.post("/api/months/:month/close", async (req, res) => { res.json({ closure: result.rows[0] }); }); -app.post("/api/months/:month/reopen", async (req, res) => { - const month = parseMonth(req.params.month); +app.post("/api/months/:month/reopen", requireUser, async (req, res) => { + const month = parseMonth(String(req.params.month)); const result = await query( ` diff --git a/backend/src/migrations.ts b/backend/src/migrations.ts index d79ec81..4e73653 100644 --- a/backend/src/migrations.ts +++ b/backend/src/migrations.ts @@ -76,6 +76,9 @@ export async function migrate() { const adminId = await ensureMigrationAdmin(); + 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(` CREATE TABLE IF NOT EXISTS app_settings ( key TEXT PRIMARY KEY, diff --git a/docs/user-guide.md b/docs/user-guide.md index 7a10061..801b687 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -80,7 +80,7 @@ Die gemessene Zeit wird auf Minuten gerundet. Pausen werden von der Gesamtdauer Sessions koennen manuell nachgetragen werden, zum Beispiel wenn ein Timer vergessen wurde. -Normale Benutzer koennen eigene Sessions nachtragen. Administratoren koennen Sessions fuer andere Benutzer nachtragen. +Normale Benutzer koennen eigene Sessions nachtragen. Der feste Admin erfasst keine Sessions. Beim Nachtragen werden angegeben: @@ -142,6 +142,8 @@ Der Graph zeigt abgerechneten Aufwand im Zeitraum: - In der Monatsansicht pro Tag - In der Tagesansicht pro Stunde +In der Monatsansicht kann ueber `Monat exportieren` eine CSV-Datei fuer den eigenen Benutzer heruntergeladen werden. Der Export enthaelt alle eigenen Sessions des Monats mit Ticket, Organisation, Art, Datum, Von/Bis, Bewertung, Session-Minuten, Teamspace-Minuten und Taetigkeit. Er ist fuer Kontrolle, Ablage oder Weiterverarbeitung gedacht. + ## Tickets im Zeitraum Unter `Tickets im Zeitraum` werden alle Tickets angezeigt, die im gewaehlten Zeitraum Sessions haben. @@ -154,7 +156,6 @@ Pro Ticket sind sichtbar: - Anzahl Sessions - Abgerechnete Zeit - Anzahl abgerechneter Sessions -- Anzahl und Zeit nicht abrechenbarer Sessions - Status Neben der Ticketnummer gibt es ein Copy-Icon. Ein Klick kopiert die Ticketnummer in die Zwischenablage. @@ -415,19 +416,21 @@ Passwortaenderungen erfordern das aktuelle Passwort. Bleiben die Passwortfelder ## Adminbereich -Administratoren koennen: +Der feste Admin ist nur fuer die Systemverwaltung vorgesehen. Er kann: - Benutzer anlegen - Benutzer bearbeiten - Passwoerter fuer Benutzer setzen -- Sessions aller Benutzer sehen -- Sessions anderen Benutzern zuweisen -- Sessions fuer Benutzer nachtragen - Zammad-Organisationen synchronisieren +- Restore-faehige SQL-Backups herunterladen -Bei der Auswertung normaler Benutzer werden nur die Sessions des angemeldeten Benutzers beruecksichtigt. Ticketdaten koennen trotzdem von Sessions anderer Benutzer uebernommen werden, wenn dasselbe Ticket schon existiert. +Neue Benutzer erhalten automatisch die Rolle `User`. Eine Rollenauswahl gibt es nicht. Der feste Admin kann nicht deaktiviert oder zur User-Rolle herabgestuft werden. -Beim Umverteilen einer Session werden betroffene Monatsabschluesse fuer alten und neuen Besitzer wieder geoeffnet, damit Auswertung und Abschluss konsistent bleiben. +Der Admin sieht keine Timer, keine Auswertungen, keine Statistiken, keine fixen Abrechnungen und keinen Monatsabschluss. Diese Bereiche sind nur fuer normale Benutzer vorgesehen. + +Der Datenbankexport im Adminbereich erzeugt eine SQL-Datei fuer einen kompletten Restore. Fuer einen Umzug wird auf dem Zielsystem zuerst TicketTracker gestartet, damit die Migrationen die Tabellen anlegen. Danach wird die SQL-Datei mit `psql` in die Ziel-Datenbank eingespielt. + +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. ## Zammad Sync diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 7e41997..3de0845 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -378,13 +378,21 @@ export function App() { return; } + if (currentUser.role === "admin") { + setTimers([]); + setSelectedTimerId(null); + setTimerOwnerId(currentUser.id); + setTimersLoaded(true); + return; + } + setTimers(readStoredTimers(currentUser.id)); setTimerOwnerId(currentUser.id); setTimersLoaded(true); }, [currentUser]); useEffect(() => { - if (!currentUser || !timersLoaded || timerOwnerId !== currentUser.id) { + if (!currentUser || currentUser.role === "admin" || !timersLoaded || timerOwnerId !== currentUser.id) { return; } @@ -401,7 +409,7 @@ export function App() { }, [currentUser, timers, selectedTimerId, timersLoaded, timerOwnerId]); useEffect(() => { - if (!currentUser || !timersLoaded || timerOwnerId !== currentUser.id) { + if (!currentUser || currentUser.role === "admin" || !timersLoaded || timerOwnerId !== currentUser.id) { return; } @@ -467,7 +475,26 @@ export function App() { return () => window.clearInterval(interval); }, []); + useEffect(() => { + if (!currentUser || currentUser.role !== "admin") { + return; + } + + if (route.page === "admin-users" || route.page === "profile" || route.page === "faq") { + return; + } + + navigate("/admin/users"); + }, [currentUser, route.page]); + async function startQuickTimer(rawTicketNumber: string) { + if (currentUser?.role === "admin") { + toast.error("Admin erfasst keine Sessions", { + description: "Der Adminbereich ist nur für die Systemverwaltung vorgesehen." + }); + return false; + } + const ticketNumber = rawTicketNumber.trim(); if (!ticketPattern.test(ticketNumber)) { @@ -532,21 +559,29 @@ export function App() { } const navItems: NavMenuEntry[] = [ - { type: "item", href: "/timer", label: "Timer", icon: Timer, active: path.startsWith("/timer") || path === "/" }, - { type: "item", href: "/analysis", label: "Auswertung", icon: BarChart3, active: path.startsWith("/analysis") }, - { type: "separator", key: "after-analysis" }, - { type: "item", href: "/monthly-close", label: "Monatsabschluss", icon: ClipboardCheck, active: path.startsWith("/monthly-close") }, - { type: "separator", key: "after-monthly-close" }, - { type: "item", href: "/statistics", label: "Statistiken", icon: ChartNoAxesCombined, active: path.startsWith("/statistics") }, - { type: "item", href: "/recurring", label: "Fixe Abrechnung", icon: Repeat, active: path.startsWith("/recurring") }, - { type: "separator", key: "before-profile" }, - { type: "item", href: "/profile", label: "Profil", icon: UserCog, active: path.startsWith("/profile") }, ...(currentUser.role === "admin" - ? [{ type: "item" as const, href: "/admin/users", label: "Benutzer", icon: Shield, active: path.startsWith("/admin/users") }] - : []), - { type: "separator", key: "before-faq" }, - { type: "item", href: "/faq", label: "FAQ", icon: CircleHelp, active: path.startsWith("/faq") }, + ? [ + { type: "item" as const, href: "/admin/users", label: "Administration", icon: Shield, active: path.startsWith("/admin/users") }, + { type: "separator" as const, key: "before-profile" }, + { type: "item" as const, href: "/profile", label: "Profil", icon: UserCog, active: path.startsWith("/profile") }, + { type: "separator" as const, key: "before-faq" }, + { type: "item" as const, href: "/faq", label: "FAQ", icon: CircleHelp, active: path.startsWith("/faq") } + ] + : [ + { type: "item" as const, href: "/timer", label: "Session starten", icon: Timer, active: path.startsWith("/timer") || path === "/" }, + { type: "item" as const, href: "/analysis", label: "Auswertung", icon: BarChart3, active: path.startsWith("/analysis") }, + { type: "separator" as const, key: "after-analysis" }, + { type: "item" as const, href: "/monthly-close", label: "Monatsabschluss", icon: ClipboardCheck, active: path.startsWith("/monthly-close") }, + { type: "separator" as const, key: "after-monthly-close" }, + { type: "item" as const, href: "/statistics", label: "Statistiken", icon: ChartNoAxesCombined, active: path.startsWith("/statistics") }, + { type: "item" as const, href: "/recurring", label: "Fixe Abrechnung", icon: Repeat, active: path.startsWith("/recurring") }, + { type: "separator" as const, key: "before-profile" }, + { type: "item" as const, href: "/profile", label: "Profil", icon: UserCog, active: path.startsWith("/profile") }, + { type: "separator" as const, key: "before-faq" }, + { type: "item" as const, href: "/faq", label: "FAQ", icon: CircleHelp, active: path.startsWith("/faq") } + ]) ]; + const isAdmin = currentUser.role === "admin"; const runningTimer = timers.find((timer) => timer.phase === "running") ?? null; const runningElapsedSeconds = runningTimer ? Math.floor(activeElapsedMs(runningTimer, tick) / 1000) : 0; @@ -564,7 +599,7 @@ export function App() { - + TicketTracker @@ -574,36 +609,40 @@ export function App() { - - - - - - - - Session starten - - - - - - + {!isAdmin ? ( + <> + + + + + + + + Session starten + + + + + + - navigate("/timer")} - /> + navigate("/timer")} + /> + + ) : null} - Workflows + {isAdmin ? "Verwaltung" : "Workflow"} {navItems.map((item) => @@ -629,13 +668,13 @@ export function App() { -
+ {!isAdmin ?
Abschluss

Offene Bewertungen findest du im Monatsabschluss.

-
+
: null} @@ -684,11 +723,13 @@ export function App() { TicketTracker - + {!isAdmin ? ( + + ) : null}
@@ -716,12 +757,12 @@ export function App() {
-
+ {!isAdmin ?
-
+
: null}
- {route.page === "timer" ? ( + {!isAdmin && route.page === "timer" ? ( ) : null} - {route.page === "analysis" ? : null} - {route.page === "monthly-close" ? : null} - {route.page === "statistics" ? : null} - {route.page === "recurring" ? : null} + {!isAdmin && route.page === "analysis" ? : null} + {!isAdmin && route.page === "monthly-close" ? : null} + {!isAdmin && route.page === "statistics" ? : null} + {!isAdmin && route.page === "recurring" ? : null} {route.page === "faq" ? : null} {route.page === "profile" ? : null} - {route.page === "admin-users" && currentUser.role === "admin" ? : null} - {route.page === "admin-users" && currentUser.role !== "admin" ? ( + {route.page === "admin-users" && isAdmin ? : null} + {route.page === "admin-users" && !isAdmin ? ( ) : null} - {route.page === "ticket" ? ( + {!isAdmin && route.page === "ticket" ? ( ) : null} + {isAdmin && route.page !== "admin-users" && route.page !== "profile" && route.page !== "faq" ? : null}
diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 11703c2..7abf7db 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -1,5 +1,4 @@ import type { - AdminSession, AdminUser, AuthUser, BillingStatus, @@ -10,7 +9,6 @@ import type { StatisticsOverview, TicketMeta, TicketPeriod, - UserRole, WorkType } from "./types"; @@ -99,7 +97,6 @@ export function createAdminUser(payload: { username: string; displayName: string; password: string; - role: UserRole; active: boolean; }) { return request<{ user: AdminUser }>("/api/admin/users", { @@ -114,7 +111,6 @@ export function updateAdminUser( username: string; displayName: string; password?: string; - role: UserRole; active: boolean; } ) { @@ -124,8 +120,42 @@ export function updateAdminUser( }); } -export function getAdminSessions() { - return request<{ sessions: AdminSession[] }>("/api/admin/sessions"); +export async function downloadDatabaseExport() { + const response = await fetch("/api/admin/export/database", { + credentials: "same-origin" + }); + + if (!response.ok) { + const body = await response.json().catch(() => ({})); + throw new ApiError(body.error ?? "Export fehlgeschlagen", response.status); + } + + const contentDisposition = response.headers.get("content-disposition") ?? ""; + const filenameMatch = contentDisposition.match(/filename="([^"]+)"/); + + return { + blob: await response.blob(), + filename: filenameMatch?.[1] ?? `tickettracker-backup-${new Date().toISOString().replace(/[:.]/g, "-")}.sql` + }; +} + +export async function downloadMonthExport(month: string) { + const response = await fetch(`/api/export/months/${month}`, { + credentials: "same-origin" + }); + + if (!response.ok) { + const body = await response.json().catch(() => ({})); + throw new ApiError(body.error ?? "Export fehlgeschlagen", response.status); + } + + const contentDisposition = response.headers.get("content-disposition") ?? ""; + const filenameMatch = contentDisposition.match(/filename="([^"]+)"/); + + return { + blob: await response.blob(), + filename: filenameMatch?.[1] ?? `tickettracker-monat-${month}.csv` + }; } export function getOrganizations(search = "") { @@ -214,28 +244,6 @@ export function deleteRecurringBilling(billingId: string) { }); } -export function createAdminSession(payload: { - userId: string; - ticketNumber: string; - organizationId: string; - activity: string; - workType: WorkType; - startedAt: string; - endedAt: string; -}) { - return request<{ session: AdminSession }>("/api/admin/sessions", { - method: "POST", - body: JSON.stringify(payload) - }); -} - -export function reassignAdminSession(sessionId: string, userId: string) { - return request<{ session: AdminSession }>(`/api/admin/sessions/${sessionId}/owner`, { - method: "PATCH", - body: JSON.stringify({ userId }) - }); -} - export function createSession(payload: CreateSessionPayload) { return request("/api/sessions", { method: "POST", diff --git a/frontend/src/help-content.ts b/frontend/src/help-content.ts index f53fa57..19af00c 100644 --- a/frontend/src/help-content.ts +++ b/frontend/src/help-content.ts @@ -107,7 +107,7 @@ export const helpSections: HelpSection[] = [ description: "Nachtragen ist für vergessene Zeiten gedacht, wenn kein Timer gelaufen ist.", quickItems: [ "Datum, Von, Bis und Tätigkeit eintragen.", - "Benutzer tragen eigene Sessions nach; Admins können Benutzer auswählen.", + "Benutzer tragen eigene Sessions nach.", "Tätigkeiten sind mehrzeilig." ], details: [ @@ -116,7 +116,6 @@ export const helpSections: HelpSection[] = [ "Die Organisation muss aus der synchronisierten Organisationsliste gewählt werden. Freitext ist absichtlich gesperrt.", "Die Tätigkeit ist eine Textarea. Zeilenumbrüche bleiben erhalten und werden in der Ticketdetailansicht wieder ausgegeben.", "Nachgetragene Sessions starten immer offen. Sie müssen später als Abgerechnet oder Nicht abrechenbar bewertet werden.", - "Admins können im Adminbereich zusätzlich bestimmen, welchem Benutzer die nachgetragene Session gehört.", "In der Ticketdetailansicht kann eine Session direkt zum geöffneten Ticket nachgetragen werden. Ticketnummer und Stammdaten sind dort schon vorbelegt beziehungsweise aus dem Ticket abgeleitet." ] }, @@ -162,12 +161,15 @@ export const helpSections: HelpSection[] = [ quickItems: [ "Monat oder Tag wählen; die Ansicht aktualisiert automatisch.", "Tickets können gefiltert, gruppiert und sortiert werden.", - "Die Einstellungen bleiben nach Refresh und Browserneustart erhalten." + "Die Einstellungen bleiben nach Refresh und Browserneustart erhalten.", + "Monate können als CSV exportiert werden." ], details: [ "Die Monatsansicht zeigt alle Tickets, die im gewählten Monat mindestens eine Session haben.", "Die Tagesansicht zeigt dieselben Datenlogiken auf einen einzelnen Tag gefiltert. Es gibt keinen Tagesabschluss.", "Die Pfeile neben der Zeitraum-Eingabe springen jeweils einen Monat oder einen Tag vor beziehungsweise zurück.", + "Über Monat exportieren lädst du eine CSV-Datei deiner eigenen Sessions dieses Monats herunter.", + "Der CSV-Export enthält Ticket, Organisation, Art, Datum, Von/Bis, Bewertung, Session-Minuten, Teamspace-Minuten und Tätigkeit.", "Der Graph zeigt abgerechnete Zeit im Zeitraum. In der Monatsansicht pro Tag, in der Tagesansicht pro Stunde.", "Die Ticketliste kann nach Typ, Organisation oder Status gruppiert werden. Gruppenüberschriften zeigen Anzahl, abgerechnete Zeit und offene Bewertungen.", "Filter und Sortierungen bleiben im Browser gespeichert und können über Zurücksetzen wieder auf Standard gestellt werden.", @@ -434,20 +436,19 @@ export const helpSections: HelpSection[] = [ { id: "admin", title: "Adminbereich", - description: "Der Adminbereich verwaltet Benutzer, Session-Zuordnung, manuelle Admin-Nachträge und Zammad-Sync.", + description: "Der feste Adminbereich verwaltet Benutzer, Zammad-Sync und restore-fähige Datenbankexporte.", quickItems: [ - "Admins legen Benutzer an und bearbeiten Rollen.", - "Sessions können anderen Benutzern zugewiesen werden.", - "Zammad-Organisationen werden über Data-Sync aktualisiert." + "Neue Benutzer erhalten automatisch die Rolle User.", + "Der Admin erfasst keine Sessions und sieht keine Auswertungen.", + "SQL-Backups sind für einen kompletten Restore gedacht." ], details: [ - "Neue Benutzer bekommen Benutzername, vollen Namen, Passwort, Rolle und Aktivstatus.", + "Neue Benutzer bekommen Benutzername, vollen Namen, Passwort und Aktivstatus. Eine Rollenauswahl gibt es nicht.", "Admins können bestehende Benutzer bearbeiten, Passwörter setzen und Benutzer deaktivieren.", - "Der eigene Admin-Zugang kann nicht versehentlich deaktiviert oder zur normalen User-Rolle herabgestuft werden.", - "In der Session-Zuordnung sehen Admins die letzten Sessions inklusive Besitzer und können einzelne Sessions umverteilen.", - "Beim Umverteilen werden die betroffenen Monatsabschlüsse für alten und neuen Besitzer wieder geöffnet, damit Bewertungen und Auswertungen konsistent bleiben.", - "Admins können Sessions für andere Benutzer nachtragen, wenn Zeiten vergessen wurden.", - "Der Data-Sync speichert Zammad-Zugangsdaten und synchronisiert Organisationen in die lokale Datenbank." + "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.", + "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/types.ts b/frontend/src/types.ts index 1025a6d..f1048dc 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -16,29 +16,6 @@ export type AdminUser = AuthUser & { updated_at: string; }; -export type AdminSession = { - id: string; - ticket_id: string; - user_id: string; - owner_username: string; - owner_display_name: string; - ticket_number: string; - organization_id: string | null; - organization_name: string | null; - customer_name: string; - activity: string; - work_type: WorkType; - started_at: string; - ended_at: string; - duration_seconds: number; - rounded_minutes: number; - billing_status: BillingStatus; - created_at: string; - recurring_billing_id: string | null; - recurring_billing_slot_id: string | null; - recurring_occurrence_date: string | null; -}; - export type Organization = { id: string; zammad_id: string; diff --git a/frontend/src/views/AdminUsersPage.tsx b/frontend/src/views/AdminUsersPage.tsx index 775091f..ce243e5 100644 --- a/frontend/src/views/AdminUsersPage.tsx +++ b/frontend/src/views/AdminUsersPage.tsx @@ -1,115 +1,76 @@ import { FormEvent, useEffect, useState } from "react"; -import { CalendarPlus, DatabaseZap, Save, Shield, Shuffle, UserPlus } from "lucide-react"; +import { DatabaseBackup, DatabaseZap, Save, Shield, UserPlus } from "lucide-react"; import { toast } from "sonner"; + import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Input } from "@/components/ui/input"; import { Switch } from "@/components/ui/switch"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; -import { Textarea } from "@/components/ui/textarea"; import { HelpLink } from "@/components/HelpLink"; -import { OrganizationSelect } from "@/components/OrganizationSelect"; import { - createAdminSession, createAdminUser, - getAdminSessions, + downloadDatabaseExport, getAdminUsers, getZammadSettings, - reassignAdminSession, saveZammadSettings, syncZammadOrganizations, updateAdminUser } from "../api"; -import { currentDay, formatDateTime, formatMinutes } from "../format"; -import type { AdminSession, AdminUser, AuthUser, UserRole, WorkType } from "../types"; +import type { AdminUser, AuthUser } from "../types"; type UserFormState = { username: string; displayName: string; password: string; - role: UserRole; active: boolean; }; -type ManualSessionFormState = { - userId: string; - ticketNumber: string; - organizationId: string; - organizationName: string | null; - activity: string; - workType: WorkType; - day: string; - startTime: string; - endTime: string; -}; - const emptyForm: UserFormState = { username: "", displayName: "", password: "", - role: "user", active: true }; -function emptyManualSession(): ManualSessionFormState { - return { - userId: "", - ticketNumber: "", - organizationId: "", - organizationName: null, - activity: "", - workType: "support", - day: currentDay(), - startTime: "09:00", - endTime: "09:30" - }; -} - function formFromUser(user: AdminUser): UserFormState { return { username: user.username, displayName: user.display_name, password: "", - role: user.role, active: user.active }; } export function AdminUsersPage({ currentUser }: { currentUser: AuthUser }) { const [users, setUsers] = useState([]); - const [sessions, setSessions] = useState([]); const [forms, setForms] = useState>({}); - const [sessionOwners, setSessionOwners] = useState>({}); const [newUser, setNewUser] = useState(emptyForm); - const [manualSession, setManualSession] = useState(() => emptyManualSession()); const [zammadBaseUrl, setZammadBaseUrl] = useState(""); const [zammadApiKey, setZammadApiKey] = useState(""); const [hasZammadApiKey, setHasZammadApiKey] = useState(false); const [syncingOrganizations, setSyncingOrganizations] = useState(false); const [savingZammadSettings, setSavingZammadSettings] = useState(false); + const [exportingDatabase, setExportingDatabase] = useState(false); const [loading, setLoading] = useState(false); const [savingId, setSavingId] = useState(null); - const [movingId, setMovingId] = useState(null); const [creating, setCreating] = useState(false); - const [creatingSession, setCreatingSession] = useState(false); async function load(options: { preserveEdits?: boolean } = {}) { setLoading(true); try { - const [usersResult, sessionsResult, zammadSettingsResult] = await Promise.all([getAdminUsers(), getAdminSessions(), getZammadSettings()]); + const [usersResult, zammadSettingsResult] = await Promise.all([getAdminUsers(), getZammadSettings()]); setUsers(usersResult.users); - setSessions(sessionsResult.sessions); + if (!options.preserveEdits) { setZammadBaseUrl(zammadSettingsResult.settings.baseUrl); setHasZammadApiKey(zammadSettingsResult.settings.hasApiKey); } + setForms((current) => Object.fromEntries(usersResult.users.map((user) => [user.id, options.preserveEdits ? current[user.id] ?? formFromUser(user) : formFromUser(user)])) ); - setSessionOwners((current) => - Object.fromEntries(sessionsResult.sessions.map((session) => [session.id, options.preserveEdits ? current[session.id] ?? session.user_id : session.user_id])) - ); } catch (error) { toast.error("Adminbereich konnte nicht geladen werden", { description: error instanceof Error ? error.message : "Unbekannter Fehler" @@ -157,7 +118,9 @@ export function AdminUsersPage({ currentUser }: { currentUser: AuthUser }) { try { await createAdminUser(newUser); - toast.success("Benutzer angelegt"); + toast.success("Benutzer angelegt", { + description: "Neue Accounts erhalten automatisch die Rolle User." + }); setNewUser(emptyForm); await load(); } catch (error) { @@ -181,7 +144,6 @@ export function AdminUsersPage({ currentUser }: { currentUser: AuthUser }) { await updateAdminUser(user.id, { username: form.username, displayName: form.displayName, - role: form.role, active: form.active, password: form.password || undefined }); @@ -196,91 +158,6 @@ export function AdminUsersPage({ currentUser }: { currentUser: AuthUser }) { } } - async function moveSession(session: AdminSession) { - const nextUserId = sessionOwners[session.id] ?? session.user_id; - - if (nextUserId === session.user_id) { - toast.info("Keine Änderung", { - description: "Diese Session gehört bereits diesem Benutzer." - }); - return; - } - - setMovingId(session.id); - try { - await reassignAdminSession(session.id, nextUserId); - toast.success("Session umverteilt", { - description: "Betroffene Tages- und Monatsabschlüsse wurden wieder geöffnet." - }); - await load(); - } catch (error) { - toast.error("Session konnte nicht umverteilt werden", { - description: error instanceof Error ? error.message : "Unbekannter Fehler" - }); - } finally { - setMovingId(null); - } - } - - const activeUsers = users.filter((user) => user.active); - const manualSessionUserId = manualSession.userId || activeUsers[0]?.id || ""; - - async function createManualSession(event: FormEvent) { - event.preventDefault(); - - if (!manualSessionUserId) { - toast.error("Kein aktiver Benutzer vorhanden"); - return; - } - - if (!manualSession.organizationId) { - toast.error("Organisation wählen"); - return; - } - - const startedAt = new Date(`${manualSession.day}T${manualSession.startTime}:00`); - const endedAt = new Date(`${manualSession.day}T${manualSession.endTime}:00`); - - if (Number.isNaN(startedAt.getTime()) || Number.isNaN(endedAt.getTime())) { - toast.error("Datum oder Uhrzeit prüfen"); - return; - } - - if (endedAt <= startedAt) { - toast.error("Ende muss nach Beginn liegen"); - return; - } - - setCreatingSession(true); - - try { - await createAdminSession({ - userId: manualSessionUserId, - ticketNumber: manualSession.ticketNumber, - organizationId: manualSession.organizationId, - activity: manualSession.activity, - workType: manualSession.workType, - startedAt: startedAt.toISOString(), - endedAt: endedAt.toISOString() - }); - toast.success("Session nachgetragen", { - description: "Der Eintrag ist in der Auswertung des gewählten Benutzers offen." - }); - setManualSession({ - ...emptyManualSession(), - userId: manualSessionUserId, - day: manualSession.day - }); - await load(); - } catch (error) { - toast.error("Session konnte nicht nachgetragen werden", { - description: error instanceof Error ? error.message : "Unbekannter Fehler" - }); - } finally { - setCreatingSession(false); - } - } - async function syncOrganizations(event: FormEvent) { event.preventDefault(); setSyncingOrganizations(true); @@ -327,58 +204,109 @@ export function AdminUsersPage({ currentUser }: { currentUser: AuthUser }) { } } + async function exportDatabase() { + setExportingDatabase(true); + + try { + const result = await downloadDatabaseExport(); + const url = window.URL.createObjectURL(result.blob); + const link = document.createElement("a"); + link.href = url; + link.download = result.filename; + document.body.append(link); + link.click(); + link.remove(); + window.URL.revokeObjectURL(url); + toast.success("Datenbankexport erstellt", { + description: result.filename + }); + } catch (error) { + toast.error("Datenbankexport fehlgeschlagen", { + description: error instanceof Error ? error.message : "Unbekannter Fehler" + }); + } finally { + setExportingDatabase(false); + } + } + + const regularUserCount = users.filter((user) => user.role === "user").length; + return (
-

Benutzer

-

Accounts verwalten, Besitzer von Sessions prüfen und Einträge umverteilen.

+

Administration

+

Systemverwaltung für Benutzer, Zammad-Organisationen und restore-fähige Backups.

- - -
-
- - Data-Sync +
+ + +
+
+ + Data-Sync +
+
- -
- Zammad-Zugang speichern und Organisationen in die lokale TicketTracker-Datenbank übernehmen. - - -
-
- - setZammadBaseUrl(event.currentTarget.value)} - required - /> + Zammad-Zugang speichern und Organisationen in die lokale TicketTracker-Datenbank übernehmen. + + + +
+ + setZammadBaseUrl(event.currentTarget.value)} + required + /> +
+
+ + setZammadApiKey(event.currentTarget.value)} + /> + {hasZammadApiKey ?

API-Key ist gespeichert. Leer lassen, um ihn weiter zu verwenden.

: null} +
+ + + +
+ + + + +
+
+ + Datenbankexport +
+
-
- - setZammadApiKey(event.currentTarget.value)} - /> - {hasZammadApiKey ?

API-Key ist gespeichert. Leer lassen, um ihn weiter zu verwenden.

: null} -
- - - - -
+ + +
@@ -391,7 +319,7 @@ export function AdminUsersPage({ currentUser }: { currentUser: AuthUser }) {
-
+
setNewUser({ ...newUser, username: event.currentTarget.value })} required /> @@ -405,16 +333,8 @@ export function AdminUsersPage({ currentUser }: { currentUser: AuthUser }) { setNewUser({ ...newUser, password: event.currentTarget.value })} required />
- - + Rolle + User