update
This commit is contained in:
@@ -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,7 +136,31 @@ 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
|
||||||
|
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', {
|
logger.info('Existing user logging in', {
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
@@ -148,12 +172,13 @@ class AuthController {
|
|||||||
|
|
||||||
const { given_name: updatedGivenName, family_name: updatedFamilyName } = extractNames(userInfo);
|
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, {
|
await userService.updateUser(user.id, {
|
||||||
name: userInfo.name,
|
name: userInfo.name,
|
||||||
given_name: updatedGivenName,
|
given_name: updatedGivenName,
|
||||||
family_name: updatedFamilyName,
|
family_name: updatedFamilyName,
|
||||||
preferred_username: userInfo.preferred_username,
|
preferred_username: userInfo.preferred_username,
|
||||||
|
profile_picture_url: userInfo.picture || null,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Audit: returning user login
|
// Audit: returning user login
|
||||||
@@ -174,31 +199,6 @@ class AuthController {
|
|||||||
// Extract normalised names once for use in the response
|
// Extract normalised names once for use in the response
|
||||||
const { given_name: resolvedGivenName, family_name: resolvedFamilyName } = extractNames(userInfo);
|
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
|
// 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({
|
||||||
|
|||||||
@@ -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;
|
const existing = await equipmentService.getEquipmentById(id);
|
||||||
if (!kategorieId) {
|
if (!existing) {
|
||||||
const existing = await equipmentService.getEquipmentById(id);
|
res.status(404).json({ success: false, message: 'Ausrüstung nicht gefunden' });
|
||||||
if (!existing) {
|
return;
|
||||||
res.status(404).json({ success: false, message: 'Ausrüstung nicht gefunden' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
kategorieId = existing.kategorie_id;
|
|
||||||
}
|
}
|
||||||
const allowed = await checkCategoryPermission(kategorieId, groups);
|
// Check permission against the OLD category (must be allowed to move FROM it)
|
||||||
if (!allowed) {
|
const allowedOld = await checkCategoryPermission(existing.kategorie_id, groups);
|
||||||
|
if (!allowedOld) {
|
||||||
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) {
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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']) {
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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'
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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 ?? [],
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
folded += currentLine;
|
||||||
return result;
|
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)}`));
|
||||||
|
|||||||
@@ -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;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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'] });
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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' }}>
|
||||||
|
|||||||
@@ -128,14 +128,7 @@ const EventQuickAddWidget: React.FC = () => {
|
|||||||
<Typography variant="h6">Veranstaltung</Typography>
|
<Typography variant="h6">Veranstaltung</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{false ? (
|
<Box component="form" onSubmit={handleSubmit} sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
|
||||||
<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 }}>
|
|
||||||
<TextField
|
<TextField
|
||||||
fullWidth
|
fullWidth
|
||||||
size="small"
|
size="small"
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -139,9 +139,12 @@ 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);
|
||||||
logout();
|
// Only logout on explicit 401 — network errors / 5xx should not destroy the session
|
||||||
|
if (error?.response?.status === 401) {
|
||||||
|
logout();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, [logout]);
|
}, [logout]);
|
||||||
|
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 ───────────────────────────────────────────── */}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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}`);
|
||||||
|
|||||||
@@ -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') && (
|
||||||
|
|||||||
@@ -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,14 +313,16 @@ function Einsaetze() {
|
|||||||
<Refresh />
|
<Refresh />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Button
|
{canWrite && (
|
||||||
variant="contained"
|
<Button
|
||||||
color="primary"
|
variant="contained"
|
||||||
startIcon={<AddIcon />}
|
color="primary"
|
||||||
onClick={() => setCreateOpen(true)}
|
startIcon={<AddIcon />}
|
||||||
>
|
onClick={() => setCreateOpen(true)}
|
||||||
Neuer Einsatz
|
>
|
||||||
</Button>
|
Neuer Einsatz
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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 ── */}
|
||||||
|
|||||||
@@ -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}`);
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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() });
|
||||||
};
|
};
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -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' }
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user