This commit is contained in:
Matthias Hochmeister
2026-03-16 14:41:08 +01:00
parent 5f329bb5c1
commit 215528a521
46 changed files with 462 additions and 251 deletions

View File

@@ -93,7 +93,7 @@ class AuthController {
try { try {
await authentikService.verifyIdToken(tokens.id_token); await authentikService.verifyIdToken(tokens.id_token);
} catch (error) { } 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,49 +136,10 @@ class AuthController {
metadata: { new_account: true }, metadata: { new_account: true },
}); });
} else { } else {
// User exists, update last login // User exists — check active status BEFORE any mutations
logger.info('Existing user logging in', {
userId: user.id,
email: user.email,
});
await userService.updateLastLogin(user.id);
await userService.updateGroups(user.id, groups);
await memberService.ensureProfileExists(user.id);
const { given_name: updatedGivenName, family_name: updatedFamilyName } = extractNames(userInfo);
// Refresh profile fields from Authentik on every login
await userService.updateUser(user.id, {
name: userInfo.name,
given_name: updatedGivenName,
family_name: updatedFamilyName,
preferred_username: userInfo.preferred_username,
});
// Audit: returning user login
auditService.logAudit({
user_id: user.id,
user_email: user.email,
action: AuditAction.LOGIN,
resource_type: AuditResourceType.USER,
resource_id: user.id,
old_value: null,
new_value: null,
ip_address: ip,
user_agent: userAgent,
metadata: {},
});
}
// 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) { if (!user.is_active) {
logger.warn('Inactive user attempted login', { userId: user.id }); logger.warn('Inactive user attempted login', { userId: user.id });
// Audit the denied login attempt
auditService.logAudit({ auditService.logAudit({
user_id: user.id, user_id: user.id,
user_email: user.email, user_email: user.email,
@@ -199,6 +160,45 @@ class AuthController {
return; return;
} }
// User is active, proceed with login updates
logger.info('Existing user logging in', {
userId: user.id,
email: user.email,
});
await userService.updateLastLogin(user.id);
await userService.updateGroups(user.id, groups);
await memberService.ensureProfileExists(user.id);
const { given_name: updatedGivenName, family_name: updatedFamilyName } = extractNames(userInfo);
// 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,
profile_picture_url: userInfo.picture || null,
});
// Audit: returning user login
auditService.logAudit({
user_id: user.id,
user_email: user.email,
action: AuditAction.LOGIN,
resource_type: AuditResourceType.USER,
resource_id: user.id,
old_value: null,
new_value: null,
ip_address: ip,
user_agent: userAgent,
metadata: {},
});
}
// Extract normalised names once for use in the response
const { given_name: resolvedGivenName, family_name: resolvedFamilyName } = extractNames(userInfo);
// Step 5: Generate internal JWT token // Step 5: Generate internal JWT token
const role = await getUserRole(user.id); const role = await getUserRole(user.id);
const accessToken = tokenService.generateToken({ const accessToken = tokenService.generateToken({

View File

@@ -252,21 +252,26 @@ class EquipmentController {
// Determine which category to check permissions against // Determine which category to check permissions against
const groups = getUserGroups(req); const groups = getUserGroups(req);
if (!groups.includes('dashboard_admin')) { if (!groups.includes('dashboard_admin')) {
// If kategorie_id is being changed, check against the new category; otherwise fetch existing // Always fetch existing equipment to check old category permission
let kategorieId = parsed.data.kategorie_id;
if (!kategorieId) {
const existing = await equipmentService.getEquipmentById(id); const existing = await equipmentService.getEquipmentById(id);
if (!existing) { if (!existing) {
res.status(404).json({ success: false, message: 'Ausrüstung nicht gefunden' }); res.status(404).json({ success: false, message: 'Ausrüstung nicht gefunden' });
return; return;
} }
kategorieId = existing.kategorie_id; // Check permission against the OLD category (must be allowed to move FROM it)
} const allowedOld = await checkCategoryPermission(existing.kategorie_id, groups);
const allowed = await checkCategoryPermission(kategorieId, groups); if (!allowedOld) {
if (!allowed) {
res.status(403).json({ success: false, message: 'Keine Berechtigung für diese Kategorie' }); res.status(403).json({ success: false, message: 'Keine Berechtigung für diese Kategorie' });
return; 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)); const equipment = await equipmentService.updateEquipment(id, parsed.data, getUserId(req));
if (!equipment) { if (!equipment) {

View File

@@ -161,7 +161,7 @@ class EventsController {
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
getUpcoming = async (req: Request, res: Response): Promise<void> => { getUpcoming = async (req: Request, res: Response): Promise<void> => {
try { 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 userGroups = getUserGroups(req);
const data = await eventsService.getUpcomingEvents(limit, userGroups); const data = await eventsService.getUpcomingEvents(limit, userGroups);
res.json({ success: true, data }); res.json({ success: true, data });

View File

@@ -2,7 +2,7 @@ import { Request, Response } from 'express';
import incidentService from '../services/incident.service'; import incidentService from '../services/incident.service';
import logger from '../utils/logger'; import logger from '../utils/logger';
import { AppError } from '../middleware/error.middleware'; import { AppError } from '../middleware/error.middleware';
import { AppRole, hasPermission } from '../middleware/rbac.middleware'; import { AppRole, hasPermission, resolveRequestRole } from '../middleware/rbac.middleware';
import { import {
CreateEinsatzSchema, CreateEinsatzSchema,
UpdateEinsatzSchema, UpdateEinsatzSchema,
@@ -75,16 +75,22 @@ class IncidentController {
async getIncident(req: AuthenticatedRequest, res: Response): Promise<void> { async getIncident(req: AuthenticatedRequest, res: Response): Promise<void> {
try { try {
const { id } = req.params as Record<string, string>; const { id } = req.params as Record<string, string>;
// 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); const incident = await incidentService.getIncidentById(id);
if (!incident) { if (!incident) {
throw new AppError('Einsatz nicht gefunden', 404); throw new AppError('Einsatz nicht gefunden', 404);
} }
// Role-based redaction: only Kommandant+ can see full bericht_text // Role-based redaction: self-contained role resolution (no middleware dependency)
const canReadBerichtText = const role = resolveRequestRole(req);
req.userRole !== undefined && const canReadBerichtText = hasPermission(role, 'incidents:read_bericht_text');
hasPermission(req.userRole, 'incidents:read_bericht_text');
const responseData = { const responseData = {
...incident, ...incident,

View File

@@ -60,8 +60,8 @@ class MemberController {
search, search,
status: normalizeArray(statusParam) as any, status: normalizeArray(statusParam) as any,
dienstgrad: normalizeArray(dienstgradParam) as any, dienstgrad: normalizeArray(dienstgradParam) as any,
page: page ? parseInt(page, 10) : 1, page: page ? parseInt(page, 10) || 1 : 1,
pageSize: pageSize ? Math.min(parseInt(pageSize, 10), 100) : 25, pageSize: pageSize ? Math.min(parseInt(pageSize, 10) || 25, 100) : 25,
}); });
res.status(200).json({ res.status(200).json({

View File

@@ -1,4 +1,5 @@
import { Request, Response } from 'express'; import { Request, Response } from 'express';
import path from 'path';
import { z } from 'zod'; import { z } from 'zod';
import nextcloudService from '../services/nextcloud.service'; import nextcloudService from '../services/nextcloud.service';
import userService from '../services/user.service'; import userService from '../services/user.service';
@@ -216,13 +217,20 @@ class NextcloudController {
res.status(400).json({ success: false, message: 'Dateipfad fehlt' }); res.status(400).json({ success: false, message: 'Dateipfad fehlt' });
return; 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( const response = await nextcloudService.downloadFile(
filePath, filePath,
credentials.loginName, credentials.loginName,
credentials.appPassword, credentials.appPassword,
); );
const contentType = response.headers['content-type'] ?? 'application/octet-stream'; 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-Type', contentType);
res.setHeader('Content-Disposition', contentDisposition); res.setHeader('Content-Disposition', contentDisposition);
if (response.headers['content-length']) { if (response.headers['content-length']) {

View File

@@ -78,6 +78,17 @@ class SettingsController {
try { try {
const userId = (req as any).user.id; const userId = (req as any).user.id;
const preferences = req.body; 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( await pool.query(
'UPDATE users SET preferences = $1 WHERE id = $2', 'UPDATE users SET preferences = $1 WHERE id = $2',
[JSON.stringify(preferences), userId] [JSON.stringify(preferences), userId]

View File

@@ -69,7 +69,7 @@ const PERMISSION_ROLE_MIN: Record<string, AppRole> = {
function roleFromGroups(groups: string[]): AppRole { function roleFromGroups(groups: string[]): AppRole {
if (groups.includes('dashboard_admin')) return 'admin'; if (groups.includes('dashboard_admin')) return 'admin';
if (groups.includes('dashboard_kommando')) return 'kommandant'; 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'; 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 * Middleware factory: requires the authenticated user to belong to at least

View File

@@ -167,8 +167,6 @@ router.get(
} }
); );
export default router;
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// FDISK Sync proxy — forwards to the fdisk-sync sidecar service // FDISK Sync proxy — forwards to the fdisk-sync sidecar service
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -214,3 +212,5 @@ router.post(
} }
} }
); );
export default router;

View File

@@ -366,20 +366,22 @@ class AuditService {
const escape = (v: unknown): string => { const escape = (v: unknown): string => {
if (v === null || v === undefined) return ''; if (v === null || v === undefined) return '';
const str = typeof v === 'object' ? JSON.stringify(v) : String(v); const str = typeof v === 'object' ? JSON.stringify(v) : String(v);
// RFC 4180: wrap in quotes, double any internal quotes let safe = str.replace(/"/g, '""');
return `"${str.replace(/"/g, '""')}"`; // Prevent formula injection in spreadsheets
if (/^[=+@\-]/.test(safe)) safe = "'" + safe;
return `"${safe}"`;
}; };
const rows = entries.map((e) => const rows = entries.map((e) =>
[ [
e.id, escape(e.id),
e.created_at.toISOString(), escape(e.created_at instanceof Date ? e.created_at.toISOString() : String(e.created_at)),
e.user_id ?? '', escape(e.user_id ?? ''),
e.user_email ?? '', escape(e.user_email ?? ''),
e.action, escape(e.action),
e.resource_type, escape(e.resource_type),
e.resource_id ?? '', escape(e.resource_id ?? ''),
e.ip_address ?? '', escape(e.ip_address ?? ''),
escape(e.user_agent), escape(e.user_agent),
escape(e.old_value), escape(e.old_value),
escape(e.new_value), escape(e.new_value),

View File

@@ -314,17 +314,19 @@ class BookingService {
/** Soft-cancels a booking by setting abgesagt=TRUE and recording the reason. */ /** Soft-cancels a booking by setting abgesagt=TRUE and recording the reason. */
async cancel(id: string, abgesagt_grund: string): Promise<void> { async cancel(id: string, abgesagt_grund: string): Promise<void> {
await pool.query( const result = await pool.query(
`UPDATE fahrzeug_buchungen `UPDATE fahrzeug_buchungen
SET abgesagt = TRUE, abgesagt_grund = $2, aktualisiert_am = NOW() SET abgesagt = TRUE, abgesagt_grund = $2, aktualisiert_am = NOW()
WHERE id = $1`, WHERE id = $1`,
[id, abgesagt_grund] [id, abgesagt_grund]
); );
if (result.rowCount === 0) throw new Error('Buchung nicht gefunden');
} }
/** Permanently deletes a booking record. */ /** Permanently deletes a booking record. */
async delete(id: string): Promise<void> { async delete(id: string): Promise<void> {
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 { rows } = await pool.query(query, params);
const now = toIcalDate(new Date()); 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 const events = rows
.map((row: any) => { .map((row: any) => {
const beschreibung = [row.buchungs_art, row.beschreibung] const beschreibung = [row.buchungs_art, row.beschreibung]
@@ -410,8 +428,8 @@ class BookingService {
`DTSTAMP:${now}\r\n` + `DTSTAMP:${now}\r\n` +
`DTSTART:${toIcalDate(new Date(row.beginn))}\r\n` + `DTSTART:${toIcalDate(new Date(row.beginn))}\r\n` +
`DTEND:${toIcalDate(new Date(row.ende))}\r\n` + `DTEND:${toIcalDate(new Date(row.ende))}\r\n` +
`SUMMARY:${row.titel} - ${row.fahrzeug_name}\r\n` + icalFold(`SUMMARY:${icalEscape(row.titel)} - ${icalEscape(row.fahrzeug_name)}`) + '\r\n' +
`DESCRIPTION:${beschreibung}\r\n` + icalFold(`DESCRIPTION:${icalEscape(beschreibung)}`) + '\r\n' +
'END:VEVENT\r\n' 'END:VEVENT\r\n'
); );
}) })

View File

@@ -158,6 +158,7 @@ async function searchPages(query: string): Promise<BookStackSearchResult[]> {
headers: buildHeaders(), headers: buildHeaders(),
}, },
); );
const bookSlugMap = await getBookSlugMap();
const results: BookStackSearchResult[] = (response.data?.data ?? []) const results: BookStackSearchResult[] = (response.data?.data ?? [])
.filter((item: any) => item.type === 'page') .filter((item: any) => item.type === 'page')
.map((item: any) => ({ .map((item: any) => ({
@@ -166,7 +167,7 @@ async function searchPages(query: string): Promise<BookStackSearchResult[]> {
slug: item.slug, slug: item.slug,
book_id: item.book_id ?? 0, book_id: item.book_id ?? 0,
book_slug: item.book_slug ?? '', 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: '' }, preview_html: item.preview_html ?? { content: '' },
tags: item.tags ?? [], tags: item.tags ?? [],
})); }));

View File

@@ -78,13 +78,23 @@ function formatIcalDate(date: Date): string {
/** Fold long iCal lines at 75 octets (RFC 5545 §3.1) */ /** Fold long iCal lines at 75 octets (RFC 5545 §3.1) */
function icalFold(line: string): string { function icalFold(line: string): string {
if (line.length <= 75) return line; if (Buffer.byteLength(line, 'utf-8') <= 75) return line;
let folded = ''; let folded = '';
while (line.length > 75) { let currentLine = '';
folded += line.slice(0, 75) + '\r\n '; let currentBytes = 0;
line = line.slice(75);
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; return folded;
} }
@@ -241,7 +251,7 @@ class EventsService {
v.alle_gruppen, v.zielgruppen, v.abgesagt, v.anmeldung_erforderlich v.alle_gruppen, v.zielgruppen, v.abgesagt, v.anmeldung_erforderlich
FROM veranstaltungen v FROM veranstaltungen v
LEFT JOIN veranstaltung_kategorien k ON k.id = v.kategorie_id 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 ( AND (
v.alle_gruppen = TRUE v.alle_gruppen = TRUE
OR v.zielgruppen && $3 OR v.zielgruppen && $3
@@ -388,6 +398,7 @@ class EventsService {
const effectiveLimit = limitDate < maxDate ? limitDate : maxDate; const effectiveLimit = limitDate < maxDate ? limitDate : maxDate;
let current = new Date(startDate); let current = new Date(startDate);
const originalDay = startDate.getDate();
while (dates.length < 100) { while (dates.length < 100) {
// Advance to next occurrence // Advance to next occurrence
@@ -400,10 +411,15 @@ class EventsService {
current = new Date(current); current = new Date(current);
current.setDate(current.getDate() + 14); current.setDate(current.getDate() + 14);
break; break;
case 'monatlich_datum': case 'monatlich_datum': {
current = new Date(current); 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; break;
}
case 'monatlich_erster_wochentag': { case 'monatlich_erster_wochentag': {
const targetWeekday = config.wochentag ?? 0; // 0=Mon const targetWeekday = config.wochentag ?? 0; // 0=Mon
current = new Date(current); current = new Date(current);

View File

@@ -177,6 +177,23 @@ class ServiceMonitorService {
} }
async getStatusSummary(): Promise<StatusSummary> { async getStatusSummary(): Promise<StatusSummary> {
// 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(); const results = await this.pingAll();
return { return {
up: results.filter((r) => r.status === 'up').length, up: results.filter((r) => r.status === 'up').length,

View File

@@ -16,6 +16,7 @@ class TokenService {
authentikSub: payload.authentikSub, authentikSub: payload.authentikSub,
groups: payload.groups ?? [], groups: payload.groups ?? [],
role: payload.role, role: payload.role,
type: 'access',
}, },
environment.jwt.secret, environment.jwt.secret,
{ {
@@ -39,7 +40,11 @@ class TokenService {
const decoded = jwt.verify( const decoded = jwt.verify(
token, token,
environment.jwt.secret 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 }); logger.debug('JWT token verified', { userId: decoded.userId });
return decoded; return decoded;
@@ -66,6 +71,7 @@ class TokenService {
{ {
userId: payload.userId, userId: payload.userId,
email: payload.email, email: payload.email,
type: 'refresh',
}, },
environment.jwt.secret, environment.jwt.secret,
{ {
@@ -89,7 +95,11 @@ class TokenService {
const decoded = jwt.verify( const decoded = jwt.verify(
token, token,
environment.jwt.secret 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 }); logger.debug('Refresh token verified', { userId: decoded.userId });
return decoded; return decoded;

View File

@@ -116,8 +116,7 @@ class TrainingService {
FROM uebungen u FROM uebungen u
LEFT JOIN uebung_teilnahmen t ON t.uebung_id = u.id 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` : ''} ${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 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))
AND u.datum_von <= $2
GROUP BY u.id ${userId ? `, own_t.status` : ''} GROUP BY u.id ${userId ? `, own_t.status` : ''}
ORDER BY u.datum_von ASC ORDER BY u.datum_von ASC
`; `;
@@ -510,16 +509,24 @@ function formatIcsDate(date: Date): string {
* Continuation lines start with a single space. * Continuation lines start with a single space.
*/ */
function foldLine(line: string): string { function foldLine(line: string): string {
const MAX = 75; if (Buffer.byteLength(line, 'utf-8') <= 75) return line;
if (line.length <= MAX) return line; let folded = '';
let currentLine = '';
let currentBytes = 0;
let result = ''; for (const char of line) {
while (line.length > MAX) { const charBytes = Buffer.byteLength(char, 'utf-8');
result += line.substring(0, MAX) + '\r\n '; if (currentBytes + charBytes > 75) {
line = line.substring(MAX); 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)}`)); lines.push(foldLine(`SUMMARY:${escapeIcsText(summary)}`));
if (descParts.length > 0) { if (descParts.length > 0) {
lines.push(foldLine(`DESCRIPTION:${escapeIcsText(descParts.join('\\n'))}`)); lines.push(foldLine(`DESCRIPTION:${escapeIcsText(descParts.join('\n'))}`));
} }
if (event.ort) { if (event.ort) {
lines.push(foldLine(`LOCATION:${escapeIcsText(event.ort)}`)); lines.push(foldLine(`LOCATION:${escapeIcsText(event.ort)}`));

View File

@@ -93,7 +93,7 @@ async function getOverdueTasks(): Promise<VikunjaTask[]> {
const tasks = await getMyTasks(); const tasks = await getMyTasks();
const now = new Date(); const now = new Date();
return tasks.filter((t) => { 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; return new Date(t.due_date) < now;
}); });
} }

View File

@@ -18,6 +18,16 @@ import { useQuery } from '@tanstack/react-query';
import { adminApi } from '../../services/admin'; import { adminApi } from '../../services/admin';
import type { UserOverview } from '../../types/admin.types'; 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 SortKey = 'name' | 'email' | 'role' | 'is_active' | 'last_login_at';
type SortDir = 'asc' | 'desc'; type SortDir = 'asc' | 'desc';
@@ -145,9 +155,9 @@ function UserOverviewTab() {
<TableCell>{user.email}</TableCell> <TableCell>{user.email}</TableCell>
<TableCell> <TableCell>
<Chip <Chip
label={user.role} label={getRoleFromGroups(user.groups)}
size="small" size="small"
color={user.role === 'admin' ? 'error' : 'default'} color={getRoleFromGroups(user.groups) === 'Admin' ? 'error' : 'default'}
/> />
</TableCell> </TableCell>
<TableCell> <TableCell>

View File

@@ -24,6 +24,8 @@ const ChatMessageView: React.FC = () => {
const { chatPanelOpen } = useLayout(); const { chatPanelOpen } = useLayout();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const messagesEndRef = useRef<HTMLDivElement>(null); const messagesEndRef = useRef<HTMLDivElement>(null);
const scrollContainerRef = useRef<HTMLDivElement>(null);
const isInitialLoadRef = useRef(true);
const [input, setInput] = useState(''); const [input, setInput] = useState('');
const [messages, setMessages] = useState<NextcloudMessage[]>([]); const [messages, setMessages] = useState<NextcloudMessage[]>([]);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
@@ -63,6 +65,7 @@ const ChatMessageView: React.FC = () => {
setIsLoading(true); setIsLoading(true);
setMessages([]); setMessages([]);
setReactionsMap(new Map()); setReactionsMap(new Map());
isInitialLoadRef.current = true;
lastMsgIdRef.current = 0; lastMsgIdRef.current = 0;
// Step 1: Initial fetch — Nextcloud returns newest-first, so reverse for chronological display // 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(() => { useEffect(() => {
if (selectedRoomToken && chatPanelOpen) { if (selectedRoomToken && chatPanelOpen) {
nextcloudApi.markAsRead(selectedRoomToken).then(() => { nextcloudApi.markAsRead(selectedRoomToken).then(() => {
queryClient.invalidateQueries({ queryKey: ['nextcloud', 'rooms'] }); queryClient.invalidateQueries({ queryKey: ['nextcloud', 'rooms'] });
}).catch(() => {}); }).catch(() => {});
} }
}, [selectedRoomToken, chatPanelOpen, queryClient, messages.length]); }, [selectedRoomToken, chatPanelOpen, queryClient]);
// Smart scroll: instant on initial load, smooth only when user is near bottom
useEffect(() => { 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]); }, [messages]);
const handleSend = () => { const handleSend = () => {
@@ -230,6 +247,7 @@ const ChatMessageView: React.FC = () => {
</Box> </Box>
<Box <Box
ref={scrollContainerRef}
sx={{ sx={{
flex: 1, flex: 1,
overflow: 'auto', overflow: 'auto',

View File

@@ -30,6 +30,7 @@ const ChatPanelInner: React.FC = () => {
const { chatPanelOpen, setChatPanelOpen } = useLayout(); const { chatPanelOpen, setChatPanelOpen } = useLayout();
const { rooms, selectedRoomToken, selectRoom, connected } = useChat(); const { rooms, selectedRoomToken, selectRoom, connected } = useChat();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const markedRoomsRef = React.useRef(new Set<string>());
const { data: externalLinks } = useQuery({ const { data: externalLinks } = useQuery({
queryKey: ['external-links'], queryKey: ['external-links'],
queryFn: () => configApi.getExternalLinks(), queryFn: () => configApi.getExternalLinks(),
@@ -46,11 +47,17 @@ const ChatPanelInner: React.FC = () => {
}).catch(() => {}); }).catch(() => {});
}, [chatPanelOpen, queryClient]); }, [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(() => { React.useEffect(() => {
if (!chatPanelOpen) return; if (!chatPanelOpen) {
const unread = rooms.filter((r) => r.unreadMessages > 0); markedRoomsRef.current.clear();
return;
}
const unread = rooms.filter(
(r) => r.unreadMessages > 0 && !markedRoomsRef.current.has(r.token),
);
if (unread.length === 0) return; if (unread.length === 0) return;
unread.forEach((r) => markedRoomsRef.current.add(r.token));
Promise.allSettled(unread.map((r) => nextcloudApi.markAsRead(r.token))).then(() => { Promise.allSettled(unread.map((r) => nextcloudApi.markAsRead(r.token))).then(() => {
queryClient.invalidateQueries({ queryKey: ['nextcloud', 'rooms'] }); queryClient.invalidateQueries({ queryKey: ['nextcloud', 'rooms'] });
}); });

View File

@@ -1,4 +1,4 @@
import React, { useState } from 'react'; import React, { useState, useEffect } from 'react';
import Dialog from '@mui/material/Dialog'; import Dialog from '@mui/material/Dialog';
import DialogTitle from '@mui/material/DialogTitle'; import DialogTitle from '@mui/material/DialogTitle';
import DialogContent from '@mui/material/DialogContent'; import DialogContent from '@mui/material/DialogContent';
@@ -25,6 +25,14 @@ const NewChatDialog: React.FC<NewChatDialogProps> = ({ open, onClose, onRoomCrea
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [creating, setCreating] = useState(false); const [creating, setCreating] = useState(false);
// Reset state when dialog opens
useEffect(() => {
if (open) {
setSearch('');
setCreating(false);
}
}, [open]);
const { data: users, isLoading } = useQuery({ const { data: users, isLoading } = useQuery({
queryKey: ['nextcloud', 'users', search], queryKey: ['nextcloud', 'users', search],
queryFn: () => nextcloudApi.searchUsers(search), queryFn: () => nextcloudApi.searchUsers(search),

View File

@@ -76,7 +76,7 @@ const BookStackRecentWidget: React.FC = () => {
retry: 1, retry: 1,
}); });
const configured = data?.configured ?? true; const configured = data?.configured ?? false;
const pages = (data?.data ?? []).slice(0, 5); const pages = (data?.data ?? []).slice(0, 5);
if (!configured) { if (!configured) {

View File

@@ -69,6 +69,11 @@ const BookStackSearchWidget: React.FC = () => {
const [searching, setSearching] = useState(false); const [searching, setSearching] = useState(false);
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null); const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const latestQueryRef = useRef<string>(''); const latestQueryRef = useRef<string>('');
const isMountedRef = useRef(true);
useEffect(() => {
return () => { isMountedRef.current = false; };
}, []);
const { data, isLoading: configLoading } = useQuery({ const { data, isLoading: configLoading } = useQuery({
queryKey: ['bookstack-recent'], queryKey: ['bookstack-recent'],
@@ -96,15 +101,15 @@ const BookStackSearchWidget: React.FC = () => {
debounceRef.current = setTimeout(async () => { debounceRef.current = setTimeout(async () => {
try { try {
const response = await bookstackApi.search(thisQuery); const response = await bookstackApi.search(thisQuery);
if (latestQueryRef.current === thisQuery) { if (isMountedRef.current && latestQueryRef.current === thisQuery) {
setResults(response.data); setResults(response.data);
} }
} catch { } catch {
if (latestQueryRef.current === thisQuery) { if (isMountedRef.current && latestQueryRef.current === thisQuery) {
setResults([]); setResults([]);
} }
} finally { } finally {
if (latestQueryRef.current === thisQuery) { if (isMountedRef.current && latestQueryRef.current === thisQuery) {
setSearching(false); setSearching(false);
} }
} }

View File

@@ -25,7 +25,7 @@ function DashboardLayoutInner({ children }: DashboardLayoutProps) {
} }
const sidebarWidth = sidebarCollapsed ? DRAWER_WIDTH_COLLAPSED : DRAWER_WIDTH; const sidebarWidth = sidebarCollapsed ? DRAWER_WIDTH_COLLAPSED : DRAWER_WIDTH;
const chatWidth = chatPanelOpen ? 360 : 60; const chatWidth = chatPanelOpen ? 360 : 64;
return ( return (
<Box sx={{ display: 'flex', height: '100vh', overflow: 'hidden' }}> <Box sx={{ display: 'flex', height: '100vh', overflow: 'hidden' }}>

View File

@@ -128,13 +128,6 @@ const EventQuickAddWidget: React.FC = () => {
<Typography variant="h6">Veranstaltung</Typography> <Typography variant="h6">Veranstaltung</Typography>
</Box> </Box>
{false ? (
<Box>
<Skeleton variant="rectangular" height={40} sx={{ mb: 1.5, borderRadius: 1 }} />
<Skeleton variant="rectangular" height={40} sx={{ mb: 1.5, borderRadius: 1 }} />
<Skeleton variant="rectangular" height={40} sx={{ borderRadius: 1 }} />
</Box>
) : (
<Box component="form" onSubmit={handleSubmit} sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}> <Box component="form" onSubmit={handleSubmit} sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
<TextField <TextField
fullWidth fullWidth
@@ -207,7 +200,6 @@ const EventQuickAddWidget: React.FC = () => {
{mutation.isPending ? 'Wird erstellt…' : 'Erstellen'} {mutation.isPending ? 'Wird erstellt…' : 'Erstellen'}
</Button> </Button>
</Box> </Box>
)}
</CardContent> </CardContent>
</Card> </Card>
); );

View File

@@ -75,6 +75,7 @@ const VehicleBookingQuickAddWidget: React.FC = () => {
setEnde(fresh.ende); setEnde(fresh.ende);
setBeschreibung(''); setBeschreibung('');
queryClient.invalidateQueries({ queryKey: ['bookings'] }); queryClient.invalidateQueries({ queryKey: ['bookings'] });
queryClient.invalidateQueries({ queryKey: ['upcoming-vehicle-bookings'] });
}, },
onError: () => { onError: () => {
showError('Fahrzeugbuchung konnte nicht erstellt werden'); showError('Fahrzeugbuchung konnte nicht erstellt werden');

View File

@@ -76,8 +76,13 @@ const CreateEinsatzDialog: React.FC<CreateEinsatzDialogProps> = ({
try { try {
// Convert local datetime string to UTC ISO string // Convert local datetime string to UTC ISO string
const isoLocal = fromGermanDateTime(form.alarm_time_local); 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 = { 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_art: form.einsatz_art,
einsatz_stichwort: form.einsatz_stichwort || null, einsatz_stichwort: form.einsatz_stichwort || null,
strasse: form.strasse || null, strasse: form.strasse || null,

View File

@@ -91,7 +91,7 @@ class ErrorBoundary extends Component<Props, State> {
<Box <Box
sx={{ sx={{
width: '100%', width: '100%',
bgcolor: 'grey.100', bgcolor: 'action.hover',
p: 2, p: 2,
borderRadius: 1, borderRadius: 1,
mb: 3, mb: 3,

View File

@@ -26,9 +26,15 @@ import type { Notification, NotificationSchwere } from '../../types/notification
const POLL_INTERVAL_MS = 15_000; // 15 seconds const POLL_INTERVAL_MS = 15_000; // 15 seconds
let sharedAudioCtx: AudioContext | null = null;
function playNotificationSound() { function playNotificationSound() {
try { try {
const ctx = new AudioContext(); if (!sharedAudioCtx || sharedAudioCtx.state === 'closed') {
sharedAudioCtx = new AudioContext();
}
const ctx = sharedAudioCtx;
if (ctx.state === 'suspended') ctx.resume();
const oscillator = ctx.createOscillator(); const oscillator = ctx.createOscillator();
const gain = ctx.createGain(); const gain = ctx.createGain();
oscillator.connect(gain); oscillator.connect(gain);
@@ -41,7 +47,6 @@ function playNotificationSound() {
gain.gain.linearRampToValueAtTime(0, now + 0.15); gain.gain.linearRampToValueAtTime(0, now + 0.15);
oscillator.start(now); oscillator.start(now);
oscillator.stop(now + 0.15); oscillator.stop(now + 0.15);
oscillator.onended = () => ctx.close();
} catch { } catch {
// Audio blocked before first user interaction — fail silently // Audio blocked before first user interaction — fail silently
} }
@@ -113,7 +118,9 @@ const NotificationBell: React.FC = () => {
showNotificationToast(n.titel, severity); showNotificationToast(n.titel, severity);
}); });
// Also add all known IDs to avoid re-toasting on re-fetch // 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 { } catch {
// non-critical // non-critical
} }

View File

@@ -11,13 +11,16 @@ export default function ServiceModeGuard({ children }: Props) {
const { user } = useAuth(); const { user } = useAuth();
const isAdmin = user?.groups?.includes('dashboard_admin') ?? false; const isAdmin = user?.groups?.includes('dashboard_admin') ?? false;
const { data: serviceMode } = useQuery({ const { data: serviceMode, isLoading } = useQuery({
queryKey: ['service-mode'], queryKey: ['service-mode'],
queryFn: configApi.getServiceMode, queryFn: configApi.getServiceMode,
refetchInterval: 60_000, refetchInterval: 60_000,
retry: false, retry: false,
}); });
// Don't render children until we know the service mode status
if (isLoading) return null;
if (serviceMode?.active && !isAdmin) { if (serviceMode?.active && !isAdmin) {
return <ServiceModePage message={serviceMode.message} />; return <ServiceModePage message={serviceMode.message} />;
} }

View File

@@ -139,10 +139,13 @@ export const AuthProvider: React.FC<AuthProviderProps> = ({ children }) => {
const user = await authService.getCurrentUser(); const user = await authService.getCurrentUser();
setUser(user); setUser(user);
setState((prev) => ({ ...prev, user })); setState((prev) => ({ ...prev, user }));
} catch (error) { } catch (error: any) {
console.error('Failed to refresh user data:', error); console.error('Failed to refresh user data:', error);
// Only logout on explicit 401 — network errors / 5xx should not destroy the session
if (error?.response?.status === 401) {
logout(); logout();
} }
}
}, [logout]); }, [logout]);
const value: AuthContextType = { const value: AuthContextType = {

View File

@@ -56,19 +56,43 @@ export const ChatProvider: React.FC<ChatProviderProps> = ({ children }) => {
const connected = data?.connected ?? false; const connected = data?.connected ?? false;
const loginName = data?.loginName ?? null; const loginName = data?.loginName ?? null;
const prevUnreadRef = useRef<Map<string, number>>(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 // Detect new unread messages while panel is closed and show toast
useEffect(() => { useEffect(() => {
if (!rooms.length) return; if (!rooms.length) return;
const prev = prevUnreadRef.current; 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) { for (const room of rooms) {
const prevCount = prev.get(room.token) ?? 0; const prevCount = prev.get(room.token) ?? 0;
if (!isFirstLoad && !chatPanelOpen && room.unreadMessages > prevCount) { if (!chatPanelOpen && room.unreadMessages > prevCount) {
showNotificationToast(room.displayName, 'info'); showNotificationToast(room.displayName, 'info');
} }
prev.set(room.token, room.unreadMessages); 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]); }, [rooms, chatPanelOpen, showNotificationToast]);
const selectRoom = useCallback((token: string | null) => { const selectRoom = useCallback((token: string | null) => {

View File

@@ -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'; import { Snackbar, Alert, AlertColor } from '@mui/material';
interface Notification { interface Notification {
@@ -22,8 +22,8 @@ interface NotificationProviderProps {
} }
export const NotificationProvider: React.FC<NotificationProviderProps> = ({ children }) => { export const NotificationProvider: React.FC<NotificationProviderProps> = ({ children }) => {
const [_notifications, setNotifications] = useState<Notification[]>([]);
const [currentNotification, setCurrentNotification] = useState<Notification | null>(null); const [currentNotification, setCurrentNotification] = useState<Notification | null>(null);
const queueRef = useRef<Notification[]>([]);
// Left-side toast queue for new backend notifications // Left-side toast queue for new backend notifications
const [toastQueue, setToastQueue] = useState<Notification[]>([]); const [toastQueue, setToastQueue] = useState<Notification[]>([]);
@@ -32,13 +32,15 @@ export const NotificationProvider: React.FC<NotificationProviderProps> = ({ chil
const id = Date.now(); const id = Date.now();
const notification: Notification = { id, message, severity }; const notification: Notification = { id, message, severity };
setNotifications((prev) => [...prev, notification]); // Use functional update to avoid stale closure over currentNotification
setCurrentNotification((prev) => {
// If no notification is currently displayed, show this one immediately if (prev) {
if (!currentNotification) { queueRef.current.push(notification);
setCurrentNotification(notification); return prev;
} }
}, [currentNotification]); return notification;
});
}, []);
const showSuccess = useCallback((message: string) => { const showSuccess = useCallback((message: string) => {
addNotification(message, 'success'); addNotification(message, 'success');
@@ -68,15 +70,12 @@ export const NotificationProvider: React.FC<NotificationProviderProps> = ({ chil
setCurrentNotification(null); setCurrentNotification(null);
// Show next notification after a short delay // Show next queued notification after a short delay
setTimeout(() => { setTimeout(() => {
setNotifications((prev) => { const next = queueRef.current.shift();
const remaining = prev.filter((n) => n.id !== currentNotification?.id); if (next) {
if (remaining.length > 0) { setCurrentNotification(next);
setCurrentNotification(remaining[0]);
} }
return remaining;
});
}, 200); }, 200);
}; };

View File

@@ -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 { ThemeProvider } from '@mui/material/styles';
import { CssBaseline } from '@mui/material'; import { CssBaseline } from '@mui/material';
import { lightTheme, darkTheme } from '../theme/theme'; import { lightTheme, darkTheme } from '../theme/theme';
@@ -50,10 +50,10 @@ export const ThemeModeProvider: React.FC<{ children: React.ReactNode }> = ({ chi
return () => mq.removeEventListener('change', handler); return () => mq.removeEventListener('change', handler);
}, []); }, []);
const setThemeMode = (mode: ThemeMode) => { const setThemeMode = useCallback((mode: ThemeMode) => {
setThemeModeState(mode); setThemeModeState(mode);
localStorage.setItem(STORAGE_KEY, mode); localStorage.setItem(STORAGE_KEY, mode);
}; }, []);
const resolvedMode: 'light' | 'dark' = const resolvedMode: 'light' | 'dark' =
themeMode === 'system' ? systemPreference : themeMode; themeMode === 'system' ? systemPreference : themeMode;

View File

@@ -41,6 +41,7 @@ import {
Search, Search,
} from '@mui/icons-material'; } from '@mui/icons-material';
import DashboardLayout from '../components/dashboard/DashboardLayout'; import DashboardLayout from '../components/dashboard/DashboardLayout';
import ChatAwareFab from '../components/shared/ChatAwareFab';
import { atemschutzApi } from '../services/atemschutz'; import { atemschutzApi } from '../services/atemschutz';
import { membersService } from '../services/members'; import { membersService } from '../services/members';
import { useNotification } from '../contexts/NotificationContext'; import { useNotification } from '../contexts/NotificationContext';
@@ -314,11 +315,11 @@ function Atemschutz() {
lehrgang_datum: normalizeDate(form.lehrgang_datum || undefined), lehrgang_datum: normalizeDate(form.lehrgang_datum || undefined),
untersuchung_datum: normalizeDate(form.untersuchung_datum || undefined), untersuchung_datum: normalizeDate(form.untersuchung_datum || undefined),
untersuchung_gueltig_bis: normalizeDate(form.untersuchung_gueltig_bis || 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_datum: normalizeDate(form.leistungstest_datum || undefined),
leistungstest_gueltig_bis: normalizeDate(form.leistungstest_gueltig_bis || undefined), leistungstest_gueltig_bis: normalizeDate(form.leistungstest_gueltig_bis || undefined),
leistungstest_bestanden: form.leistungstest_bestanden, leistungstest_bestanden: form.leistungstest_bestanden,
bemerkung: form.bemerkung || undefined, bemerkung: form.bemerkung || null,
}; };
await atemschutzApi.update(editingId, payload); await atemschutzApi.update(editingId, payload);
notification.showSuccess('Atemschutzträger erfolgreich aktualisiert.'); notification.showSuccess('Atemschutzträger erfolgreich aktualisiert.');
@@ -594,14 +595,13 @@ function Atemschutz() {
{/* FAB to create */} {/* FAB to create */}
{canWrite && ( {canWrite && (
<Fab <ChatAwareFab
color="primary" color="primary"
aria-label="Atemschutzträger hinzufügen" aria-label="Atemschutzträger hinzufügen"
sx={{ position: 'fixed', bottom: 32, right: 32 }}
onClick={handleOpenCreate} onClick={handleOpenCreate}
> >
<Add /> <Add />
</Fab> </ChatAwareFab>
)} )}
{/* ── Add / Edit Dialog ───────────────────────────────────────────── */} {/* ── Add / Edit Dialog ───────────────────────────────────────────── */}

View File

@@ -45,6 +45,7 @@ import {
EquipmentStats, EquipmentStats,
} from '../types/equipment.types'; } from '../types/equipment.types';
import { usePermissions } from '../hooks/usePermissions'; import { usePermissions } from '../hooks/usePermissions';
import ChatAwareFab from '../components/shared/ChatAwareFab';
// ── Status chip config ──────────────────────────────────────────────────────── // ── Status chip config ────────────────────────────────────────────────────────
@@ -464,14 +465,13 @@ function Ausruestung() {
{/* FAB for adding new equipment */} {/* FAB for adding new equipment */}
{canManageEquipment && ( {canManageEquipment && (
<Fab <ChatAwareFab
color="primary" color="primary"
aria-label="Ausrüstung hinzufügen" aria-label="Ausrüstung hinzufügen"
sx={{ position: 'fixed', bottom: 32, right: 32 }}
onClick={() => navigate('/ausruestung/neu')} onClick={() => navigate('/ausruestung/neu')}
> >
<Add /> <Add />
</Fab> </ChatAwareFab>
)} )}
</Container> </Container>
</DashboardLayout> </DashboardLayout>

View File

@@ -85,27 +85,6 @@ function AusruestungForm() {
const { canManageEquipment } = usePermissions(); const { canManageEquipment } = usePermissions();
const isEditMode = Boolean(id); const isEditMode = Boolean(id);
// -- Permission guard: only authorized users may create or edit equipment ----
if (!canManageEquipment) {
return (
<DashboardLayout>
<Container maxWidth="lg">
<Box sx={{ textAlign: 'center', py: 8 }}>
<Typography variant="h5" gutterBottom>
Keine Berechtigung
</Typography>
<Typography variant="body1" color="text.secondary" sx={{ mb: 3 }}>
Sie haben nicht die erforderlichen Rechte, um Ausrüstung zu bearbeiten.
</Typography>
<Button variant="contained" onClick={() => navigate('/ausruestung')}>
Zurück zur Ausrüstungsübersicht
</Button>
</Box>
</Container>
</DashboardLayout>
);
}
const [form, setForm] = useState<FormState>(EMPTY_FORM); const [form, setForm] = useState<FormState>(EMPTY_FORM);
const [loading, setLoading] = useState(isEditMode); const [loading, setLoading] = useState(isEditMode);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
@@ -168,6 +147,27 @@ function AusruestungForm() {
if (isEditMode) fetchEquipment(); if (isEditMode) fetchEquipment();
}, [isEditMode, fetchEquipment]); }, [isEditMode, fetchEquipment]);
// -- Permission guard: only authorized users may create or edit equipment ----
if (!canManageEquipment) {
return (
<DashboardLayout>
<Container maxWidth="lg">
<Box sx={{ textAlign: 'center', py: 8 }}>
<Typography variant="h5" gutterBottom>
Keine Berechtigung
</Typography>
<Typography variant="body1" color="text.secondary" sx={{ mb: 3 }}>
Sie haben nicht die erforderlichen Rechte, um Ausrüstung zu bearbeiten.
</Typography>
<Button variant="contained" onClick={() => navigate('/ausruestung')}>
Zurück zur Ausrüstungsübersicht
</Button>
</Box>
</Container>
</DashboardLayout>
);
}
// -- Validation ------------------------------------------------------------- // -- Validation -------------------------------------------------------------
const validate = (): boolean => { const validate = (): boolean => {
@@ -213,19 +213,19 @@ function AusruestungForm() {
const payload: UpdateAusruestungPayload = { const payload: UpdateAusruestungPayload = {
bezeichnung: form.bezeichnung.trim() || undefined, bezeichnung: form.bezeichnung.trim() || undefined,
kategorie_id: form.kategorie_id || undefined, kategorie_id: form.kategorie_id || undefined,
seriennummer: form.seriennummer.trim() || undefined, seriennummer: form.seriennummer.trim() || null,
inventarnummer: form.inventarnummer.trim() || undefined, inventarnummer: form.inventarnummer.trim() || null,
hersteller: form.hersteller.trim() || undefined, hersteller: form.hersteller.trim() || null,
baujahr: form.baujahr ? parseInt(form.baujahr, 10) : undefined, baujahr: form.baujahr ? parseInt(form.baujahr, 10) : null,
status: form.status, status: form.status,
status_bemerkung: form.status_bemerkung.trim() || undefined, status_bemerkung: form.status_bemerkung.trim() || null,
ist_wichtig: form.ist_wichtig, ist_wichtig: form.ist_wichtig,
fahrzeug_id: form.fahrzeug_id || null, fahrzeug_id: form.fahrzeug_id || null,
standort: !form.fahrzeug_id ? (form.standort.trim() || 'Lager') : undefined, standort: !form.fahrzeug_id ? (form.standort.trim() || 'Lager') : undefined,
pruef_intervall_monate: form.pruef_intervall_monate ? parseInt(form.pruef_intervall_monate, 10) : 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) || undefined : undefined, 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) || undefined : undefined, naechste_pruefung_am: form.naechste_pruefung_am ? fromGermanDate(form.naechste_pruefung_am) || null : null,
bemerkung: form.bemerkung.trim() || undefined, bemerkung: form.bemerkung.trim() || null,
}; };
await equipmentApi.update(id, payload); await equipmentApi.update(id, payload);
navigate(`/ausruestung/${id}`); navigate(`/ausruestung/${id}`);

View File

@@ -70,6 +70,8 @@ function Dashboard() {
return ( return (
<DashboardLayout> <DashboardLayout>
{/* Vikunja — Overdue Notifier (invisible, polling component — outside grid) */}
<VikunjaOverdueNotifier />
<Container maxWidth={false} disableGutters> <Container maxWidth={false} disableGutters>
<Box <Box
sx={{ sx={{
@@ -97,9 +99,6 @@ function Dashboard() {
</Box> </Box>
)} )}
{/* Vikunja — Overdue Notifier (invisible, polling component) */}
<VikunjaOverdueNotifier />
{/* Status Group */} {/* Status Group */}
<WidgetGroup title="Status" gridColumn="1 / -1"> <WidgetGroup title="Status" gridColumn="1 / -1">
{widgetVisible('vehicles') && ( {widgetVisible('vehicles') && (

View File

@@ -49,6 +49,7 @@ import {
EINSATZ_STATUS_LABELS, EINSATZ_STATUS_LABELS,
} from '../services/incidents'; } from '../services/incidents';
import CreateEinsatzDialog from '../components/incidents/CreateEinsatzDialog'; import CreateEinsatzDialog from '../components/incidents/CreateEinsatzDialog';
import { useAuth } from '../contexts/AuthContext';
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// COLOUR MAP for Einsatzart chips // COLOUR MAP for Einsatzart chips
@@ -175,6 +176,10 @@ function StatsSummaryBar({ stats, loading }: StatsSummaryProps) {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
function Einsaetze() { function Einsaetze() {
const navigate = useNavigate(); const navigate = useNavigate();
const { user } = useAuth();
const canWrite = user?.groups?.some((g: string) =>
['dashboard_admin', 'dashboard_kommando', 'dashboard_gruppenfuehrer'].includes(g)
) ?? false;
// List state // List state
const [items, setItems] = useState<EinsatzListItem[]>([]); const [items, setItems] = useState<EinsatzListItem[]>([]);
@@ -220,7 +225,7 @@ function Einsaetze() {
filters.dateTo = end.toISOString(); 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<typeof incidentsApi.getAll>[0]); const result = await incidentsApi.getAll(filters as Parameters<typeof incidentsApi.getAll>[0]);
setItems(result.items); setItems(result.items);
@@ -308,6 +313,7 @@ function Einsaetze() {
<Refresh /> <Refresh />
</IconButton> </IconButton>
</Tooltip> </Tooltip>
{canWrite && (
<Button <Button
variant="contained" variant="contained"
color="primary" color="primary"
@@ -316,6 +322,7 @@ function Einsaetze() {
> >
Neuer Einsatz Neuer Einsatz
</Button> </Button>
)}
</Stack> </Stack>
</Box> </Box>

View File

@@ -43,6 +43,7 @@ import {
EinsatzArt, EinsatzArt,
} from '../services/incidents'; } from '../services/incidents';
import { useNotification } from '../contexts/NotificationContext'; import { useNotification } from '../contexts/NotificationContext';
import { useAuth } from '../contexts/AuthContext';
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// COLOUR MAPS // COLOUR MAPS
@@ -164,6 +165,10 @@ function EinsatzDetail() {
const { id } = useParams<{ id: string }>(); const { id } = useParams<{ id: string }>();
const navigate = useNavigate(); const navigate = useNavigate();
const notification = useNotification(); 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<EinsatzDetailType | null>(null); const [einsatz, setEinsatz] = useState<EinsatzDetailType | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@@ -297,7 +302,7 @@ function EinsatzDetail() {
PDF Export PDF Export
</Button> </Button>
</Tooltip> </Tooltip>
{!editing ? ( {canWrite && !editing ? (
<Button <Button
variant="contained" variant="contained"
startIcon={<Edit />} startIcon={<Edit />}
@@ -306,7 +311,7 @@ function EinsatzDetail() {
> >
Bearbeiten Bearbeiten
</Button> </Button>
) : ( ) : canWrite && editing ? (
<> <>
<Button <Button
variant="outlined" variant="outlined"
@@ -328,7 +333,7 @@ function EinsatzDetail() {
{saving ? 'Speichere...' : 'Speichern'} {saving ? 'Speichere...' : 'Speichern'}
</Button> </Button>
</> </>
)} ) : null}
</Stack> </Stack>
</Box> </Box>

View File

@@ -43,6 +43,7 @@ import {
Block, Block,
} from '@mui/icons-material'; } from '@mui/icons-material';
import DashboardLayout from '../components/dashboard/DashboardLayout'; import DashboardLayout from '../components/dashboard/DashboardLayout';
import ChatAwareFab from '../components/shared/ChatAwareFab';
import { useAuth } from '../contexts/AuthContext'; import { useAuth } from '../contexts/AuthContext';
import { useNotification } from '../contexts/NotificationContext'; import { useNotification } from '../contexts/NotificationContext';
import { bookingApi, fetchVehicles } from '../services/bookings'; import { bookingApi, fetchVehicles } from '../services/bookings';
@@ -593,14 +594,13 @@ function FahrzeugBuchungen() {
{/* ── FAB ── */} {/* ── FAB ── */}
{canCreate && ( {canCreate && (
<Fab <ChatAwareFab
color="primary" color="primary"
aria-label="Buchung erstellen" aria-label="Buchung erstellen"
sx={{ position: 'fixed', bottom: 32, right: 32 }}
onClick={openCreateDialog} onClick={openCreateDialog}
> >
<Add /> <Add />
</Fab> </ChatAwareFab>
)} )}
{/* ── Booking detail popover ── */} {/* ── Booking detail popover ── */}

View File

@@ -80,27 +80,6 @@ function FahrzeugForm() {
const { isAdmin } = usePermissions(); const { isAdmin } = usePermissions();
const isEditMode = Boolean(id); const isEditMode = Boolean(id);
// ── Permission guard: only admins may create or edit vehicles ──────────────
if (!isAdmin) {
return (
<DashboardLayout>
<Container maxWidth="lg">
<Box sx={{ textAlign: 'center', py: 8 }}>
<Typography variant="h5" gutterBottom>
Keine Berechtigung
</Typography>
<Typography variant="body1" color="text.secondary" sx={{ mb: 3 }}>
Sie haben nicht die erforderlichen Rechte, um Fahrzeuge zu bearbeiten.
</Typography>
<Button variant="contained" onClick={() => navigate('/fahrzeuge')}>
Zurück zur Fahrzeugübersicht
</Button>
</Box>
</Container>
</DashboardLayout>
);
}
const [form, setForm] = useState<FormState>(EMPTY_FORM); const [form, setForm] = useState<FormState>(EMPTY_FORM);
const [loading, setLoading] = useState(isEditMode); const [loading, setLoading] = useState(isEditMode);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
@@ -141,6 +120,27 @@ function FahrzeugForm() {
if (isEditMode) fetchVehicle(); if (isEditMode) fetchVehicle();
}, [isEditMode, fetchVehicle]); }, [isEditMode, fetchVehicle]);
// ── Permission guard: only admins may create or edit vehicles ──────────────
if (!isAdmin) {
return (
<DashboardLayout>
<Container maxWidth="lg">
<Box sx={{ textAlign: 'center', py: 8 }}>
<Typography variant="h5" gutterBottom>
Keine Berechtigung
</Typography>
<Typography variant="body1" color="text.secondary" sx={{ mb: 3 }}>
Sie haben nicht die erforderlichen Rechte, um Fahrzeuge zu bearbeiten.
</Typography>
<Button variant="contained" onClick={() => navigate('/fahrzeuge')}>
Zurück zur Fahrzeugübersicht
</Button>
</Box>
</Container>
</DashboardLayout>
);
}
const validate = (): boolean => { const validate = (): boolean => {
const errors: Partial<Record<keyof FormState, string>> = {}; const errors: Partial<Record<keyof FormState, string>> = {};
if (!form.bezeichnung.trim()) { if (!form.bezeichnung.trim()) {
@@ -160,19 +160,19 @@ function FahrzeugForm() {
if (isEditMode && id) { if (isEditMode && id) {
const payload: UpdateFahrzeugPayload = { const payload: UpdateFahrzeugPayload = {
bezeichnung: form.bezeichnung.trim() || undefined, bezeichnung: form.bezeichnung.trim() || undefined,
kurzname: form.kurzname.trim() || undefined, kurzname: form.kurzname.trim() || null,
amtliches_kennzeichen: form.amtliches_kennzeichen.trim() || undefined, amtliches_kennzeichen: form.amtliches_kennzeichen.trim() || null,
fahrgestellnummer: form.fahrgestellnummer.trim() || undefined, fahrgestellnummer: form.fahrgestellnummer.trim() || null,
baujahr: form.baujahr ? Number(form.baujahr) : undefined, baujahr: form.baujahr ? Number(form.baujahr) : null,
hersteller: form.hersteller.trim() || undefined, hersteller: form.hersteller.trim() || null,
typ_schluessel: form.typ_schluessel.trim() || undefined, typ_schluessel: form.typ_schluessel.trim() || null,
besatzung_soll: form.besatzung_soll.trim() || undefined, besatzung_soll: form.besatzung_soll.trim() || null,
status: form.status, status: form.status,
status_bemerkung: form.status_bemerkung.trim() || undefined, status_bemerkung: form.status_bemerkung.trim() || null,
standort: form.standort.trim() || 'Feuerwehrhaus', standort: form.standort.trim() || 'Feuerwehrhaus',
bild_url: form.bild_url.trim() || undefined, bild_url: form.bild_url.trim() || null,
paragraph57a_faellig_am: form.paragraph57a_faellig_am || undefined, paragraph57a_faellig_am: form.paragraph57a_faellig_am || null,
naechste_wartung_am: form.naechste_wartung_am || undefined, naechste_wartung_am: form.naechste_wartung_am || null,
}; };
await vehiclesApi.update(id, payload); await vehiclesApi.update(id, payload);
navigate(`/fahrzeuge/${id}`); navigate(`/fahrzeuge/${id}`);

View File

@@ -135,8 +135,13 @@ function Mitglieder() {
fetchMembers(); fetchMembers();
}, [fetchMembers, debouncedSearch]); }, [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(() => { useEffect(() => {
if (isInitialMount.current) {
isInitialMount.current = false;
return;
}
fetchMembers(); fetchMembers();
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [page, selectedStatus, selectedDienstgrad]); }, [page, selectedStatus, selectedDienstgrad]);

View File

@@ -130,8 +130,8 @@ function Settings() {
const result = await nextcloudApi.poll(pollToken, pollEndpoint); const result = await nextcloudApi.poll(pollToken, pollEndpoint);
if (result.completed) { if (result.completed) {
stopPolling(); stopPolling();
queryClient.invalidateQueries({ queryKey: ['nextcloud-talk'] }); queryClient.invalidateQueries({ queryKey: ['nextcloud', 'connection'] });
queryClient.invalidateQueries({ queryKey: ['nextcloud-talk-rooms'] }); queryClient.invalidateQueries({ queryKey: ['nextcloud', 'rooms'] });
} }
} catch { } catch {
// Polling error — keep trying until timeout // Polling error — keep trying until timeout

View File

@@ -1101,7 +1101,8 @@ export default function Veranstaltungen() {
}; };
const handleToday = () => { const handleToday = () => {
setViewMonth({ year: today.getFullYear(), month: today.getMonth() }); const now = new Date();
setViewMonth({ year: now.getFullYear(), month: now.getMonth() });
}; };
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------

View File

@@ -457,30 +457,29 @@ const AuditLog: React.FC = () => {
setLoading(true); setLoading(true);
setError(null); setError(null);
try { try {
const params: Record<string, string> = { const params = new URLSearchParams();
page: String(pagination.page + 1), // convert 0-based to 1-based params.set('page', String(pagination.page + 1));
pageSize: String(pagination.pageSize), params.set('pageSize', String(pagination.pageSize));
};
if (f.dateFrom) { if (f.dateFrom) {
const iso = fromGermanDate(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) { if (f.dateTo) {
const iso = fromGermanDate(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) { if (f.action && f.action.length > 0) {
params.action = f.action.join(','); f.action.forEach((a) => params.append('action', a));
} }
if (f.resourceType && f.resourceType.length > 0) { 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 }>( const response = await api.get<{ success: boolean; data: AuditLogPage }>(
`/admin/audit-log?${queryString}` `/api/admin/audit-log?${queryString}`
); );
setRows(response.data.data.entries); setRows(response.data.data.entries);
@@ -538,7 +537,7 @@ const AuditLog: React.FC = () => {
const queryString = new URLSearchParams(params).toString(); const queryString = new URLSearchParams(params).toString();
const response = await api.get<Blob>( const response = await api.get<Blob>(
`/admin/audit-log/export?${queryString}`, `/api/admin/audit-log/export?${queryString}`,
{ responseType: 'blob' } { responseType: 'blob' }
); );