diff --git a/backend/src/controllers/auth.controller.ts b/backend/src/controllers/auth.controller.ts index 3c60250..bc6caac 100644 --- a/backend/src/controllers/auth.controller.ts +++ b/backend/src/controllers/auth.controller.ts @@ -93,7 +93,7 @@ class AuthController { try { await authentikService.verifyIdToken(tokens.id_token); } catch (error) { - logger.warn('ID token verification failed — continuing with userinfo', { error }); + logger.error('ID token verification failed — continuing with userinfo (security event)', { error }); } } @@ -136,7 +136,31 @@ class AuthController { metadata: { new_account: true }, }); } else { - // User exists, update last login + // User exists — check active status BEFORE any mutations + if (!user.is_active) { + logger.warn('Inactive user attempted login', { userId: user.id }); + + auditService.logAudit({ + user_id: user.id, + user_email: user.email, + action: AuditAction.PERMISSION_DENIED, + resource_type: AuditResourceType.USER, + resource_id: user.id, + old_value: null, + new_value: null, + ip_address: ip, + user_agent: userAgent, + metadata: { reason: 'account_inactive' }, + }); + + res.status(403).json({ + success: false, + message: 'User account is inactive', + }); + return; + } + + // User is active, proceed with login updates logger.info('Existing user logging in', { userId: user.id, email: user.email, @@ -148,12 +172,13 @@ class AuthController { const { given_name: updatedGivenName, family_name: updatedFamilyName } = extractNames(userInfo); - // Refresh profile fields from Authentik on every login + // Refresh profile fields from Authentik on every login (including profile picture) await userService.updateUser(user.id, { - name: userInfo.name, - given_name: updatedGivenName, - family_name: updatedFamilyName, - preferred_username: userInfo.preferred_username, + name: userInfo.name, + given_name: updatedGivenName, + family_name: updatedFamilyName, + preferred_username: userInfo.preferred_username, + profile_picture_url: userInfo.picture || null, }); // Audit: returning user login @@ -174,31 +199,6 @@ class AuthController { // Extract normalised names once for use in the response const { given_name: resolvedGivenName, family_name: resolvedFamilyName } = extractNames(userInfo); - // Check if user is active - if (!user.is_active) { - logger.warn('Inactive user attempted login', { userId: user.id }); - - // Audit the denied login attempt - auditService.logAudit({ - user_id: user.id, - user_email: user.email, - action: AuditAction.PERMISSION_DENIED, - resource_type: AuditResourceType.USER, - resource_id: user.id, - old_value: null, - new_value: null, - ip_address: ip, - user_agent: userAgent, - metadata: { reason: 'account_inactive' }, - }); - - res.status(403).json({ - success: false, - message: 'User account is inactive', - }); - return; - } - // Step 5: Generate internal JWT token const role = await getUserRole(user.id); const accessToken = tokenService.generateToken({ diff --git a/backend/src/controllers/equipment.controller.ts b/backend/src/controllers/equipment.controller.ts index 5421b39..2364f03 100644 --- a/backend/src/controllers/equipment.controller.ts +++ b/backend/src/controllers/equipment.controller.ts @@ -252,21 +252,26 @@ class EquipmentController { // Determine which category to check permissions against const groups = getUserGroups(req); if (!groups.includes('dashboard_admin')) { - // If kategorie_id is being changed, check against the new category; otherwise fetch existing - let kategorieId = parsed.data.kategorie_id; - if (!kategorieId) { - const existing = await equipmentService.getEquipmentById(id); - if (!existing) { - res.status(404).json({ success: false, message: 'Ausrüstung nicht gefunden' }); - return; - } - kategorieId = existing.kategorie_id; + // Always fetch existing equipment to check old category permission + const existing = await equipmentService.getEquipmentById(id); + if (!existing) { + res.status(404).json({ success: false, message: 'Ausrüstung nicht gefunden' }); + return; } - const allowed = await checkCategoryPermission(kategorieId, groups); - if (!allowed) { + // Check permission against the OLD category (must be allowed to move FROM it) + const allowedOld = await checkCategoryPermission(existing.kategorie_id, groups); + if (!allowedOld) { res.status(403).json({ success: false, message: 'Keine Berechtigung für diese Kategorie' }); return; } + // If kategorie_id is being changed, also check permission against the NEW category + if (parsed.data.kategorie_id && parsed.data.kategorie_id !== existing.kategorie_id) { + const allowedNew = await checkCategoryPermission(parsed.data.kategorie_id, groups); + if (!allowedNew) { + res.status(403).json({ success: false, message: 'Keine Berechtigung für die Ziel-Kategorie' }); + return; + } + } } const equipment = await equipmentService.updateEquipment(id, parsed.data, getUserId(req)); if (!equipment) { diff --git a/backend/src/controllers/events.controller.ts b/backend/src/controllers/events.controller.ts index 9ad1fdc..f2ff0d3 100644 --- a/backend/src/controllers/events.controller.ts +++ b/backend/src/controllers/events.controller.ts @@ -161,7 +161,7 @@ class EventsController { // ------------------------------------------------------------------------- getUpcoming = async (req: Request, res: Response): Promise => { try { - const limit = Math.min(Number(req.query.limit ?? 10), 50); + const limit = Math.min(Number(req.query.limit) || 10, 50); const userGroups = getUserGroups(req); const data = await eventsService.getUpcomingEvents(limit, userGroups); res.json({ success: true, data }); diff --git a/backend/src/controllers/incident.controller.ts b/backend/src/controllers/incident.controller.ts index 272c186..2d19fd3 100644 --- a/backend/src/controllers/incident.controller.ts +++ b/backend/src/controllers/incident.controller.ts @@ -2,7 +2,7 @@ import { Request, Response } from 'express'; import incidentService from '../services/incident.service'; import logger from '../utils/logger'; import { AppError } from '../middleware/error.middleware'; -import { AppRole, hasPermission } from '../middleware/rbac.middleware'; +import { AppRole, hasPermission, resolveRequestRole } from '../middleware/rbac.middleware'; import { CreateEinsatzSchema, UpdateEinsatzSchema, @@ -75,16 +75,22 @@ class IncidentController { async getIncident(req: AuthenticatedRequest, res: Response): Promise { try { const { id } = req.params as Record; + + // UUID validation + if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(id)) { + res.status(400).json({ success: false, message: 'Ungültige Einsatz-ID' }); + return; + } + const incident = await incidentService.getIncidentById(id); if (!incident) { throw new AppError('Einsatz nicht gefunden', 404); } - // Role-based redaction: only Kommandant+ can see full bericht_text - const canReadBerichtText = - req.userRole !== undefined && - hasPermission(req.userRole, 'incidents:read_bericht_text'); + // Role-based redaction: self-contained role resolution (no middleware dependency) + const role = resolveRequestRole(req); + const canReadBerichtText = hasPermission(role, 'incidents:read_bericht_text'); const responseData = { ...incident, diff --git a/backend/src/controllers/member.controller.ts b/backend/src/controllers/member.controller.ts index 533ecbb..1c28d99 100644 --- a/backend/src/controllers/member.controller.ts +++ b/backend/src/controllers/member.controller.ts @@ -60,8 +60,8 @@ class MemberController { search, status: normalizeArray(statusParam) as any, dienstgrad: normalizeArray(dienstgradParam) as any, - page: page ? parseInt(page, 10) : 1, - pageSize: pageSize ? Math.min(parseInt(pageSize, 10), 100) : 25, + page: page ? parseInt(page, 10) || 1 : 1, + pageSize: pageSize ? Math.min(parseInt(pageSize, 10) || 25, 100) : 25, }); res.status(200).json({ diff --git a/backend/src/controllers/nextcloud.controller.ts b/backend/src/controllers/nextcloud.controller.ts index d0e4443..382a3e1 100644 --- a/backend/src/controllers/nextcloud.controller.ts +++ b/backend/src/controllers/nextcloud.controller.ts @@ -1,4 +1,5 @@ import { Request, Response } from 'express'; +import path from 'path'; import { z } from 'zod'; import nextcloudService from '../services/nextcloud.service'; import userService from '../services/user.service'; @@ -216,13 +217,20 @@ class NextcloudController { res.status(400).json({ success: false, message: 'Dateipfad fehlt' }); return; } + // Path traversal protection + const normalized = path.normalize(filePath); + if (normalized.includes('..') || !normalized.startsWith('/')) { + res.status(400).json({ success: false, message: 'Ungültiger Dateipfad' }); + return; + } const response = await nextcloudService.downloadFile( filePath, credentials.loginName, credentials.appPassword, ); const contentType = response.headers['content-type'] ?? 'application/octet-stream'; - const contentDisposition = response.headers['content-disposition'] ?? `attachment; filename="${req.params.fileId}"`; + const contentDisposition = response.headers['content-disposition'] + ?? `attachment; filename="${String(req.params.fileId).replace(/["\r\n\\]/g, '_')}"`; res.setHeader('Content-Type', contentType); res.setHeader('Content-Disposition', contentDisposition); if (response.headers['content-length']) { diff --git a/backend/src/controllers/settings.controller.ts b/backend/src/controllers/settings.controller.ts index b372bcb..498adbd 100644 --- a/backend/src/controllers/settings.controller.ts +++ b/backend/src/controllers/settings.controller.ts @@ -78,6 +78,17 @@ class SettingsController { try { const userId = (req as any).user.id; const preferences = req.body; + + // Basic validation — reject excessively large or non-object payloads + if (typeof preferences !== 'object' || preferences === null || Array.isArray(preferences)) { + res.status(400).json({ success: false, message: 'Preferences must be a JSON object' }); + return; + } + if (JSON.stringify(preferences).length > 10_000) { + res.status(400).json({ success: false, message: 'Preferences payload too large' }); + return; + } + await pool.query( 'UPDATE users SET preferences = $1 WHERE id = $2', [JSON.stringify(preferences), userId] diff --git a/backend/src/middleware/rbac.middleware.ts b/backend/src/middleware/rbac.middleware.ts index 974a45e..152b90b 100644 --- a/backend/src/middleware/rbac.middleware.ts +++ b/backend/src/middleware/rbac.middleware.ts @@ -69,7 +69,7 @@ const PERMISSION_ROLE_MIN: Record = { function roleFromGroups(groups: string[]): AppRole { if (groups.includes('dashboard_admin')) return 'admin'; if (groups.includes('dashboard_kommando')) return 'kommandant'; - if (groups.includes('dashboard_fahrmeister') || groups.includes('dashboard_zeugmeister')) return 'gruppenfuehrer'; + if (groups.includes('dashboard_gruppenfuehrer') || groups.includes('dashboard_fahrmeister') || groups.includes('dashboard_zeugmeister')) return 'gruppenfuehrer'; return 'mitglied'; } @@ -160,7 +160,19 @@ export function requirePermission(permission: string) { }; } -export { getUserRole, hasPermission }; +/** + * Resolve the effective AppRole for a request, combining DB role and group role. + * Self-contained — does not depend on requirePermission() middleware having run. + */ +export function resolveRequestRole(req: Request): AppRole { + const dbRole = (req.user as any)?.role + ? ((req.user as any).role as AppRole) + : 'mitglied'; + const groupRole = roleFromGroups(req.user?.groups ?? []); + return ROLE_HIERARCHY.indexOf(groupRole) > ROLE_HIERARCHY.indexOf(dbRole) ? groupRole : dbRole; +} + +export { getUserRole, hasPermission, roleFromGroups }; /** * Middleware factory: requires the authenticated user to belong to at least diff --git a/backend/src/routes/admin.routes.ts b/backend/src/routes/admin.routes.ts index db3cde2..665d740 100644 --- a/backend/src/routes/admin.routes.ts +++ b/backend/src/routes/admin.routes.ts @@ -167,8 +167,6 @@ router.get( } ); -export default router; - // --------------------------------------------------------------------------- // FDISK Sync proxy — forwards to the fdisk-sync sidecar service // --------------------------------------------------------------------------- @@ -214,3 +212,5 @@ router.post( } } ); + +export default router; diff --git a/backend/src/services/audit.service.ts b/backend/src/services/audit.service.ts index da42a9a..78d08e1 100644 --- a/backend/src/services/audit.service.ts +++ b/backend/src/services/audit.service.ts @@ -366,20 +366,22 @@ class AuditService { const escape = (v: unknown): string => { if (v === null || v === undefined) return ''; const str = typeof v === 'object' ? JSON.stringify(v) : String(v); - // RFC 4180: wrap in quotes, double any internal quotes - return `"${str.replace(/"/g, '""')}"`; + let safe = str.replace(/"/g, '""'); + // Prevent formula injection in spreadsheets + if (/^[=+@\-]/.test(safe)) safe = "'" + safe; + return `"${safe}"`; }; const rows = entries.map((e) => [ - e.id, - e.created_at.toISOString(), - e.user_id ?? '', - e.user_email ?? '', - e.action, - e.resource_type, - e.resource_id ?? '', - e.ip_address ?? '', + escape(e.id), + escape(e.created_at instanceof Date ? e.created_at.toISOString() : String(e.created_at)), + escape(e.user_id ?? ''), + escape(e.user_email ?? ''), + escape(e.action), + escape(e.resource_type), + escape(e.resource_id ?? ''), + escape(e.ip_address ?? ''), escape(e.user_agent), escape(e.old_value), escape(e.new_value), diff --git a/backend/src/services/booking.service.ts b/backend/src/services/booking.service.ts index 746363f..4a330ad 100644 --- a/backend/src/services/booking.service.ts +++ b/backend/src/services/booking.service.ts @@ -314,17 +314,19 @@ class BookingService { /** Soft-cancels a booking by setting abgesagt=TRUE and recording the reason. */ async cancel(id: string, abgesagt_grund: string): Promise { - await pool.query( + const result = await pool.query( `UPDATE fahrzeug_buchungen SET abgesagt = TRUE, abgesagt_grund = $2, aktualisiert_am = NOW() WHERE id = $1`, [id, abgesagt_grund] ); + if (result.rowCount === 0) throw new Error('Buchung nicht gefunden'); } /** Permanently deletes a booking record. */ async delete(id: string): Promise { - await pool.query('DELETE FROM fahrzeug_buchungen WHERE id = $1', [id]); + const result = await pool.query('DELETE FROM fahrzeug_buchungen WHERE id = $1', [id]); + if (result.rowCount === 0) throw new Error('Buchung nicht gefunden'); } /** @@ -399,6 +401,22 @@ class BookingService { const { rows } = await pool.query(query, params); const now = toIcalDate(new Date()); + // iCal escaping and folding helpers + const icalEscape = (val: string): string => + val.replace(/\\/g, '\\\\').replace(/;/g, '\\;').replace(/,/g, '\\,').replace(/\n/g, '\\n'); + const icalFold = (line: string): string => { + if (Buffer.byteLength(line, 'utf-8') <= 75) return line; + let folded = ''; + let cur = ''; + let bytes = 0; + for (const ch of line) { + const cb = Buffer.byteLength(ch, 'utf-8'); + if (bytes + cb > 75) { folded += cur + '\r\n '; cur = ch; bytes = 1 + cb; } + else { cur += ch; bytes += cb; } + } + return folded + cur; + }; + const events = rows .map((row: any) => { const beschreibung = [row.buchungs_art, row.beschreibung] @@ -410,8 +428,8 @@ class BookingService { `DTSTAMP:${now}\r\n` + `DTSTART:${toIcalDate(new Date(row.beginn))}\r\n` + `DTEND:${toIcalDate(new Date(row.ende))}\r\n` + - `SUMMARY:${row.titel} - ${row.fahrzeug_name}\r\n` + - `DESCRIPTION:${beschreibung}\r\n` + + icalFold(`SUMMARY:${icalEscape(row.titel)} - ${icalEscape(row.fahrzeug_name)}`) + '\r\n' + + icalFold(`DESCRIPTION:${icalEscape(beschreibung)}`) + '\r\n' + 'END:VEVENT\r\n' ); }) diff --git a/backend/src/services/bookstack.service.ts b/backend/src/services/bookstack.service.ts index 7249166..909f022 100644 --- a/backend/src/services/bookstack.service.ts +++ b/backend/src/services/bookstack.service.ts @@ -158,6 +158,7 @@ async function searchPages(query: string): Promise { headers: buildHeaders(), }, ); + const bookSlugMap = await getBookSlugMap(); const results: BookStackSearchResult[] = (response.data?.data ?? []) .filter((item: any) => item.type === 'page') .map((item: any) => ({ @@ -166,7 +167,7 @@ async function searchPages(query: string): Promise { slug: item.slug, book_id: item.book_id ?? 0, book_slug: item.book_slug ?? '', - url: `${bookstack.url}/books/${item.book_slug || item.book_id}/page/${item.slug}`, + url: `${bookstack.url}/books/${bookSlugMap.get(item.book_id) || item.book_slug || item.book_id}/page/${item.slug}`, preview_html: item.preview_html ?? { content: '' }, tags: item.tags ?? [], })); diff --git a/backend/src/services/events.service.ts b/backend/src/services/events.service.ts index 55c0bbf..39d0791 100644 --- a/backend/src/services/events.service.ts +++ b/backend/src/services/events.service.ts @@ -78,13 +78,23 @@ function formatIcalDate(date: Date): string { /** Fold long iCal lines at 75 octets (RFC 5545 §3.1) */ function icalFold(line: string): string { - if (line.length <= 75) return line; + if (Buffer.byteLength(line, 'utf-8') <= 75) return line; let folded = ''; - while (line.length > 75) { - folded += line.slice(0, 75) + '\r\n '; - line = line.slice(75); + let currentLine = ''; + let currentBytes = 0; + + for (const char of line) { + const charBytes = Buffer.byteLength(char, 'utf-8'); + if (currentBytes + charBytes > 75) { + folded += currentLine + '\r\n '; + currentLine = char; + currentBytes = 1 + charBytes; // continuation line leading space = 1 byte + } else { + currentLine += char; + currentBytes += charBytes; + } } - folded += line; + folded += currentLine; return folded; } @@ -241,7 +251,7 @@ class EventsService { v.alle_gruppen, v.zielgruppen, v.abgesagt, v.anmeldung_erforderlich FROM veranstaltungen v LEFT JOIN veranstaltung_kategorien k ON k.id = v.kategorie_id - WHERE (v.datum_von BETWEEN $1 AND $2 OR v.datum_bis BETWEEN $1 AND $2) + WHERE (v.datum_von BETWEEN $1 AND $2 OR v.datum_bis BETWEEN $1 AND $2 OR (v.datum_von <= $1 AND v.datum_bis >= $2)) AND ( v.alle_gruppen = TRUE OR v.zielgruppen && $3 @@ -388,6 +398,7 @@ class EventsService { const effectiveLimit = limitDate < maxDate ? limitDate : maxDate; let current = new Date(startDate); + const originalDay = startDate.getDate(); while (dates.length < 100) { // Advance to next occurrence @@ -400,10 +411,15 @@ class EventsService { current = new Date(current); current.setDate(current.getDate() + 14); break; - case 'monatlich_datum': + case 'monatlich_datum': { current = new Date(current); - current.setMonth(current.getMonth() + 1); + const targetMonth = current.getMonth() + 1; + current.setDate(1); + current.setMonth(targetMonth); + const lastDay = new Date(current.getFullYear(), current.getMonth() + 1, 0).getDate(); + current.setDate(Math.min(originalDay, lastDay)); break; + } case 'monatlich_erster_wochentag': { const targetWeekday = config.wochentag ?? 0; // 0=Mon current = new Date(current); diff --git a/backend/src/services/serviceMonitor.service.ts b/backend/src/services/serviceMonitor.service.ts index 65967e4..0daf210 100644 --- a/backend/src/services/serviceMonitor.service.ts +++ b/backend/src/services/serviceMonitor.service.ts @@ -177,6 +177,23 @@ class ServiceMonitorService { } async getStatusSummary(): Promise { + // Read latest stored ping results instead of triggering a new ping cycle + try { + const { rows } = await pool.query(` + SELECT DISTINCT ON (service_id) service_id, status + FROM service_ping_history + ORDER BY service_id, checked_at DESC + `); + if (rows.length > 0) { + return { + up: rows.filter((r: any) => r.status === 'up').length, + total: rows.length, + }; + } + } catch { + // Fall through to live ping if no history + } + // Fallback: no history yet — do a live ping const results = await this.pingAll(); return { up: results.filter((r) => r.status === 'up').length, diff --git a/backend/src/services/token.service.ts b/backend/src/services/token.service.ts index 8aa4c33..b794d30 100644 --- a/backend/src/services/token.service.ts +++ b/backend/src/services/token.service.ts @@ -16,6 +16,7 @@ class TokenService { authentikSub: payload.authentikSub, groups: payload.groups ?? [], role: payload.role, + type: 'access', }, environment.jwt.secret, { @@ -39,7 +40,11 @@ class TokenService { const decoded = jwt.verify( token, environment.jwt.secret - ) as JwtPayload; + ) as JwtPayload & { type?: string }; + + if (decoded.type && decoded.type !== 'access') { + throw new Error('Invalid token type'); + } logger.debug('JWT token verified', { userId: decoded.userId }); return decoded; @@ -66,6 +71,7 @@ class TokenService { { userId: payload.userId, email: payload.email, + type: 'refresh', }, environment.jwt.secret, { @@ -89,7 +95,11 @@ class TokenService { const decoded = jwt.verify( token, environment.jwt.secret - ) as RefreshTokenPayload; + ) as RefreshTokenPayload & { type?: string }; + + if (decoded.type && decoded.type !== 'refresh') { + throw new Error('Invalid token type'); + } logger.debug('Refresh token verified', { userId: decoded.userId }); return decoded; diff --git a/backend/src/services/training.service.ts b/backend/src/services/training.service.ts index 830cd45..cee1fac 100644 --- a/backend/src/services/training.service.ts +++ b/backend/src/services/training.service.ts @@ -116,8 +116,7 @@ class TrainingService { FROM uebungen u LEFT JOIN uebung_teilnahmen t ON t.uebung_id = u.id ${userId ? `LEFT JOIN uebung_teilnahmen own_t ON own_t.uebung_id = u.id AND own_t.user_id = $3` : ''} - WHERE u.datum_von >= $1 - AND u.datum_von <= $2 + WHERE (u.datum_von BETWEEN $1 AND $2 OR u.datum_bis BETWEEN $1 AND $2 OR (u.datum_von <= $1 AND u.datum_bis >= $2)) GROUP BY u.id ${userId ? `, own_t.status` : ''} ORDER BY u.datum_von ASC `; @@ -510,16 +509,24 @@ function formatIcsDate(date: Date): string { * Continuation lines start with a single space. */ function foldLine(line: string): string { - const MAX = 75; - if (line.length <= MAX) return line; + if (Buffer.byteLength(line, 'utf-8') <= 75) return line; + let folded = ''; + let currentLine = ''; + let currentBytes = 0; - let result = ''; - while (line.length > MAX) { - result += line.substring(0, MAX) + '\r\n '; - line = line.substring(MAX); + for (const char of line) { + const charBytes = Buffer.byteLength(char, 'utf-8'); + if (currentBytes + charBytes > 75) { + folded += currentLine + '\r\n '; + currentLine = char; + currentBytes = 1 + charBytes; + } else { + currentLine += char; + currentBytes += charBytes; + } } - result += line; - return result; + folded += currentLine; + return folded; } /** @@ -592,7 +599,7 @@ export function generateICS( lines.push(foldLine(`SUMMARY:${escapeIcsText(summary)}`)); if (descParts.length > 0) { - lines.push(foldLine(`DESCRIPTION:${escapeIcsText(descParts.join('\\n'))}`)); + lines.push(foldLine(`DESCRIPTION:${escapeIcsText(descParts.join('\n'))}`)); } if (event.ort) { lines.push(foldLine(`LOCATION:${escapeIcsText(event.ort)}`)); diff --git a/backend/src/services/vikunja.service.ts b/backend/src/services/vikunja.service.ts index 2f378a6..6fc1017 100644 --- a/backend/src/services/vikunja.service.ts +++ b/backend/src/services/vikunja.service.ts @@ -93,7 +93,7 @@ async function getOverdueTasks(): Promise { const tasks = await getMyTasks(); const now = new Date(); return tasks.filter((t) => { - if (!t.due_date) return false; + if (!t.due_date || t.due_date.startsWith('0001-')) return false; return new Date(t.due_date) < now; }); } diff --git a/frontend/src/components/admin/UserOverviewTab.tsx b/frontend/src/components/admin/UserOverviewTab.tsx index 22c8ba7..0cb5eb3 100644 --- a/frontend/src/components/admin/UserOverviewTab.tsx +++ b/frontend/src/components/admin/UserOverviewTab.tsx @@ -18,6 +18,16 @@ import { useQuery } from '@tanstack/react-query'; import { adminApi } from '../../services/admin'; import type { UserOverview } from '../../types/admin.types'; +function getRoleFromGroups(groups: string[] | null): string { + if (!groups) return 'Mitglied'; + if (groups.includes('dashboard_admin')) return 'Admin'; + if (groups.includes('dashboard_kommando')) return 'Kommandant'; + if (groups.includes('dashboard_gruppenfuehrer')) return 'Gruppenführer'; + if (groups.includes('dashboard_moderator')) return 'Moderator'; + if (groups.includes('dashboard_atemschutz')) return 'Atemschutz'; + return 'Mitglied'; +} + type SortKey = 'name' | 'email' | 'role' | 'is_active' | 'last_login_at'; type SortDir = 'asc' | 'desc'; @@ -145,9 +155,9 @@ function UserOverviewTab() { {user.email} diff --git a/frontend/src/components/chat/ChatMessageView.tsx b/frontend/src/components/chat/ChatMessageView.tsx index 950d5c1..6accbb5 100644 --- a/frontend/src/components/chat/ChatMessageView.tsx +++ b/frontend/src/components/chat/ChatMessageView.tsx @@ -24,6 +24,8 @@ const ChatMessageView: React.FC = () => { const { chatPanelOpen } = useLayout(); const queryClient = useQueryClient(); const messagesEndRef = useRef(null); + const scrollContainerRef = useRef(null); + const isInitialLoadRef = useRef(true); const [input, setInput] = useState(''); const [messages, setMessages] = useState([]); const [isLoading, setIsLoading] = useState(false); @@ -63,6 +65,7 @@ const ChatMessageView: React.FC = () => { setIsLoading(true); setMessages([]); setReactionsMap(new Map()); + isInitialLoadRef.current = true; lastMsgIdRef.current = 0; // Step 1: Initial fetch — Nextcloud returns newest-first, so reverse for chronological display @@ -143,17 +146,31 @@ const ChatMessageView: React.FC = () => { }, }); - // Mark room as read while viewing messages + // Mark room as read when first opened useEffect(() => { if (selectedRoomToken && chatPanelOpen) { nextcloudApi.markAsRead(selectedRoomToken).then(() => { queryClient.invalidateQueries({ queryKey: ['nextcloud', 'rooms'] }); }).catch(() => {}); } - }, [selectedRoomToken, chatPanelOpen, queryClient, messages.length]); + }, [selectedRoomToken, chatPanelOpen, queryClient]); + // Smart scroll: instant on initial load, smooth only when user is near bottom useEffect(() => { - messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); + if (!messagesEndRef.current) return; + if (isInitialLoadRef.current) { + messagesEndRef.current.scrollIntoView(); + if (messages.length > 0) isInitialLoadRef.current = false; + return; + } + const container = scrollContainerRef.current; + if (container) { + const { scrollHeight, scrollTop, clientHeight } = container; + const isNearBottom = scrollHeight - scrollTop - clientHeight < 150; + if (isNearBottom) { + messagesEndRef.current.scrollIntoView({ behavior: 'smooth' }); + } + } }, [messages]); const handleSend = () => { @@ -230,6 +247,7 @@ const ChatMessageView: React.FC = () => { { const { chatPanelOpen, setChatPanelOpen } = useLayout(); const { rooms, selectedRoomToken, selectRoom, connected } = useChat(); const queryClient = useQueryClient(); + const markedRoomsRef = React.useRef(new Set()); const { data: externalLinks } = useQuery({ queryKey: ['external-links'], queryFn: () => configApi.getExternalLinks(), @@ -46,11 +47,17 @@ const ChatPanelInner: React.FC = () => { }).catch(() => {}); }, [chatPanelOpen, queryClient]); - // Mark unread rooms as read in Nextcloud whenever panel is open and rooms update + // Mark unread rooms as read in Nextcloud whenever panel is open React.useEffect(() => { - if (!chatPanelOpen) return; - const unread = rooms.filter((r) => r.unreadMessages > 0); + if (!chatPanelOpen) { + markedRoomsRef.current.clear(); + return; + } + const unread = rooms.filter( + (r) => r.unreadMessages > 0 && !markedRoomsRef.current.has(r.token), + ); if (unread.length === 0) return; + unread.forEach((r) => markedRoomsRef.current.add(r.token)); Promise.allSettled(unread.map((r) => nextcloudApi.markAsRead(r.token))).then(() => { queryClient.invalidateQueries({ queryKey: ['nextcloud', 'rooms'] }); }); diff --git a/frontend/src/components/chat/NewChatDialog.tsx b/frontend/src/components/chat/NewChatDialog.tsx index 93e8059..bde5f8f 100644 --- a/frontend/src/components/chat/NewChatDialog.tsx +++ b/frontend/src/components/chat/NewChatDialog.tsx @@ -1,4 +1,4 @@ -import React, { useState } from 'react'; +import React, { useState, useEffect } from 'react'; import Dialog from '@mui/material/Dialog'; import DialogTitle from '@mui/material/DialogTitle'; import DialogContent from '@mui/material/DialogContent'; @@ -25,6 +25,14 @@ const NewChatDialog: React.FC = ({ open, onClose, onRoomCrea const [search, setSearch] = useState(''); const [creating, setCreating] = useState(false); + // Reset state when dialog opens + useEffect(() => { + if (open) { + setSearch(''); + setCreating(false); + } + }, [open]); + const { data: users, isLoading } = useQuery({ queryKey: ['nextcloud', 'users', search], queryFn: () => nextcloudApi.searchUsers(search), diff --git a/frontend/src/components/dashboard/BookStackRecentWidget.tsx b/frontend/src/components/dashboard/BookStackRecentWidget.tsx index fcdcc6a..c84c297 100644 --- a/frontend/src/components/dashboard/BookStackRecentWidget.tsx +++ b/frontend/src/components/dashboard/BookStackRecentWidget.tsx @@ -76,7 +76,7 @@ const BookStackRecentWidget: React.FC = () => { retry: 1, }); - const configured = data?.configured ?? true; + const configured = data?.configured ?? false; const pages = (data?.data ?? []).slice(0, 5); if (!configured) { diff --git a/frontend/src/components/dashboard/BookStackSearchWidget.tsx b/frontend/src/components/dashboard/BookStackSearchWidget.tsx index 1456480..69a768c 100644 --- a/frontend/src/components/dashboard/BookStackSearchWidget.tsx +++ b/frontend/src/components/dashboard/BookStackSearchWidget.tsx @@ -69,6 +69,11 @@ const BookStackSearchWidget: React.FC = () => { const [searching, setSearching] = useState(false); const debounceRef = useRef | null>(null); const latestQueryRef = useRef(''); + const isMountedRef = useRef(true); + + useEffect(() => { + return () => { isMountedRef.current = false; }; + }, []); const { data, isLoading: configLoading } = useQuery({ queryKey: ['bookstack-recent'], @@ -96,15 +101,15 @@ const BookStackSearchWidget: React.FC = () => { debounceRef.current = setTimeout(async () => { try { const response = await bookstackApi.search(thisQuery); - if (latestQueryRef.current === thisQuery) { + if (isMountedRef.current && latestQueryRef.current === thisQuery) { setResults(response.data); } } catch { - if (latestQueryRef.current === thisQuery) { + if (isMountedRef.current && latestQueryRef.current === thisQuery) { setResults([]); } } finally { - if (latestQueryRef.current === thisQuery) { + if (isMountedRef.current && latestQueryRef.current === thisQuery) { setSearching(false); } } diff --git a/frontend/src/components/dashboard/DashboardLayout.tsx b/frontend/src/components/dashboard/DashboardLayout.tsx index 3ad6d98..0083bf2 100644 --- a/frontend/src/components/dashboard/DashboardLayout.tsx +++ b/frontend/src/components/dashboard/DashboardLayout.tsx @@ -25,7 +25,7 @@ function DashboardLayoutInner({ children }: DashboardLayoutProps) { } const sidebarWidth = sidebarCollapsed ? DRAWER_WIDTH_COLLAPSED : DRAWER_WIDTH; - const chatWidth = chatPanelOpen ? 360 : 60; + const chatWidth = chatPanelOpen ? 360 : 64; return ( diff --git a/frontend/src/components/dashboard/EventQuickAddWidget.tsx b/frontend/src/components/dashboard/EventQuickAddWidget.tsx index f363ccb..ebf308c 100644 --- a/frontend/src/components/dashboard/EventQuickAddWidget.tsx +++ b/frontend/src/components/dashboard/EventQuickAddWidget.tsx @@ -128,14 +128,7 @@ const EventQuickAddWidget: React.FC = () => { Veranstaltung - {false ? ( - - - - - - ) : ( - + { {mutation.isPending ? 'Wird erstellt…' : 'Erstellen'} - )} ); diff --git a/frontend/src/components/dashboard/VehicleBookingQuickAddWidget.tsx b/frontend/src/components/dashboard/VehicleBookingQuickAddWidget.tsx index 306513a..597e737 100644 --- a/frontend/src/components/dashboard/VehicleBookingQuickAddWidget.tsx +++ b/frontend/src/components/dashboard/VehicleBookingQuickAddWidget.tsx @@ -75,6 +75,7 @@ const VehicleBookingQuickAddWidget: React.FC = () => { setEnde(fresh.ende); setBeschreibung(''); queryClient.invalidateQueries({ queryKey: ['bookings'] }); + queryClient.invalidateQueries({ queryKey: ['upcoming-vehicle-bookings'] }); }, onError: () => { showError('Fahrzeugbuchung konnte nicht erstellt werden'); diff --git a/frontend/src/components/incidents/CreateEinsatzDialog.tsx b/frontend/src/components/incidents/CreateEinsatzDialog.tsx index 5ee501d..0c8a21f 100644 --- a/frontend/src/components/incidents/CreateEinsatzDialog.tsx +++ b/frontend/src/components/incidents/CreateEinsatzDialog.tsx @@ -76,8 +76,13 @@ const CreateEinsatzDialog: React.FC = ({ try { // Convert local datetime string to UTC ISO string const isoLocal = fromGermanDateTime(form.alarm_time_local); + if (!isoLocal) { + setError('Ungültiges Datum/Uhrzeit-Format. Bitte TT.MM.JJJJ HH:MM verwenden.'); + setLoading(false); + return; + } const payload: CreateEinsatzPayload = { - alarm_time: isoLocal ? new Date(isoLocal).toISOString() : new Date().toISOString(), + alarm_time: new Date(isoLocal).toISOString(), einsatz_art: form.einsatz_art, einsatz_stichwort: form.einsatz_stichwort || null, strasse: form.strasse || null, diff --git a/frontend/src/components/shared/ErrorBoundary.tsx b/frontend/src/components/shared/ErrorBoundary.tsx index 1da51d6..1cbc906 100644 --- a/frontend/src/components/shared/ErrorBoundary.tsx +++ b/frontend/src/components/shared/ErrorBoundary.tsx @@ -91,7 +91,7 @@ class ErrorBoundary extends Component { ctx.close(); } catch { // Audio blocked before first user interaction — fail silently } @@ -113,7 +118,9 @@ const NotificationBell: React.FC = () => { showNotificationToast(n.titel, severity); }); // Also add all known IDs to avoid re-toasting on re-fetch - data.forEach((n) => knownIdsRef.current!.add(n.id)); + // Prune to only current IDs to prevent unbounded growth + const currentIds = new Set(data.map((n) => n.id)); + knownIdsRef.current = currentIds; } catch { // non-critical } diff --git a/frontend/src/components/shared/ServiceModeGuard.tsx b/frontend/src/components/shared/ServiceModeGuard.tsx index 22e5364..5478b07 100644 --- a/frontend/src/components/shared/ServiceModeGuard.tsx +++ b/frontend/src/components/shared/ServiceModeGuard.tsx @@ -11,13 +11,16 @@ export default function ServiceModeGuard({ children }: Props) { const { user } = useAuth(); const isAdmin = user?.groups?.includes('dashboard_admin') ?? false; - const { data: serviceMode } = useQuery({ + const { data: serviceMode, isLoading } = useQuery({ queryKey: ['service-mode'], queryFn: configApi.getServiceMode, refetchInterval: 60_000, retry: false, }); + // Don't render children until we know the service mode status + if (isLoading) return null; + if (serviceMode?.active && !isAdmin) { return ; } diff --git a/frontend/src/contexts/AuthContext.tsx b/frontend/src/contexts/AuthContext.tsx index a9232fc..41ffe30 100644 --- a/frontend/src/contexts/AuthContext.tsx +++ b/frontend/src/contexts/AuthContext.tsx @@ -139,9 +139,12 @@ export const AuthProvider: React.FC = ({ children }) => { const user = await authService.getCurrentUser(); setUser(user); setState((prev) => ({ ...prev, user })); - } catch (error) { + } catch (error: any) { console.error('Failed to refresh user data:', error); - logout(); + // Only logout on explicit 401 — network errors / 5xx should not destroy the session + if (error?.response?.status === 401) { + logout(); + } } }, [logout]); diff --git a/frontend/src/contexts/ChatContext.tsx b/frontend/src/contexts/ChatContext.tsx index 33b4fb8..e26dae1 100644 --- a/frontend/src/contexts/ChatContext.tsx +++ b/frontend/src/contexts/ChatContext.tsx @@ -56,19 +56,43 @@ export const ChatProvider: React.FC = ({ children }) => { const connected = data?.connected ?? false; const loginName = data?.loginName ?? null; + const prevUnreadRef = useRef>(new Map()); + const isInitializedRef = useRef(false); + + // Reset initialization flag when disconnected + useEffect(() => { + if (!isConnected) { + isInitializedRef.current = false; + } + }, [isConnected]); + // Detect new unread messages while panel is closed and show toast useEffect(() => { if (!rooms.length) return; const prev = prevUnreadRef.current; - const isFirstLoad = prev.size === 0; + + if (!isInitializedRef.current) { + // First load (or after reconnect) — initialize without toasting + for (const room of rooms) { + prev.set(room.token, room.unreadMessages); + } + isInitializedRef.current = true; + return; + } for (const room of rooms) { const prevCount = prev.get(room.token) ?? 0; - if (!isFirstLoad && !chatPanelOpen && room.unreadMessages > prevCount) { + if (!chatPanelOpen && room.unreadMessages > prevCount) { showNotificationToast(room.displayName, 'info'); } prev.set(room.token, room.unreadMessages); } + + // Prune entries for rooms no longer in the list + const currentTokens = new Set(rooms.map((r) => r.token)); + for (const key of prev.keys()) { + if (!currentTokens.has(key)) prev.delete(key); + } }, [rooms, chatPanelOpen, showNotificationToast]); const selectRoom = useCallback((token: string | null) => { diff --git a/frontend/src/contexts/NotificationContext.tsx b/frontend/src/contexts/NotificationContext.tsx index ca3fea3..d6e70be 100644 --- a/frontend/src/contexts/NotificationContext.tsx +++ b/frontend/src/contexts/NotificationContext.tsx @@ -1,4 +1,4 @@ -import React, { createContext, useContext, useState, ReactNode, useCallback } from 'react'; +import React, { createContext, useContext, useState, useRef, ReactNode, useCallback } from 'react'; import { Snackbar, Alert, AlertColor } from '@mui/material'; interface Notification { @@ -22,8 +22,8 @@ interface NotificationProviderProps { } export const NotificationProvider: React.FC = ({ children }) => { - const [_notifications, setNotifications] = useState([]); const [currentNotification, setCurrentNotification] = useState(null); + const queueRef = useRef([]); // Left-side toast queue for new backend notifications const [toastQueue, setToastQueue] = useState([]); @@ -32,13 +32,15 @@ export const NotificationProvider: React.FC = ({ chil const id = Date.now(); const notification: Notification = { id, message, severity }; - setNotifications((prev) => [...prev, notification]); - - // If no notification is currently displayed, show this one immediately - if (!currentNotification) { - setCurrentNotification(notification); - } - }, [currentNotification]); + // Use functional update to avoid stale closure over currentNotification + setCurrentNotification((prev) => { + if (prev) { + queueRef.current.push(notification); + return prev; + } + return notification; + }); + }, []); const showSuccess = useCallback((message: string) => { addNotification(message, 'success'); @@ -68,15 +70,12 @@ export const NotificationProvider: React.FC = ({ chil setCurrentNotification(null); - // Show next notification after a short delay + // Show next queued notification after a short delay setTimeout(() => { - setNotifications((prev) => { - const remaining = prev.filter((n) => n.id !== currentNotification?.id); - if (remaining.length > 0) { - setCurrentNotification(remaining[0]); - } - return remaining; - }); + const next = queueRef.current.shift(); + if (next) { + setCurrentNotification(next); + } }, 200); }; diff --git a/frontend/src/contexts/ThemeContext.tsx b/frontend/src/contexts/ThemeContext.tsx index 6a10076..19d1d15 100644 --- a/frontend/src/contexts/ThemeContext.tsx +++ b/frontend/src/contexts/ThemeContext.tsx @@ -1,4 +1,4 @@ -import React, { createContext, useContext, useState, useEffect, useMemo } from 'react'; +import React, { createContext, useContext, useState, useEffect, useMemo, useCallback } from 'react'; import { ThemeProvider } from '@mui/material/styles'; import { CssBaseline } from '@mui/material'; import { lightTheme, darkTheme } from '../theme/theme'; @@ -50,10 +50,10 @@ export const ThemeModeProvider: React.FC<{ children: React.ReactNode }> = ({ chi return () => mq.removeEventListener('change', handler); }, []); - const setThemeMode = (mode: ThemeMode) => { + const setThemeMode = useCallback((mode: ThemeMode) => { setThemeModeState(mode); localStorage.setItem(STORAGE_KEY, mode); - }; + }, []); const resolvedMode: 'light' | 'dark' = themeMode === 'system' ? systemPreference : themeMode; diff --git a/frontend/src/pages/Atemschutz.tsx b/frontend/src/pages/Atemschutz.tsx index e543277..43ffa73 100644 --- a/frontend/src/pages/Atemschutz.tsx +++ b/frontend/src/pages/Atemschutz.tsx @@ -41,6 +41,7 @@ import { Search, } from '@mui/icons-material'; import DashboardLayout from '../components/dashboard/DashboardLayout'; +import ChatAwareFab from '../components/shared/ChatAwareFab'; import { atemschutzApi } from '../services/atemschutz'; import { membersService } from '../services/members'; import { useNotification } from '../contexts/NotificationContext'; @@ -314,11 +315,11 @@ function Atemschutz() { lehrgang_datum: normalizeDate(form.lehrgang_datum || undefined), untersuchung_datum: normalizeDate(form.untersuchung_datum || undefined), untersuchung_gueltig_bis: normalizeDate(form.untersuchung_gueltig_bis || undefined), - untersuchung_ergebnis: (form.untersuchung_ergebnis as UntersuchungErgebnis) || undefined, + untersuchung_ergebnis: (form.untersuchung_ergebnis as UntersuchungErgebnis) || null, leistungstest_datum: normalizeDate(form.leistungstest_datum || undefined), leistungstest_gueltig_bis: normalizeDate(form.leistungstest_gueltig_bis || undefined), leistungstest_bestanden: form.leistungstest_bestanden, - bemerkung: form.bemerkung || undefined, + bemerkung: form.bemerkung || null, }; await atemschutzApi.update(editingId, payload); notification.showSuccess('Atemschutzträger erfolgreich aktualisiert.'); @@ -594,14 +595,13 @@ function Atemschutz() { {/* FAB to create */} {canWrite && ( - - + )} {/* ── Add / Edit Dialog ───────────────────────────────────────────── */} diff --git a/frontend/src/pages/Ausruestung.tsx b/frontend/src/pages/Ausruestung.tsx index 61aa080..951f274 100644 --- a/frontend/src/pages/Ausruestung.tsx +++ b/frontend/src/pages/Ausruestung.tsx @@ -45,6 +45,7 @@ import { EquipmentStats, } from '../types/equipment.types'; import { usePermissions } from '../hooks/usePermissions'; +import ChatAwareFab from '../components/shared/ChatAwareFab'; // ── Status chip config ──────────────────────────────────────────────────────── @@ -464,14 +465,13 @@ function Ausruestung() { {/* FAB for adding new equipment */} {canManageEquipment && ( - navigate('/ausruestung/neu')} > - + )} diff --git a/frontend/src/pages/AusruestungForm.tsx b/frontend/src/pages/AusruestungForm.tsx index 4fe8217..80504d5 100644 --- a/frontend/src/pages/AusruestungForm.tsx +++ b/frontend/src/pages/AusruestungForm.tsx @@ -85,27 +85,6 @@ function AusruestungForm() { const { canManageEquipment } = usePermissions(); const isEditMode = Boolean(id); - // -- Permission guard: only authorized users may create or edit equipment ---- - if (!canManageEquipment) { - return ( - - - - - Keine Berechtigung - - - Sie haben nicht die erforderlichen Rechte, um Ausrüstung zu bearbeiten. - - - - - - ); - } - const [form, setForm] = useState(EMPTY_FORM); const [loading, setLoading] = useState(isEditMode); const [saving, setSaving] = useState(false); @@ -168,6 +147,27 @@ function AusruestungForm() { if (isEditMode) fetchEquipment(); }, [isEditMode, fetchEquipment]); + // -- Permission guard: only authorized users may create or edit equipment ---- + if (!canManageEquipment) { + return ( + + + + + Keine Berechtigung + + + Sie haben nicht die erforderlichen Rechte, um Ausrüstung zu bearbeiten. + + + + + + ); + } + // -- Validation ------------------------------------------------------------- const validate = (): boolean => { @@ -213,19 +213,19 @@ function AusruestungForm() { const payload: UpdateAusruestungPayload = { bezeichnung: form.bezeichnung.trim() || undefined, kategorie_id: form.kategorie_id || undefined, - seriennummer: form.seriennummer.trim() || undefined, - inventarnummer: form.inventarnummer.trim() || undefined, - hersteller: form.hersteller.trim() || undefined, - baujahr: form.baujahr ? parseInt(form.baujahr, 10) : undefined, + seriennummer: form.seriennummer.trim() || null, + inventarnummer: form.inventarnummer.trim() || null, + hersteller: form.hersteller.trim() || null, + baujahr: form.baujahr ? parseInt(form.baujahr, 10) : null, status: form.status, - status_bemerkung: form.status_bemerkung.trim() || undefined, + status_bemerkung: form.status_bemerkung.trim() || null, ist_wichtig: form.ist_wichtig, fahrzeug_id: form.fahrzeug_id || null, standort: !form.fahrzeug_id ? (form.standort.trim() || 'Lager') : undefined, - pruef_intervall_monate: form.pruef_intervall_monate ? parseInt(form.pruef_intervall_monate, 10) : undefined, - letzte_pruefung_am: form.letzte_pruefung_am ? fromGermanDate(form.letzte_pruefung_am) || undefined : undefined, - naechste_pruefung_am: form.naechste_pruefung_am ? fromGermanDate(form.naechste_pruefung_am) || undefined : undefined, - bemerkung: form.bemerkung.trim() || undefined, + pruef_intervall_monate: form.pruef_intervall_monate ? parseInt(form.pruef_intervall_monate, 10) : null, + letzte_pruefung_am: form.letzte_pruefung_am ? fromGermanDate(form.letzte_pruefung_am) || null : null, + naechste_pruefung_am: form.naechste_pruefung_am ? fromGermanDate(form.naechste_pruefung_am) || null : null, + bemerkung: form.bemerkung.trim() || null, }; await equipmentApi.update(id, payload); navigate(`/ausruestung/${id}`); diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index 3180b00..191ef76 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -70,6 +70,8 @@ function Dashboard() { return ( + {/* Vikunja — Overdue Notifier (invisible, polling component — outside grid) */} + )} - {/* Vikunja — Overdue Notifier (invisible, polling component) */} - - {/* Status Group */} {widgetVisible('vehicles') && ( diff --git a/frontend/src/pages/Einsaetze.tsx b/frontend/src/pages/Einsaetze.tsx index b070dcd..03b33df 100644 --- a/frontend/src/pages/Einsaetze.tsx +++ b/frontend/src/pages/Einsaetze.tsx @@ -49,6 +49,7 @@ import { EINSATZ_STATUS_LABELS, } from '../services/incidents'; import CreateEinsatzDialog from '../components/incidents/CreateEinsatzDialog'; +import { useAuth } from '../contexts/AuthContext'; // --------------------------------------------------------------------------- // COLOUR MAP for Einsatzart chips @@ -175,6 +176,10 @@ function StatsSummaryBar({ stats, loading }: StatsSummaryProps) { // --------------------------------------------------------------------------- function Einsaetze() { const navigate = useNavigate(); + const { user } = useAuth(); + const canWrite = user?.groups?.some((g: string) => + ['dashboard_admin', 'dashboard_kommando', 'dashboard_gruppenfuehrer'].includes(g) + ) ?? false; // List state const [items, setItems] = useState([]); @@ -220,7 +225,7 @@ function Einsaetze() { filters.dateTo = end.toISOString(); } } - if (selectedArts.length === 1) filters.einsatzArt = selectedArts[0]; + if (selectedArts.length >= 1) filters.einsatzArt = selectedArts[0]; const result = await incidentsApi.getAll(filters as Parameters[0]); setItems(result.items); @@ -308,14 +313,16 @@ function Einsaetze() { - + {canWrite && ( + + )} diff --git a/frontend/src/pages/EinsatzDetail.tsx b/frontend/src/pages/EinsatzDetail.tsx index 83417a7..c9ffd25 100644 --- a/frontend/src/pages/EinsatzDetail.tsx +++ b/frontend/src/pages/EinsatzDetail.tsx @@ -43,6 +43,7 @@ import { EinsatzArt, } from '../services/incidents'; import { useNotification } from '../contexts/NotificationContext'; +import { useAuth } from '../contexts/AuthContext'; // --------------------------------------------------------------------------- // COLOUR MAPS @@ -164,6 +165,10 @@ function EinsatzDetail() { const { id } = useParams<{ id: string }>(); const navigate = useNavigate(); const notification = useNotification(); + const { user } = useAuth(); + const canWrite = user?.groups?.some((g: string) => + ['dashboard_admin', 'dashboard_kommando', 'dashboard_gruppenfuehrer'].includes(g) + ) ?? false; const [einsatz, setEinsatz] = useState(null); const [loading, setLoading] = useState(true); @@ -297,7 +302,7 @@ function EinsatzDetail() { PDF Export - {!editing ? ( + {canWrite && !editing ? ( - ) : ( + ) : canWrite && editing ? ( <> - - - - ); - } - const [form, setForm] = useState(EMPTY_FORM); const [loading, setLoading] = useState(isEditMode); const [saving, setSaving] = useState(false); @@ -141,6 +120,27 @@ function FahrzeugForm() { if (isEditMode) fetchVehicle(); }, [isEditMode, fetchVehicle]); + // ── Permission guard: only admins may create or edit vehicles ────────────── + if (!isAdmin) { + return ( + + + + + Keine Berechtigung + + + Sie haben nicht die erforderlichen Rechte, um Fahrzeuge zu bearbeiten. + + + + + + ); + } + const validate = (): boolean => { const errors: Partial> = {}; if (!form.bezeichnung.trim()) { @@ -160,19 +160,19 @@ function FahrzeugForm() { if (isEditMode && id) { const payload: UpdateFahrzeugPayload = { bezeichnung: form.bezeichnung.trim() || undefined, - kurzname: form.kurzname.trim() || undefined, - amtliches_kennzeichen: form.amtliches_kennzeichen.trim() || undefined, - fahrgestellnummer: form.fahrgestellnummer.trim() || undefined, - baujahr: form.baujahr ? Number(form.baujahr) : undefined, - hersteller: form.hersteller.trim() || undefined, - typ_schluessel: form.typ_schluessel.trim() || undefined, - besatzung_soll: form.besatzung_soll.trim() || undefined, + kurzname: form.kurzname.trim() || null, + amtliches_kennzeichen: form.amtliches_kennzeichen.trim() || null, + fahrgestellnummer: form.fahrgestellnummer.trim() || null, + baujahr: form.baujahr ? Number(form.baujahr) : null, + hersteller: form.hersteller.trim() || null, + typ_schluessel: form.typ_schluessel.trim() || null, + besatzung_soll: form.besatzung_soll.trim() || null, status: form.status, - status_bemerkung: form.status_bemerkung.trim() || undefined, + status_bemerkung: form.status_bemerkung.trim() || null, standort: form.standort.trim() || 'Feuerwehrhaus', - bild_url: form.bild_url.trim() || undefined, - paragraph57a_faellig_am: form.paragraph57a_faellig_am || undefined, - naechste_wartung_am: form.naechste_wartung_am || undefined, + bild_url: form.bild_url.trim() || null, + paragraph57a_faellig_am: form.paragraph57a_faellig_am || null, + naechste_wartung_am: form.naechste_wartung_am || null, }; await vehiclesApi.update(id, payload); navigate(`/fahrzeuge/${id}`); diff --git a/frontend/src/pages/Mitglieder.tsx b/frontend/src/pages/Mitglieder.tsx index 57bd027..4038e41 100644 --- a/frontend/src/pages/Mitglieder.tsx +++ b/frontend/src/pages/Mitglieder.tsx @@ -135,8 +135,13 @@ function Mitglieder() { fetchMembers(); }, [fetchMembers, debouncedSearch]); - // Also fetch when page/filters change + // Also fetch when page/filters change (skip initial mount to avoid double-fetch) + const isInitialMount = useRef(true); useEffect(() => { + if (isInitialMount.current) { + isInitialMount.current = false; + return; + } fetchMembers(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [page, selectedStatus, selectedDienstgrad]); diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx index 7c15424..ad23dcf 100644 --- a/frontend/src/pages/Settings.tsx +++ b/frontend/src/pages/Settings.tsx @@ -130,8 +130,8 @@ function Settings() { const result = await nextcloudApi.poll(pollToken, pollEndpoint); if (result.completed) { stopPolling(); - queryClient.invalidateQueries({ queryKey: ['nextcloud-talk'] }); - queryClient.invalidateQueries({ queryKey: ['nextcloud-talk-rooms'] }); + queryClient.invalidateQueries({ queryKey: ['nextcloud', 'connection'] }); + queryClient.invalidateQueries({ queryKey: ['nextcloud', 'rooms'] }); } } catch { // Polling error — keep trying until timeout diff --git a/frontend/src/pages/Veranstaltungen.tsx b/frontend/src/pages/Veranstaltungen.tsx index 1fe64fd..59bcde8 100644 --- a/frontend/src/pages/Veranstaltungen.tsx +++ b/frontend/src/pages/Veranstaltungen.tsx @@ -1101,7 +1101,8 @@ export default function Veranstaltungen() { }; const handleToday = () => { - setViewMonth({ year: today.getFullYear(), month: today.getMonth() }); + const now = new Date(); + setViewMonth({ year: now.getFullYear(), month: now.getMonth() }); }; // --------------------------------------------------------------------------- diff --git a/frontend/src/pages/admin/AuditLog.tsx b/frontend/src/pages/admin/AuditLog.tsx index 371554a..11369d9 100644 --- a/frontend/src/pages/admin/AuditLog.tsx +++ b/frontend/src/pages/admin/AuditLog.tsx @@ -457,30 +457,29 @@ const AuditLog: React.FC = () => { setLoading(true); setError(null); try { - const params: Record = { - page: String(pagination.page + 1), // convert 0-based to 1-based - pageSize: String(pagination.pageSize), - }; + const params = new URLSearchParams(); + params.set('page', String(pagination.page + 1)); + params.set('pageSize', String(pagination.pageSize)); if (f.dateFrom) { const iso = fromGermanDate(f.dateFrom); - if (iso) params.dateFrom = new Date(iso).toISOString(); + if (iso) params.set('dateFrom', new Date(iso).toISOString()); } if (f.dateTo) { const iso = fromGermanDate(f.dateTo); - if (iso) params.dateTo = new Date(iso + 'T23:59:59').toISOString(); + if (iso) params.set('dateTo', new Date(iso + 'T23:59:59').toISOString()); } - if (f.action && f.action.length > 0) { - params.action = f.action.join(','); + if (f.action && f.action.length > 0) { + f.action.forEach((a) => params.append('action', a)); } if (f.resourceType && f.resourceType.length > 0) { - params.resourceType = f.resourceType.join(','); + f.resourceType.forEach((rt) => params.append('resourceType', rt)); } - if (f.userId) params.userId = f.userId; + if (f.userId) params.set('userId', f.userId); - const queryString = new URLSearchParams(params).toString(); + const queryString = params.toString(); const response = await api.get<{ success: boolean; data: AuditLogPage }>( - `/admin/audit-log?${queryString}` + `/api/admin/audit-log?${queryString}` ); setRows(response.data.data.entries); @@ -538,7 +537,7 @@ const AuditLog: React.FC = () => { const queryString = new URLSearchParams(params).toString(); const response = await api.get( - `/admin/audit-log/export?${queryString}`, + `/api/admin/audit-log/export?${queryString}`, { responseType: 'blob' } );