diff --git a/backend/src/app.ts b/backend/src/app.ts index fca7325..cbdfeeb 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -97,6 +97,7 @@ import configRoutes from './routes/config.routes'; import serviceMonitorRoutes from './routes/serviceMonitor.routes'; import settingsRoutes from './routes/settings.routes'; import bannerRoutes from './routes/banner.routes'; +import permissionRoutes from './routes/permission.routes'; app.use('/api/auth', authRoutes); app.use('/api/user', userRoutes); @@ -118,6 +119,7 @@ app.use('/api/admin', serviceMonitorRoutes); app.use('/api/admin/settings', settingsRoutes); app.use('/api/settings', settingsRoutes); app.use('/api/banners', bannerRoutes); +app.use('/api/permissions', permissionRoutes); // 404 handler app.use(notFoundHandler); diff --git a/backend/src/controllers/incident.controller.ts b/backend/src/controllers/incident.controller.ts index 2d19fd3..36bf06a 100644 --- a/backend/src/controllers/incident.controller.ts +++ b/backend/src/controllers/incident.controller.ts @@ -2,7 +2,8 @@ import { Request, Response } from 'express'; import incidentService from '../services/incident.service'; import logger from '../utils/logger'; import { AppError } from '../middleware/error.middleware'; -import { AppRole, hasPermission, resolveRequestRole } from '../middleware/rbac.middleware'; +import { AppRole } from '../middleware/rbac.middleware'; +import { permissionService } from '../services/permission.service'; import { CreateEinsatzSchema, UpdateEinsatzSchema, @@ -88,9 +89,11 @@ class IncidentController { throw new AppError('Einsatz nicht gefunden', 404); } - // Role-based redaction: self-contained role resolution (no middleware dependency) - const role = resolveRequestRole(req); - const canReadBerichtText = hasPermission(role, 'incidents:read_bericht_text'); + // Role-based redaction: check einsaetze:view_reports permission + const groups: string[] = req.user?.groups ?? []; + const canReadBerichtText = + groups.includes('dashboard_admin') || + permissionService.hasPermission(groups, 'einsaetze:view_reports'); const responseData = { ...incident, diff --git a/backend/src/controllers/permission.controller.ts b/backend/src/controllers/permission.controller.ts new file mode 100644 index 0000000..f4eeb32 --- /dev/null +++ b/backend/src/controllers/permission.controller.ts @@ -0,0 +1,116 @@ +import { Request, Response } from 'express'; +import { permissionService } from '../services/permission.service'; +import logger from '../utils/logger'; + +class PermissionController { + /** + * GET /api/permissions/me + * Returns the current user's effective permissions. + */ + async getMyPermissions(req: Request, res: Response): Promise { + try { + const groups: string[] = req.user?.groups ?? []; + const isAdmin = groups.includes('dashboard_admin'); + + let permissions: string[]; + if (isAdmin) { + // Admin gets all permissions + const matrix = await permissionService.getMatrix(); + permissions = matrix.permissions.map(p => p.id); + } else { + permissions = permissionService.getEffectivePermissions(groups); + } + + res.json({ + success: true, + data: { + permissions, + maintenance: permissionService.getMaintenanceFlags(), + isAdmin, + }, + }); + } catch (error) { + logger.error('Failed to get user permissions', { error }); + res.status(500).json({ success: false, message: 'Fehler beim Laden der Berechtigungen' }); + } + } + + /** + * GET /api/admin/permissions/matrix + * Returns the full permission matrix for the admin UI. + */ + async getMatrix(_req: Request, res: Response): Promise { + try { + const matrix = await permissionService.getMatrix(); + res.json({ success: true, data: matrix }); + } catch (error) { + logger.error('Failed to get permission matrix', { error }); + res.status(500).json({ success: false, message: 'Fehler beim Laden der Berechtigungsmatrix' }); + } + } + + /** + * PUT /api/admin/permissions/group/:groupName + * Sets all permissions for a given Authentik group. + */ + async setGroupPermissions(req: Request, res: Response): Promise { + try { + const groupName = req.params.groupName as string; + const { permissions } = req.body; + + if (!Array.isArray(permissions)) { + res.status(400).json({ success: false, message: 'permissions must be an array' }); + return; + } + + await permissionService.setGroupPermissions( + groupName, + permissions, + req.user!.id + ); + + res.json({ success: true, message: 'Berechtigungen aktualisiert' }); + } catch (error) { + logger.error('Failed to set group permissions', { error }); + res.status(500).json({ success: false, message: 'Fehler beim Speichern der Berechtigungen' }); + } + } + + /** + * GET /api/admin/permissions/groups + * Returns all known Authentik groups from the permission table. + */ + async getGroups(_req: Request, res: Response): Promise { + try { + const groups = await permissionService.getKnownGroups(); + res.json({ success: true, data: groups }); + } catch (error) { + logger.error('Failed to get groups', { error }); + res.status(500).json({ success: false, message: 'Fehler beim Laden der Gruppen' }); + } + } + + /** + * PUT /api/admin/permissions/maintenance/:featureGroupId + * Toggles maintenance mode for a feature group. + */ + async setMaintenanceFlag(req: Request, res: Response): Promise { + try { + const featureGroupId = req.params.featureGroupId as string; + const { active } = req.body; + + if (typeof active !== 'boolean') { + res.status(400).json({ success: false, message: 'active must be a boolean' }); + return; + } + + await permissionService.setMaintenanceFlag(featureGroupId, active); + res.json({ success: true, message: 'Wartungsmodus aktualisiert' }); + } catch (error) { + logger.error('Failed to set maintenance flag', { error }); + res.status(500).json({ success: false, message: 'Fehler beim Setzen des Wartungsmodus' }); + } + } +} + +export default new PermissionController(); diff --git a/backend/src/database/migrations/037_create_permission_system.sql b/backend/src/database/migrations/037_create_permission_system.sql new file mode 100644 index 0000000..213c7ef --- /dev/null +++ b/backend/src/database/migrations/037_create_permission_system.sql @@ -0,0 +1,563 @@ +-- Migration 037: DB-driven permission system +-- Replaces hardcoded RBAC with per-Authentik-group permission assignments + +-- ═══════════════════════════════════════════════════════════════════════════ +-- 1. Feature Groups +-- ═══════════════════════════════════════════════════════════════════════════ + +CREATE TABLE IF NOT EXISTS feature_groups ( + id VARCHAR(50) PRIMARY KEY, + label VARCHAR(100) NOT NULL, + sort_order INT NOT NULL DEFAULT 0, + maintenance BOOLEAN NOT NULL DEFAULT FALSE +); + +INSERT INTO feature_groups (id, label, sort_order) VALUES + ('kalender', 'Kalender', 1), + ('fahrzeuge', 'Fahrzeuge', 2), + ('einsaetze', 'Einsätze', 3), + ('ausruestung', 'Ausrüstung', 4), + ('mitglieder', 'Mitglieder', 5), + ('atemschutz', 'Atemschutz', 6), + ('wissen', 'Wissen', 7), + ('vikunja', 'Vikunja', 8), + ('nextcloud', 'Nextcloud', 9), + ('dashboard', 'Dashboard', 10) +ON CONFLICT (id) DO NOTHING; + +-- ═══════════════════════════════════════════════════════════════════════════ +-- 2. Permissions +-- ═══════════════════════════════════════════════════════════════════════════ + +CREATE TABLE IF NOT EXISTS permissions ( + id VARCHAR(100) PRIMARY KEY, + feature_group_id VARCHAR(50) NOT NULL REFERENCES feature_groups(id) ON DELETE CASCADE, + label VARCHAR(150) NOT NULL, + description TEXT, + sort_order INT NOT NULL DEFAULT 0 +); + +-- Kalender permissions +INSERT INTO permissions (id, feature_group_id, label, description, sort_order) VALUES + ('kalender:access', 'kalender', 'Zugriff', 'Kalender-Seite anzeigen', 1), + ('kalender:view_events', 'kalender', 'Termine ansehen', 'Termine und Übungen einsehen', 2), + ('kalender:view_bookings', 'kalender', 'Buchungen ansehen', 'Fahrzeugbuchungen einsehen', 3), + ('kalender:create_events', 'kalender', 'Termine erstellen', 'Neue Termine/Veranstaltungen erstellen', 4), + ('kalender:create_training', 'kalender', 'Übungen erstellen', 'Neue Übungen anlegen', 5), + ('kalender:cancel_training', 'kalender', 'Übungen absagen', 'Übungen absagen/löschen', 6), + ('kalender:mark_attendance', 'kalender', 'Anwesenheit eintragen', 'Teilnahme an Übungen bestätigen', 7), + ('kalender:create_bookings', 'kalender', 'Buchungen erstellen', 'Neue Fahrzeugbuchungen anlegen', 8), + ('kalender:edit_bookings', 'kalender', 'Buchungen bearbeiten', 'Bestehende Buchungen ändern', 9), + ('kalender:delete_bookings', 'kalender', 'Buchungen löschen', 'Buchungen endgültig löschen', 10), + ('kalender:manage_categories', 'kalender', 'Kategorien verwalten', 'Veranstaltungskategorien verwalten', 11), + ('kalender:view_reports', 'kalender', 'Berichte ansehen', 'Übungsstatistiken und Berichte einsehen', 12), + ('kalender:widget_events', 'kalender', 'Widget: Termine', 'Dashboard-Widget für Termine', 13), + ('kalender:widget_bookings', 'kalender', 'Widget: Buchungen', 'Dashboard-Widget für Buchungen', 14), + ('kalender:widget_quick_add', 'kalender', 'Widget: Schnell-Termin', 'Dashboard-Widget zum schnellen Erstellen', 15) +ON CONFLICT (id) DO NOTHING; + +-- Fahrzeuge permissions +INSERT INTO permissions (id, feature_group_id, label, description, sort_order) VALUES + ('fahrzeuge:access', 'fahrzeuge', 'Zugriff', 'Fahrzeug-Seite anzeigen', 1), + ('fahrzeuge:view', 'fahrzeuge', 'Ansehen', 'Fahrzeugdetails einsehen', 2), + ('fahrzeuge:create', 'fahrzeuge', 'Erstellen', 'Neue Fahrzeuge anlegen', 3), + ('fahrzeuge:change_status', 'fahrzeuge', 'Status ändern', 'Fahrzeugstatus ändern', 4), + ('fahrzeuge:manage_maintenance', 'fahrzeuge', 'Wartung verwalten', 'Wartungseinträge erstellen/bearbeiten', 5), + ('fahrzeuge:delete', 'fahrzeuge', 'Löschen', 'Fahrzeuge löschen', 6), + ('fahrzeuge:widget', 'fahrzeuge', 'Widget', 'Dashboard-Widget für Fahrzeuge', 7) +ON CONFLICT (id) DO NOTHING; + +-- Einsätze permissions +INSERT INTO permissions (id, feature_group_id, label, description, sort_order) VALUES + ('einsaetze:access', 'einsaetze', 'Zugriff', 'Einsatz-Seite anzeigen', 1), + ('einsaetze:view', 'einsaetze', 'Ansehen', 'Einsätze einsehen', 2), + ('einsaetze:view_reports', 'einsaetze', 'Berichte ansehen', 'Einsatzberichte/Berichtstext einsehen', 3), + ('einsaetze:create', 'einsaetze', 'Erstellen', 'Neue Einsätze anlegen und bearbeiten', 4), + ('einsaetze:delete', 'einsaetze', 'Löschen', 'Einsätze archivieren/löschen', 5), + ('einsaetze:manage_personnel', 'einsaetze', 'Personal verwalten', 'Einsatzpersonal und Fahrzeuge zuweisen', 6) +ON CONFLICT (id) DO NOTHING; + +-- Ausrüstung permissions +INSERT INTO permissions (id, feature_group_id, label, description, sort_order) VALUES + ('ausruestung:access', 'ausruestung', 'Zugriff', 'Ausrüstungs-Seite anzeigen', 1), + ('ausruestung:view', 'ausruestung', 'Ansehen', 'Ausrüstung einsehen', 2), + ('ausruestung:create', 'ausruestung', 'Erstellen', 'Neue Ausrüstung anlegen und bearbeiten', 3), + ('ausruestung:manage_maintenance', 'ausruestung', 'Wartung verwalten', 'Wartungseinträge verwalten', 4), + ('ausruestung:delete', 'ausruestung', 'Löschen', 'Ausrüstung löschen', 5), + ('ausruestung:widget', 'ausruestung', 'Widget', 'Dashboard-Widget für Ausrüstung', 6) +ON CONFLICT (id) DO NOTHING; + +-- Mitglieder permissions +INSERT INTO permissions (id, feature_group_id, label, description, sort_order) VALUES + ('mitglieder:access', 'mitglieder', 'Zugriff', 'Mitglieder-Seite anzeigen', 1), + ('mitglieder:view', 'mitglieder', 'Ansehen', 'Mitglieder-Profile einsehen', 2), + ('mitglieder:edit', 'mitglieder', 'Bearbeiten', 'Mitglieder-Profile bearbeiten', 3), + ('mitglieder:create_profile', 'mitglieder', 'Profil erstellen', 'Neue Mitglieder-Profile anlegen', 4) +ON CONFLICT (id) DO NOTHING; + +-- Atemschutz permissions +INSERT INTO permissions (id, feature_group_id, label, description, sort_order) VALUES + ('atemschutz:access', 'atemschutz', 'Zugriff', 'Atemschutz-Seite anzeigen', 1), + ('atemschutz:view', 'atemschutz', 'Ansehen', 'Atemschutz-Daten aller Träger sehen', 2), + ('atemschutz:create', 'atemschutz', 'Erstellen', 'Atemschutz-Einträge anlegen/ändern', 3), + ('atemschutz:delete', 'atemschutz', 'Löschen', 'Atemschutz-Einträge löschen', 4), + ('atemschutz:widget', 'atemschutz', 'Widget', 'Dashboard-Widget für Atemschutz', 5) +ON CONFLICT (id) DO NOTHING; + +-- Wissen permissions +INSERT INTO permissions (id, feature_group_id, label, description, sort_order) VALUES + ('wissen:access', 'wissen', 'Zugriff', 'Wissen-Seite anzeigen', 1), + ('wissen:widget_recent', 'wissen', 'Widget: Letzte', 'Dashboard-Widget letzte Seiten', 2), + ('wissen:widget_search', 'wissen', 'Widget: Suche', 'Dashboard-Widget für BookStack-Suche', 3) +ON CONFLICT (id) DO NOTHING; + +-- Vikunja permissions +INSERT INTO permissions (id, feature_group_id, label, description, sort_order) VALUES + ('vikunja:access', 'vikunja', 'Zugriff', 'Vikunja-Integration nutzen', 1), + ('vikunja:create_tasks', 'vikunja', 'Aufgaben erstellen', 'Neue Vikunja-Aufgaben erstellen', 2), + ('vikunja:widget_tasks', 'vikunja', 'Widget: Aufgaben', 'Dashboard-Widget für Vikunja-Aufgaben', 3), + ('vikunja:widget_quick_add', 'vikunja', 'Widget: Schnell-Task', 'Dashboard-Widget zum schnellen Erstellen', 4) +ON CONFLICT (id) DO NOTHING; + +-- Nextcloud permissions +INSERT INTO permissions (id, feature_group_id, label, description, sort_order) VALUES + ('nextcloud:access', 'nextcloud', 'Zugriff', 'Nextcloud-Integration nutzen', 1), + ('nextcloud:widget', 'nextcloud', 'Widget', 'Dashboard-Widget für Nextcloud', 2) +ON CONFLICT (id) DO NOTHING; + +-- Dashboard permissions +INSERT INTO permissions (id, feature_group_id, label, description, sort_order) VALUES + ('dashboard:widget_links', 'dashboard', 'Widget: Links', 'Dashboard-Widget für externe Links', 1), + ('dashboard:widget_banner', 'dashboard', 'Widget: Banner', 'Dashboard-Widget für Banner', 2) +ON CONFLICT (id) DO NOTHING; + +-- ═══════════════════════════════════════════════════════════════════════════ +-- 3. Group Permissions +-- ═══════════════════════════════════════════════════════════════════════════ + +CREATE TABLE IF NOT EXISTS group_permissions ( + authentik_group VARCHAR(100) NOT NULL, + permission_id VARCHAR(100) NOT NULL REFERENCES permissions(id) ON DELETE CASCADE, + granted_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + granted_by UUID REFERENCES users(id) ON DELETE SET NULL, + PRIMARY KEY (authentik_group, permission_id) +); + +CREATE INDEX IF NOT EXISTS idx_group_permissions_group ON group_permissions(authentik_group); + +-- ═══════════════════════════════════════════════════════════════════════════ +-- 4. Seed data — replicate current RBAC behavior +-- ═══════════════════════════════════════════════════════════════════════════ + +-- Helper: grant a list of permissions to a group +-- We insert each combination individually with ON CONFLICT DO NOTHING + +-- ── All authenticated users (mitglied-level) ── +-- Every non-bewerber group gets access + view + widget permissions + +-- dashboard_kommando — gets everything (except admin) +INSERT INTO group_permissions (authentik_group, permission_id) VALUES + -- Kalender + ('dashboard_kommando', 'kalender:access'), + ('dashboard_kommando', 'kalender:view_events'), + ('dashboard_kommando', 'kalender:view_bookings'), + ('dashboard_kommando', 'kalender:create_events'), + ('dashboard_kommando', 'kalender:create_training'), + ('dashboard_kommando', 'kalender:cancel_training'), + ('dashboard_kommando', 'kalender:mark_attendance'), + ('dashboard_kommando', 'kalender:create_bookings'), + ('dashboard_kommando', 'kalender:edit_bookings'), + ('dashboard_kommando', 'kalender:delete_bookings'), + ('dashboard_kommando', 'kalender:manage_categories'), + ('dashboard_kommando', 'kalender:view_reports'), + ('dashboard_kommando', 'kalender:widget_events'), + ('dashboard_kommando', 'kalender:widget_bookings'), + ('dashboard_kommando', 'kalender:widget_quick_add'), + -- Fahrzeuge + ('dashboard_kommando', 'fahrzeuge:access'), + ('dashboard_kommando', 'fahrzeuge:view'), + ('dashboard_kommando', 'fahrzeuge:create'), + ('dashboard_kommando', 'fahrzeuge:change_status'), + ('dashboard_kommando', 'fahrzeuge:manage_maintenance'), + ('dashboard_kommando', 'fahrzeuge:delete'), + ('dashboard_kommando', 'fahrzeuge:widget'), + -- Einsätze + ('dashboard_kommando', 'einsaetze:access'), + ('dashboard_kommando', 'einsaetze:view'), + ('dashboard_kommando', 'einsaetze:view_reports'), + ('dashboard_kommando', 'einsaetze:create'), + ('dashboard_kommando', 'einsaetze:delete'), + ('dashboard_kommando', 'einsaetze:manage_personnel'), + -- Ausrüstung + ('dashboard_kommando', 'ausruestung:access'), + ('dashboard_kommando', 'ausruestung:view'), + ('dashboard_kommando', 'ausruestung:create'), + ('dashboard_kommando', 'ausruestung:manage_maintenance'), + ('dashboard_kommando', 'ausruestung:delete'), + ('dashboard_kommando', 'ausruestung:widget'), + -- Mitglieder + ('dashboard_kommando', 'mitglieder:access'), + ('dashboard_kommando', 'mitglieder:view'), + ('dashboard_kommando', 'mitglieder:edit'), + ('dashboard_kommando', 'mitglieder:create_profile'), + -- Atemschutz + ('dashboard_kommando', 'atemschutz:access'), + ('dashboard_kommando', 'atemschutz:view'), + ('dashboard_kommando', 'atemschutz:create'), + ('dashboard_kommando', 'atemschutz:delete'), + ('dashboard_kommando', 'atemschutz:widget'), + -- Wissen + ('dashboard_kommando', 'wissen:access'), + ('dashboard_kommando', 'wissen:widget_recent'), + ('dashboard_kommando', 'wissen:widget_search'), + -- Vikunja + ('dashboard_kommando', 'vikunja:access'), + ('dashboard_kommando', 'vikunja:create_tasks'), + ('dashboard_kommando', 'vikunja:widget_tasks'), + ('dashboard_kommando', 'vikunja:widget_quick_add'), + -- Nextcloud + ('dashboard_kommando', 'nextcloud:access'), + ('dashboard_kommando', 'nextcloud:widget'), + -- Dashboard + ('dashboard_kommando', 'dashboard:widget_links'), + ('dashboard_kommando', 'dashboard:widget_banner') +ON CONFLICT DO NOTHING; + +-- dashboard_gruppenfuehrer — write-level for most features +INSERT INTO group_permissions (authentik_group, permission_id) VALUES + -- Kalender + ('dashboard_gruppenfuehrer', 'kalender:access'), + ('dashboard_gruppenfuehrer', 'kalender:view_events'), + ('dashboard_gruppenfuehrer', 'kalender:view_bookings'), + ('dashboard_gruppenfuehrer', 'kalender:create_events'), + ('dashboard_gruppenfuehrer', 'kalender:create_training'), + ('dashboard_gruppenfuehrer', 'kalender:mark_attendance'), + ('dashboard_gruppenfuehrer', 'kalender:create_bookings'), + ('dashboard_gruppenfuehrer', 'kalender:edit_bookings'), + ('dashboard_gruppenfuehrer', 'kalender:manage_categories'), + ('dashboard_gruppenfuehrer', 'kalender:widget_events'), + ('dashboard_gruppenfuehrer', 'kalender:widget_bookings'), + ('dashboard_gruppenfuehrer', 'kalender:widget_quick_add'), + -- Fahrzeuge + ('dashboard_gruppenfuehrer', 'fahrzeuge:access'), + ('dashboard_gruppenfuehrer', 'fahrzeuge:view'), + ('dashboard_gruppenfuehrer', 'fahrzeuge:change_status'), + ('dashboard_gruppenfuehrer', 'fahrzeuge:manage_maintenance'), + ('dashboard_gruppenfuehrer', 'fahrzeuge:widget'), + -- Einsätze + ('dashboard_gruppenfuehrer', 'einsaetze:access'), + ('dashboard_gruppenfuehrer', 'einsaetze:view'), + ('dashboard_gruppenfuehrer', 'einsaetze:create'), + ('dashboard_gruppenfuehrer', 'einsaetze:manage_personnel'), + -- Ausrüstung + ('dashboard_gruppenfuehrer', 'ausruestung:access'), + ('dashboard_gruppenfuehrer', 'ausruestung:view'), + ('dashboard_gruppenfuehrer', 'ausruestung:create'), + ('dashboard_gruppenfuehrer', 'ausruestung:manage_maintenance'), + ('dashboard_gruppenfuehrer', 'ausruestung:widget'), + -- Mitglieder + ('dashboard_gruppenfuehrer', 'mitglieder:access'), + ('dashboard_gruppenfuehrer', 'mitglieder:view'), + -- Atemschutz + ('dashboard_gruppenfuehrer', 'atemschutz:access'), + ('dashboard_gruppenfuehrer', 'atemschutz:view'), + ('dashboard_gruppenfuehrer', 'atemschutz:create'), + ('dashboard_gruppenfuehrer', 'atemschutz:widget'), + -- Wissen + ('dashboard_gruppenfuehrer', 'wissen:access'), + ('dashboard_gruppenfuehrer', 'wissen:widget_recent'), + ('dashboard_gruppenfuehrer', 'wissen:widget_search'), + -- Vikunja + ('dashboard_gruppenfuehrer', 'vikunja:access'), + ('dashboard_gruppenfuehrer', 'vikunja:create_tasks'), + ('dashboard_gruppenfuehrer', 'vikunja:widget_tasks'), + ('dashboard_gruppenfuehrer', 'vikunja:widget_quick_add'), + -- Nextcloud + ('dashboard_gruppenfuehrer', 'nextcloud:access'), + ('dashboard_gruppenfuehrer', 'nextcloud:widget'), + -- Dashboard + ('dashboard_gruppenfuehrer', 'dashboard:widget_links'), + ('dashboard_gruppenfuehrer', 'dashboard:widget_banner') +ON CONFLICT DO NOTHING; + +-- dashboard_fahrmeister — vehicle specialist +INSERT INTO group_permissions (authentik_group, permission_id) VALUES + -- Kalender + ('dashboard_fahrmeister', 'kalender:access'), + ('dashboard_fahrmeister', 'kalender:view_events'), + ('dashboard_fahrmeister', 'kalender:view_bookings'), + ('dashboard_fahrmeister', 'kalender:create_bookings'), + ('dashboard_fahrmeister', 'kalender:edit_bookings'), + ('dashboard_fahrmeister', 'kalender:widget_events'), + ('dashboard_fahrmeister', 'kalender:widget_bookings'), + -- Fahrzeuge + ('dashboard_fahrmeister', 'fahrzeuge:access'), + ('dashboard_fahrmeister', 'fahrzeuge:view'), + ('dashboard_fahrmeister', 'fahrzeuge:change_status'), + ('dashboard_fahrmeister', 'fahrzeuge:manage_maintenance'), + ('dashboard_fahrmeister', 'fahrzeuge:widget'), + -- Einsätze + ('dashboard_fahrmeister', 'einsaetze:access'), + ('dashboard_fahrmeister', 'einsaetze:view'), + -- Ausrüstung + ('dashboard_fahrmeister', 'ausruestung:access'), + ('dashboard_fahrmeister', 'ausruestung:view'), + ('dashboard_fahrmeister', 'ausruestung:create'), + ('dashboard_fahrmeister', 'ausruestung:manage_maintenance'), + ('dashboard_fahrmeister', 'ausruestung:widget'), + -- Mitglieder + ('dashboard_fahrmeister', 'mitglieder:access'), + ('dashboard_fahrmeister', 'mitglieder:view'), + -- Atemschutz + ('dashboard_fahrmeister', 'atemschutz:access'), + ('dashboard_fahrmeister', 'atemschutz:widget'), + -- Wissen + ('dashboard_fahrmeister', 'wissen:access'), + ('dashboard_fahrmeister', 'wissen:widget_recent'), + ('dashboard_fahrmeister', 'wissen:widget_search'), + -- Vikunja + ('dashboard_fahrmeister', 'vikunja:access'), + ('dashboard_fahrmeister', 'vikunja:create_tasks'), + ('dashboard_fahrmeister', 'vikunja:widget_tasks'), + ('dashboard_fahrmeister', 'vikunja:widget_quick_add'), + -- Nextcloud + ('dashboard_fahrmeister', 'nextcloud:access'), + ('dashboard_fahrmeister', 'nextcloud:widget'), + -- Dashboard + ('dashboard_fahrmeister', 'dashboard:widget_links'), + ('dashboard_fahrmeister', 'dashboard:widget_banner') +ON CONFLICT DO NOTHING; + +-- dashboard_zeugmeister — equipment specialist +INSERT INTO group_permissions (authentik_group, permission_id) VALUES + -- Kalender + ('dashboard_zeugmeister', 'kalender:access'), + ('dashboard_zeugmeister', 'kalender:view_events'), + ('dashboard_zeugmeister', 'kalender:view_bookings'), + ('dashboard_zeugmeister', 'kalender:create_bookings'), + ('dashboard_zeugmeister', 'kalender:widget_events'), + ('dashboard_zeugmeister', 'kalender:widget_bookings'), + -- Fahrzeuge + ('dashboard_zeugmeister', 'fahrzeuge:access'), + ('dashboard_zeugmeister', 'fahrzeuge:view'), + ('dashboard_zeugmeister', 'fahrzeuge:change_status'), + ('dashboard_zeugmeister', 'fahrzeuge:manage_maintenance'), + ('dashboard_zeugmeister', 'fahrzeuge:widget'), + -- Einsätze + ('dashboard_zeugmeister', 'einsaetze:access'), + ('dashboard_zeugmeister', 'einsaetze:view'), + -- Ausrüstung + ('dashboard_zeugmeister', 'ausruestung:access'), + ('dashboard_zeugmeister', 'ausruestung:view'), + ('dashboard_zeugmeister', 'ausruestung:create'), + ('dashboard_zeugmeister', 'ausruestung:manage_maintenance'), + ('dashboard_zeugmeister', 'ausruestung:widget'), + -- Mitglieder + ('dashboard_zeugmeister', 'mitglieder:access'), + ('dashboard_zeugmeister', 'mitglieder:view'), + -- Atemschutz + ('dashboard_zeugmeister', 'atemschutz:access'), + ('dashboard_zeugmeister', 'atemschutz:widget'), + -- Wissen + ('dashboard_zeugmeister', 'wissen:access'), + ('dashboard_zeugmeister', 'wissen:widget_recent'), + ('dashboard_zeugmeister', 'wissen:widget_search'), + -- Vikunja + ('dashboard_zeugmeister', 'vikunja:access'), + ('dashboard_zeugmeister', 'vikunja:create_tasks'), + ('dashboard_zeugmeister', 'vikunja:widget_tasks'), + ('dashboard_zeugmeister', 'vikunja:widget_quick_add'), + -- Nextcloud + ('dashboard_zeugmeister', 'nextcloud:access'), + ('dashboard_zeugmeister', 'nextcloud:widget'), + -- Dashboard + ('dashboard_zeugmeister', 'dashboard:widget_links'), + ('dashboard_zeugmeister', 'dashboard:widget_banner') +ON CONFLICT DO NOTHING; + +-- dashboard_chargen — similar to gruppenfuehrer +INSERT INTO group_permissions (authentik_group, permission_id) VALUES + -- Kalender + ('dashboard_chargen', 'kalender:access'), + ('dashboard_chargen', 'kalender:view_events'), + ('dashboard_chargen', 'kalender:view_bookings'), + ('dashboard_chargen', 'kalender:create_bookings'), + ('dashboard_chargen', 'kalender:widget_events'), + ('dashboard_chargen', 'kalender:widget_bookings'), + -- Fahrzeuge + ('dashboard_chargen', 'fahrzeuge:access'), + ('dashboard_chargen', 'fahrzeuge:view'), + ('dashboard_chargen', 'fahrzeuge:change_status'), + ('dashboard_chargen', 'fahrzeuge:manage_maintenance'), + ('dashboard_chargen', 'fahrzeuge:widget'), + -- Einsätze + ('dashboard_chargen', 'einsaetze:access'), + ('dashboard_chargen', 'einsaetze:view'), + ('dashboard_chargen', 'einsaetze:create'), + ('dashboard_chargen', 'einsaetze:manage_personnel'), + -- Ausrüstung + ('dashboard_chargen', 'ausruestung:access'), + ('dashboard_chargen', 'ausruestung:view'), + ('dashboard_chargen', 'ausruestung:create'), + ('dashboard_chargen', 'ausruestung:manage_maintenance'), + ('dashboard_chargen', 'ausruestung:widget'), + -- Mitglieder + ('dashboard_chargen', 'mitglieder:access'), + ('dashboard_chargen', 'mitglieder:view'), + -- Atemschutz + ('dashboard_chargen', 'atemschutz:access'), + ('dashboard_chargen', 'atemschutz:view'), + ('dashboard_chargen', 'atemschutz:create'), + ('dashboard_chargen', 'atemschutz:widget'), + -- Wissen + ('dashboard_chargen', 'wissen:access'), + ('dashboard_chargen', 'wissen:widget_recent'), + ('dashboard_chargen', 'wissen:widget_search'), + -- Vikunja + ('dashboard_chargen', 'vikunja:access'), + ('dashboard_chargen', 'vikunja:create_tasks'), + ('dashboard_chargen', 'vikunja:widget_tasks'), + ('dashboard_chargen', 'vikunja:widget_quick_add'), + -- Nextcloud + ('dashboard_chargen', 'nextcloud:access'), + ('dashboard_chargen', 'nextcloud:widget'), + -- Dashboard + ('dashboard_chargen', 'dashboard:widget_links'), + ('dashboard_chargen', 'dashboard:widget_banner') +ON CONFLICT DO NOTHING; + +-- dashboard_moderator — event/calendar management + atemschutz view +INSERT INTO group_permissions (authentik_group, permission_id) VALUES + -- Kalender + ('dashboard_moderator', 'kalender:access'), + ('dashboard_moderator', 'kalender:view_events'), + ('dashboard_moderator', 'kalender:view_bookings'), + ('dashboard_moderator', 'kalender:create_events'), + ('dashboard_moderator', 'kalender:create_bookings'), + ('dashboard_moderator', 'kalender:edit_bookings'), + ('dashboard_moderator', 'kalender:manage_categories'), + ('dashboard_moderator', 'kalender:widget_events'), + ('dashboard_moderator', 'kalender:widget_bookings'), + ('dashboard_moderator', 'kalender:widget_quick_add'), + -- Fahrzeuge + ('dashboard_moderator', 'fahrzeuge:access'), + ('dashboard_moderator', 'fahrzeuge:view'), + ('dashboard_moderator', 'fahrzeuge:widget'), + -- Einsätze + ('dashboard_moderator', 'einsaetze:access'), + ('dashboard_moderator', 'einsaetze:view'), + -- Ausrüstung + ('dashboard_moderator', 'ausruestung:access'), + ('dashboard_moderator', 'ausruestung:view'), + ('dashboard_moderator', 'ausruestung:widget'), + -- Mitglieder + ('dashboard_moderator', 'mitglieder:access'), + ('dashboard_moderator', 'mitglieder:view'), + -- Atemschutz + ('dashboard_moderator', 'atemschutz:access'), + ('dashboard_moderator', 'atemschutz:view'), + ('dashboard_moderator', 'atemschutz:widget'), + -- Wissen + ('dashboard_moderator', 'wissen:access'), + ('dashboard_moderator', 'wissen:widget_recent'), + ('dashboard_moderator', 'wissen:widget_search'), + -- Vikunja + ('dashboard_moderator', 'vikunja:access'), + ('dashboard_moderator', 'vikunja:create_tasks'), + ('dashboard_moderator', 'vikunja:widget_tasks'), + ('dashboard_moderator', 'vikunja:widget_quick_add'), + -- Nextcloud + ('dashboard_moderator', 'nextcloud:access'), + ('dashboard_moderator', 'nextcloud:widget'), + -- Dashboard + ('dashboard_moderator', 'dashboard:widget_links'), + ('dashboard_moderator', 'dashboard:widget_banner') +ON CONFLICT DO NOTHING; + +-- dashboard_atemschutz — atemschutz specialist +INSERT INTO group_permissions (authentik_group, permission_id) VALUES + -- Kalender (basic access) + ('dashboard_atemschutz', 'kalender:access'), + ('dashboard_atemschutz', 'kalender:view_events'), + ('dashboard_atemschutz', 'kalender:view_bookings'), + ('dashboard_atemschutz', 'kalender:create_bookings'), + ('dashboard_atemschutz', 'kalender:widget_events'), + ('dashboard_atemschutz', 'kalender:widget_bookings'), + -- Fahrzeuge (read) + ('dashboard_atemschutz', 'fahrzeuge:access'), + ('dashboard_atemschutz', 'fahrzeuge:view'), + ('dashboard_atemschutz', 'fahrzeuge:widget'), + -- Einsätze (read) + ('dashboard_atemschutz', 'einsaetze:access'), + ('dashboard_atemschutz', 'einsaetze:view'), + -- Ausrüstung (read) + ('dashboard_atemschutz', 'ausruestung:access'), + ('dashboard_atemschutz', 'ausruestung:view'), + ('dashboard_atemschutz', 'ausruestung:widget'), + -- Mitglieder (read) + ('dashboard_atemschutz', 'mitglieder:access'), + ('dashboard_atemschutz', 'mitglieder:view'), + -- Atemschutz (full) + ('dashboard_atemschutz', 'atemschutz:access'), + ('dashboard_atemschutz', 'atemschutz:view'), + ('dashboard_atemschutz', 'atemschutz:create'), + ('dashboard_atemschutz', 'atemschutz:widget'), + -- Wissen + ('dashboard_atemschutz', 'wissen:access'), + ('dashboard_atemschutz', 'wissen:widget_recent'), + ('dashboard_atemschutz', 'wissen:widget_search'), + -- Vikunja + ('dashboard_atemschutz', 'vikunja:access'), + ('dashboard_atemschutz', 'vikunja:create_tasks'), + ('dashboard_atemschutz', 'vikunja:widget_tasks'), + ('dashboard_atemschutz', 'vikunja:widget_quick_add'), + -- Nextcloud + ('dashboard_atemschutz', 'nextcloud:access'), + ('dashboard_atemschutz', 'nextcloud:widget'), + -- Dashboard + ('dashboard_atemschutz', 'dashboard:widget_links'), + ('dashboard_atemschutz', 'dashboard:widget_banner') +ON CONFLICT DO NOTHING; + +-- dashboard_mitglied — basic member access (read + basic booking creation) +INSERT INTO group_permissions (authentik_group, permission_id) VALUES + -- Kalender + ('dashboard_mitglied', 'kalender:access'), + ('dashboard_mitglied', 'kalender:view_events'), + ('dashboard_mitglied', 'kalender:view_bookings'), + ('dashboard_mitglied', 'kalender:create_bookings'), + ('dashboard_mitglied', 'kalender:widget_events'), + ('dashboard_mitglied', 'kalender:widget_bookings'), + -- Fahrzeuge + ('dashboard_mitglied', 'fahrzeuge:access'), + ('dashboard_mitglied', 'fahrzeuge:view'), + ('dashboard_mitglied', 'fahrzeuge:widget'), + -- Einsätze + ('dashboard_mitglied', 'einsaetze:access'), + ('dashboard_mitglied', 'einsaetze:view'), + -- Ausrüstung + ('dashboard_mitglied', 'ausruestung:access'), + ('dashboard_mitglied', 'ausruestung:view'), + ('dashboard_mitglied', 'ausruestung:widget'), + -- Mitglieder + ('dashboard_mitglied', 'mitglieder:access'), + ('dashboard_mitglied', 'mitglieder:view'), + -- Atemschutz + ('dashboard_mitglied', 'atemschutz:access'), + ('dashboard_mitglied', 'atemschutz:widget'), + -- Wissen + ('dashboard_mitglied', 'wissen:access'), + ('dashboard_mitglied', 'wissen:widget_recent'), + ('dashboard_mitglied', 'wissen:widget_search'), + -- Vikunja + ('dashboard_mitglied', 'vikunja:access'), + ('dashboard_mitglied', 'vikunja:create_tasks'), + ('dashboard_mitglied', 'vikunja:widget_tasks'), + ('dashboard_mitglied', 'vikunja:widget_quick_add'), + -- Nextcloud + ('dashboard_mitglied', 'nextcloud:access'), + ('dashboard_mitglied', 'nextcloud:widget'), + -- Dashboard + ('dashboard_mitglied', 'dashboard:widget_links'), + ('dashboard_mitglied', 'dashboard:widget_banner') +ON CONFLICT DO NOTHING; diff --git a/backend/src/middleware/rbac.middleware.ts b/backend/src/middleware/rbac.middleware.ts index 63a3297..55bd0c4 100644 --- a/backend/src/middleware/rbac.middleware.ts +++ b/backend/src/middleware/rbac.middleware.ts @@ -1,13 +1,11 @@ import { Request, Response, NextFunction } from 'express'; -import pool from '../config/database'; import logger from '../utils/logger'; import { auditPermissionDenied } from './audit.middleware'; import { AuditResourceType } from '../services/audit.service'; +import { permissionService } from '../services/permission.service'; // --------------------------------------------------------------------------- -// AppRole — mirrors the roles defined in the project spec. -// Tier 1 (RBAC) is assumed complete and adds a `role` column to users. -// This middleware reads that column to enforce permissions. +// AppRole — kept for backward compatibility (resolveRequestRole, bericht_text) // --------------------------------------------------------------------------- export type AppRole = | 'admin' @@ -16,105 +14,14 @@ export type AppRole = | 'mitglied' | 'bewerber'; -/** - * Role hierarchy: higher index = more permissions. - * Used to implement "at least X role" checks. - */ -const ROLE_HIERARCHY: AppRole[] = [ - 'bewerber', - 'mitglied', - 'gruppenfuehrer', - 'kommandant', - 'admin', -]; - -/** - * Permission map: defines which roles hold a given permission string. - * All roles at or above the listed minimum also hold the permission. - */ -const PERMISSION_ROLE_MIN: Record = { - 'incidents:read': 'mitglied', - 'incidents:write': 'gruppenfuehrer', - 'incidents:delete': 'kommandant', - 'incidents:read_bericht_text': 'kommandant', - 'incidents:manage_personnel': 'gruppenfuehrer', - // Training / Calendar - 'training:read': 'mitglied', - 'training:write': 'gruppenfuehrer', - 'training:cancel': 'kommandant', - 'training:mark_attendance': 'gruppenfuehrer', - 'reports:read': 'kommandant', - // Audit log and admin panel — restricted to admin role only - 'admin:access': 'admin', - 'audit:read': 'admin', - 'audit:export': 'admin', - 'members:read': 'mitglied', - 'members:write': 'kommandant', - 'vehicles:write': 'kommandant', - 'vehicles:status': 'gruppenfuehrer', - 'vehicles:delete': 'admin', - 'equipment:write': 'gruppenfuehrer', - 'equipment:delete': 'admin', - 'events:write': 'gruppenfuehrer', - 'events:categories': 'gruppenfuehrer', - 'atemschutz:write': 'gruppenfuehrer', - 'atemschutz:delete': 'kommandant', - 'bookings:write': 'gruppenfuehrer', - 'bookings:delete': 'admin', -}; - -/** - * Derive an AppRole from Authentik JWT groups (highest matching role wins). - */ -function roleFromGroups(groups: string[]): AppRole { - if (groups.includes('dashboard_admin')) return 'admin'; - if (groups.includes('dashboard_kommando')) return 'kommandant'; - if (groups.includes('dashboard_gruppenfuehrer') || groups.includes('dashboard_fahrmeister') || groups.includes('dashboard_zeugmeister') || groups.includes('dashboard_chargen')) return 'gruppenfuehrer'; - return 'mitglied'; -} - -function hasPermission(role: AppRole, permission: string): boolean { - const minRole = PERMISSION_ROLE_MIN[permission]; - if (!minRole) { - logger.warn('Unknown permission checked', { permission }); - return false; - } - const userLevel = ROLE_HIERARCHY.indexOf(role); - const minLevel = ROLE_HIERARCHY.indexOf(minRole); - return userLevel >= minLevel; -} - -/** - * Retrieves the role for a given user ID from the database. - * Falls back to 'mitglied' if the users table does not yet have a role column - * (graceful degradation while Tier 1 migration is pending). - */ -async function getUserRole(userId: string): Promise { - try { - const result = await pool.query( - `SELECT role FROM users WHERE id = $1`, - [userId] - ); - if (result.rows.length === 0) return 'mitglied'; - return (result.rows[0].role as AppRole) ?? 'mitglied'; - } catch (error) { - // If the column doesn't exist yet (Tier 1 not deployed), degrade gracefully - const errMsg = error instanceof Error ? error.message : String(error); - if (errMsg.includes('column "role" does not exist')) { - logger.warn('users.role column not found — Tier 1 RBAC migration pending. Defaulting to mitglied.'); - return 'mitglied'; - } - logger.error('Error fetching user role', { error, userId }); - return 'mitglied'; - } -} - /** * Middleware factory: requires the authenticated user to hold the given - * permission (or a role with sufficient hierarchy level). + * permission. Permission is checked against the DB-driven permission system. * - * Usage: - * router.post('/api/incidents', authenticate, requirePermission('incidents:write'), handler) + * Hardwired rules: + * - `dashboard_admin` group always passes (full access). + * - `admin:access` is checked via group membership (not DB). + * - Maintenance mode blocks non-admin access per feature group. */ export function requirePermission(permission: string) { return async (req: Request, res: Response, next: NextFunction): Promise => { @@ -126,27 +33,65 @@ export function requirePermission(permission: string) { return; } - const dbRole = (req.user as any).role - ? (req.user as any).role as AppRole - : await getUserRole(req.user.id); - const groupRole = roleFromGroups(req.user?.groups ?? []); - const role = ROLE_HIERARCHY.indexOf(groupRole) > ROLE_HIERARCHY.indexOf(dbRole) ? groupRole : dbRole; + const groups: string[] = req.user?.groups ?? []; - // Attach role to request for downstream use (e.g., bericht_text redaction) - (req as Request & { userRole?: AppRole }).userRole = role; + // Attach resolved role for downstream use (bericht_text redaction, etc.) + (req as any).userRole = resolveRequestRole(req); - if (!hasPermission(role, permission)) { - logger.warn('Permission denied', { + // Hardwired: dashboard_admin always has full access + if (groups.includes('dashboard_admin')) { + next(); + return; + } + + // Hardwired: admin:access only for dashboard_admin (already returned above) + if (permission === 'admin:access') { + logger.warn('Permission denied — admin:access', { userId: req.user.id, - role, permission, path: req.path, }); - // GDPR audit trail — fire-and-forget, never throws auditPermissionDenied(req, AuditResourceType.SYSTEM, undefined, { required_permission: permission, - user_role: role, + }); + + res.status(403).json({ + success: false, + message: 'Keine Berechtigung', + }); + return; + } + + // Check maintenance mode for the feature group + const featureGroup = permission.split(':')[0]; + if (permissionService.isFeatureInMaintenance(featureGroup)) { + logger.info('Feature in maintenance mode', { + userId: req.user.id, + featureGroup, + permission, + path: req.path, + }); + + res.status(403).json({ + success: false, + message: 'Diese Funktion befindet sich im Wartungsmodus', + }); + return; + } + + // Check DB-driven permission + if (!permissionService.hasPermission(groups, permission)) { + logger.warn('Permission denied', { + userId: req.user.id, + groups, + permission, + path: req.path, + }); + + auditPermissionDenied(req, AuditResourceType.SYSTEM, undefined, { + required_permission: permission, + user_groups: groups, }); res.status(403).json({ @@ -161,25 +106,42 @@ export function requirePermission(permission: string) { } /** - * Resolve the effective AppRole for a request, combining DB role and group role. - * Self-contained — does not depend on requirePermission() middleware having run. + * Resolve the effective AppRole for a request. + * Simplified: returns 'admin' for dashboard_admin, 'kommandant' for dashboard_kommando, + * 'gruppenfuehrer' for specialist groups, else 'mitglied'. + * Used for backward-compatible features like bericht_text redaction. */ 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; + const groups: string[] = req.user?.groups ?? []; + if (groups.includes('dashboard_admin')) return 'admin'; + if (groups.includes('dashboard_kommando')) return 'kommandant'; + if ( + groups.includes('dashboard_gruppenfuehrer') || + groups.includes('dashboard_fahrmeister') || + groups.includes('dashboard_zeugmeister') || + groups.includes('dashboard_chargen') + ) return 'gruppenfuehrer'; + return 'mitglied'; } -export { getUserRole, hasPermission, roleFromGroups }; +// Legacy exports for backward compatibility +export function getUserRole(_userId: string): Promise { + return Promise.resolve('mitglied'); +} + +export function roleFromGroups(groups: string[]): AppRole { + if (groups.includes('dashboard_admin')) return 'admin'; + if (groups.includes('dashboard_kommando')) return 'kommandant'; + if (groups.includes('dashboard_gruppenfuehrer') || groups.includes('dashboard_fahrmeister') || groups.includes('dashboard_zeugmeister') || groups.includes('dashboard_chargen')) return 'gruppenfuehrer'; + return 'mitglied'; +} + +export function hasPermission(role: AppRole, _permission: string): boolean { + return role === 'admin'; +} /** - * Middleware factory: requires the authenticated user to belong to at least - * one of the given Authentik groups (sourced from the JWT `groups` claim). - * - * Usage: - * router.post('/api/vehicles', authenticate, requireGroups(['dashboard_admin']), handler) + * @deprecated Use requirePermission() instead. */ export function requireGroups(requiredGroups: string[]) { return async (req: Request, res: Response, next: NextFunction): Promise => { @@ -195,15 +157,15 @@ export function requireGroups(requiredGroups: string[]) { if (!hasAccess) { logger.warn('Group-based access denied', { - userId: req.user.id, + userId: req.user.id, userGroups, requiredGroups, - path: req.path, + path: req.path, }); auditPermissionDenied(req, AuditResourceType.SYSTEM, undefined, { required_groups: requiredGroups, - user_groups: userGroups, + user_groups: userGroups, }); res.status(403).json({ diff --git a/backend/src/routes/atemschutz.routes.ts b/backend/src/routes/atemschutz.routes.ts index bd1c789..b8a08bf 100644 --- a/backend/src/routes/atemschutz.routes.ts +++ b/backend/src/routes/atemschutz.routes.ts @@ -16,8 +16,8 @@ router.get('/:id', authenticate, atemschutzController.getOne.bind(atem // ── Write — gruppenfuehrer+ ───────────────────────────────────────────────── -router.post('/', authenticate, requirePermission('atemschutz:write'), atemschutzController.create.bind(atemschutzController)); -router.patch('/:id', authenticate, requirePermission('atemschutz:write'), atemschutzController.update.bind(atemschutzController)); +router.post('/', authenticate, requirePermission('atemschutz:create'), atemschutzController.create.bind(atemschutzController)); +router.patch('/:id', authenticate, requirePermission('atemschutz:create'), atemschutzController.update.bind(atemschutzController)); // ── Delete — kommandant+ ──────────────────────────────────────────────────── diff --git a/backend/src/routes/booking.routes.ts b/backend/src/routes/booking.routes.ts index 236be87..89cb3bb 100644 --- a/backend/src/routes/booking.routes.ts +++ b/backend/src/routes/booking.routes.ts @@ -19,14 +19,14 @@ router.get('/calendar-token', authenticate, bookingController.getCalendarToken.b // ── Write operations ────────────────────────────────────────────────────────── router.post('/', authenticate, bookingController.create.bind(bookingController)); -router.patch('/:id', authenticate, requirePermission('bookings:write'), bookingController.update.bind(bookingController)); +router.patch('/:id', authenticate, requirePermission('kalender:edit_bookings'), bookingController.update.bind(bookingController)); // Soft-cancel (sets abgesagt=TRUE) — creator or bookings:write router.delete('/:id', authenticate, bookingController.cancel.bind(bookingController)); router.patch('/:id/cancel', authenticate, bookingController.cancel.bind(bookingController)); // Hard-delete (admin only) -router.delete('/:id/force', authenticate, requirePermission('bookings:delete'), bookingController.hardDelete.bind(bookingController)); +router.delete('/:id/force', authenticate, requirePermission('kalender:delete_bookings'), bookingController.hardDelete.bind(bookingController)); // ── Single booking read — after specific routes to avoid path conflicts ─────── diff --git a/backend/src/routes/equipment.routes.ts b/backend/src/routes/equipment.routes.ts index 2f12775..922319b 100644 --- a/backend/src/routes/equipment.routes.ts +++ b/backend/src/routes/equipment.routes.ts @@ -17,13 +17,13 @@ router.get('/:id', authenticate, equipmentController.getEquipmen // ── Write — gruppenfuehrer+ ──────────────────────────────────────────────── -router.post('/', authenticate, requirePermission('equipment:write'), equipmentController.createEquipment.bind(equipmentController)); -router.patch('/:id', authenticate, requirePermission('equipment:write'), equipmentController.updateEquipment.bind(equipmentController)); -router.patch('/:id/status', authenticate, requirePermission('equipment:write'), equipmentController.updateStatus.bind(equipmentController)); -router.post('/:id/wartung', authenticate, requirePermission('equipment:write'), equipmentController.addWartung.bind(equipmentController)); +router.post('/', authenticate, requirePermission('ausruestung:create'), equipmentController.createEquipment.bind(equipmentController)); +router.patch('/:id', authenticate, requirePermission('ausruestung:create'), equipmentController.updateEquipment.bind(equipmentController)); +router.patch('/:id/status', authenticate, requirePermission('ausruestung:create'), equipmentController.updateStatus.bind(equipmentController)); +router.post('/:id/wartung', authenticate, requirePermission('ausruestung:create'), equipmentController.addWartung.bind(equipmentController)); // ── Delete — admin only ────────────────────────────────────────────────────── -router.delete('/:id', authenticate, requirePermission('equipment:delete'), equipmentController.deleteEquipment.bind(equipmentController)); +router.delete('/:id', authenticate, requirePermission('ausruestung:delete'), equipmentController.deleteEquipment.bind(equipmentController)); export default router; diff --git a/backend/src/routes/events.routes.ts b/backend/src/routes/events.routes.ts index c3ed2f0..6178b69 100644 --- a/backend/src/routes/events.routes.ts +++ b/backend/src/routes/events.routes.ts @@ -22,7 +22,7 @@ router.get('/kategorien', authenticate, eventsController.listKategorien.bind(eve router.post( '/kategorien', authenticate, - requirePermission('events:categories'), + requirePermission('kalender:manage_categories'), eventsController.createKategorie.bind(eventsController) ); @@ -33,7 +33,7 @@ router.post( router.patch( '/kategorien/:id', authenticate, - requirePermission('events:categories'), + requirePermission('kalender:manage_categories'), eventsController.updateKategorie.bind(eventsController) ); @@ -44,7 +44,7 @@ router.patch( router.delete( '/kategorien/:id', authenticate, - requirePermission('events:categories'), + requirePermission('kalender:manage_categories'), eventsController.deleteKategorie.bind(eventsController) ); @@ -118,7 +118,7 @@ router.get( router.post( '/import', authenticate, - requirePermission('events:write'), + requirePermission('kalender:create_events'), eventsController.importEvents.bind(eventsController) ); @@ -129,7 +129,7 @@ router.post( router.post( '/', authenticate, - requirePermission('events:write'), + requirePermission('kalender:create_events'), eventsController.createEvent.bind(eventsController) ); @@ -146,7 +146,7 @@ router.get('/:id', authenticate, eventsController.getById.bind(eventsController) router.patch( '/:id', authenticate, - requirePermission('events:write'), + requirePermission('kalender:create_events'), eventsController.updateEvent.bind(eventsController) ); @@ -157,7 +157,7 @@ router.patch( router.delete( '/:id', authenticate, - requirePermission('events:write'), + requirePermission('kalender:create_events'), eventsController.cancelEvent.bind(eventsController) ); @@ -168,7 +168,7 @@ router.delete( router.post( '/:id/delete', authenticate, - requirePermission('events:write'), + requirePermission('kalender:create_events'), eventsController.deleteEvent.bind(eventsController) ); diff --git a/backend/src/routes/incident.routes.ts b/backend/src/routes/incident.routes.ts index ff078b4..1cc7fb8 100644 --- a/backend/src/routes/incident.routes.ts +++ b/backend/src/routes/incident.routes.ts @@ -16,7 +16,7 @@ router.use(authenticate); */ router.get( '/', - requirePermission('incidents:read'), + requirePermission('einsaetze:view'), incidentController.listIncidents.bind(incidentController) ); @@ -31,7 +31,7 @@ router.get( */ router.get( '/stats', - requirePermission('incidents:read'), + requirePermission('einsaetze:view'), incidentController.getStats.bind(incidentController) ); @@ -42,7 +42,7 @@ router.get( */ router.post( '/refresh-stats', - requirePermission('incidents:delete'), // kommandant+ (repurposing delete permission for admin ops) + requirePermission('einsaetze:delete'), incidentController.refreshStats.bind(incidentController) ); @@ -53,7 +53,7 @@ router.post( */ router.get( '/:id', - requirePermission('incidents:read'), + requirePermission('einsaetze:view'), incidentController.getIncident.bind(incidentController) ); @@ -64,7 +64,7 @@ router.get( */ router.post( '/', - requirePermission('incidents:write'), + requirePermission('einsaetze:create'), incidentController.createIncident.bind(incidentController) ); @@ -75,7 +75,7 @@ router.post( */ router.patch( '/:id', - requirePermission('incidents:write'), + requirePermission('einsaetze:create'), incidentController.updateIncident.bind(incidentController) ); @@ -86,7 +86,7 @@ router.patch( */ router.delete( '/:id', - requirePermission('incidents:delete'), + requirePermission('einsaetze:delete'), incidentController.deleteIncident.bind(incidentController) ); @@ -98,7 +98,7 @@ router.delete( */ router.post( '/:id/personnel', - requirePermission('incidents:manage_personnel'), + requirePermission('einsaetze:manage_personnel'), incidentController.assignPersonnel.bind(incidentController) ); @@ -109,7 +109,7 @@ router.post( */ router.delete( '/:id/personnel/:userId', - requirePermission('incidents:manage_personnel'), + requirePermission('einsaetze:manage_personnel'), incidentController.removePersonnel.bind(incidentController) ); @@ -121,7 +121,7 @@ router.delete( */ router.post( '/:id/vehicles', - requirePermission('incidents:manage_personnel'), + requirePermission('einsaetze:manage_personnel'), incidentController.assignVehicle.bind(incidentController) ); @@ -132,7 +132,7 @@ router.post( */ router.delete( '/:id/vehicles/:fahrzeugId', - requirePermission('incidents:manage_personnel'), + requirePermission('einsaetze:manage_personnel'), incidentController.removeVehicle.bind(incidentController) ); diff --git a/backend/src/routes/member.routes.ts b/backend/src/routes/member.routes.ts index 90510d8..9e6bf91 100644 --- a/backend/src/routes/member.routes.ts +++ b/backend/src/routes/member.routes.ts @@ -17,49 +17,49 @@ router.use(authenticate); // "stats" as a userId parameter. router.get( '/stats', - requirePermission('members:read'), + requirePermission('mitglieder:view'), memberController.getMemberStats.bind(memberController) ); router.get( '/', - requirePermission('members:read'), + requirePermission('mitglieder:view'), memberController.getMembers.bind(memberController) ); router.get( '/:userId', - requirePermission('members:read'), + requirePermission('mitglieder:view'), memberController.getMemberById.bind(memberController) ); router.post( '/:userId/profile', - requirePermission('members:write'), + requirePermission('mitglieder:edit'), memberController.createMemberProfile.bind(memberController) ); router.get( '/:userId/befoerderungen', - requirePermission('members:read'), + requirePermission('mitglieder:view'), memberController.getBefoerderungen.bind(memberController) ); router.get( '/:userId/untersuchungen', - requirePermission('members:read'), + requirePermission('mitglieder:view'), memberController.getUntersuchungen.bind(memberController) ); router.get( '/:userId/fahrgenehmigungen', - requirePermission('members:read'), + requirePermission('mitglieder:view'), memberController.getFahrgenehmigungen.bind(memberController) ); router.get( '/:userId/ausbildungen', - requirePermission('members:read'), + requirePermission('mitglieder:view'), memberController.getAusbildungen.bind(memberController) ); @@ -76,7 +76,7 @@ const requireOwnerOrWrite = (req: Request, res: Response, next: NextFunction): v return; } // Not the owner — must have members:write permission - requirePermission('members:write')(req, res, next); + requirePermission('mitglieder:edit')(req, res, next); }; /** diff --git a/backend/src/routes/permission.routes.ts b/backend/src/routes/permission.routes.ts new file mode 100644 index 0000000..7b154a3 --- /dev/null +++ b/backend/src/routes/permission.routes.ts @@ -0,0 +1,17 @@ +import { Router } from 'express'; +import permissionController from '../controllers/permission.controller'; +import { authenticate } from '../middleware/auth.middleware'; +import { requirePermission } from '../middleware/rbac.middleware'; + +const router = Router(); + +// ── User-facing (any authenticated user) ────────────────────────────────── +router.get('/me', authenticate, permissionController.getMyPermissions.bind(permissionController)); + +// ── Admin-only routes ───────────────────────────────────────────────────── +router.get('/admin/matrix', authenticate, requirePermission('admin:access'), permissionController.getMatrix.bind(permissionController)); +router.get('/admin/groups', authenticate, requirePermission('admin:access'), permissionController.getGroups.bind(permissionController)); +router.put('/admin/group/:groupName', authenticate, requirePermission('admin:access'), permissionController.setGroupPermissions.bind(permissionController)); +router.put('/admin/maintenance/:featureGroupId', authenticate, requirePermission('admin:access'), permissionController.setMaintenanceFlag.bind(permissionController)); + +export default router; diff --git a/backend/src/routes/training.routes.ts b/backend/src/routes/training.routes.ts index 3ca0bdb..9a7922d 100644 --- a/backend/src/routes/training.routes.ts +++ b/backend/src/routes/training.routes.ts @@ -1,14 +1,15 @@ import { Router, Request, Response, NextFunction } from 'express'; import trainingController from '../controllers/training.controller'; import { authenticate, optionalAuth } from '../middleware/auth.middleware'; -import { requirePermission, getUserRole } from '../middleware/rbac.middleware'; +import { requirePermission } from '../middleware/rbac.middleware'; +import { permissionService } from '../services/permission.service'; const router = Router(); // --------------------------------------------------------------------------- // injectTeilnahmenFlag // -// Sets req.canSeeTeilnahmen = true for Gruppenführer and above. +// Sets req.canSeeTeilnahmen = true for users with kalender:mark_attendance. // Regular Mitglieder see only attendance counts; officers see the full list. // --------------------------------------------------------------------------- @@ -19,12 +20,10 @@ async function injectTeilnahmenFlag( ): Promise { try { if (req.user) { - const role = await getUserRole(req.user.id); - const ROLE_ORDER: Record = { - bewerber: -1, mitglied: 0, gruppenfuehrer: 1, kommandant: 2, admin: 3, - }; + const groups: string[] = req.user?.groups ?? []; (req as any).canSeeTeilnahmen = - (ROLE_ORDER[role] ?? 0) >= ROLE_ORDER.gruppenfuehrer; + groups.includes('dashboard_admin') || + permissionService.hasPermission(groups, 'kalender:mark_attendance'); } } catch (_err) { // Non-fatal — default to restricted view @@ -68,12 +67,12 @@ router.get('/calendar-token', authenticate, trainingController.getCalendarToken) /** * GET /api/training/stats?year= * Annual participation stats. - * Requires Kommandant or above (requirePermission('reports:read')). + * Requires Kommandant or above (requirePermission('kalender:view_reports')). */ router.get( '/stats', authenticate, - requirePermission('reports:read'), + requirePermission('kalender:view_reports'), trainingController.getStats ); @@ -92,12 +91,12 @@ router.get( /** * POST /api/training * Create a new training event. - * Requires Gruppenführer or above (requirePermission('training:write')). + * Requires Gruppenführer or above (requirePermission('kalender:create_training')). */ router.post( '/', authenticate, - requirePermission('training:write'), + requirePermission('kalender:create_training'), trainingController.createEvent ); @@ -109,7 +108,7 @@ router.post( router.patch( '/:id', authenticate, - requirePermission('training:write'), + requirePermission('kalender:create_training'), trainingController.updateEvent ); @@ -121,7 +120,7 @@ router.patch( router.delete( '/:id', authenticate, - requirePermission('training:cancel'), + requirePermission('kalender:cancel_training'), trainingController.cancelEvent ); @@ -142,7 +141,7 @@ router.patch( router.post( '/:id/attendance/mark', authenticate, - requirePermission('training:mark_attendance'), + requirePermission('kalender:mark_attendance'), trainingController.markAttendance ); diff --git a/backend/src/routes/vehicle.routes.ts b/backend/src/routes/vehicle.routes.ts index 94759a1..b7211b1 100644 --- a/backend/src/routes/vehicle.routes.ts +++ b/backend/src/routes/vehicle.routes.ts @@ -10,19 +10,19 @@ const router = Router(); router.get('/', authenticate, vehicleController.listVehicles.bind(vehicleController)); router.get('/stats', authenticate, vehicleController.getStats.bind(vehicleController)); router.get('/alerts', authenticate, vehicleController.getAlerts.bind(vehicleController)); -router.get('/alerts/export', authenticate, requirePermission('vehicles:read'), vehicleController.exportAlerts.bind(vehicleController)); +router.get('/alerts/export', authenticate, requirePermission('fahrzeuge:view'), vehicleController.exportAlerts.bind(vehicleController)); router.get('/:id', authenticate, vehicleController.getVehicle.bind(vehicleController)); router.get('/:id/wartung', authenticate, vehicleController.getWartung.bind(vehicleController)); // ── Write — kommandant+ ────────────────────────────────────────────────────── -router.post('/', authenticate, requirePermission('vehicles:write'), vehicleController.createVehicle.bind(vehicleController)); -router.patch('/:id', authenticate, requirePermission('vehicles:write'), vehicleController.updateVehicle.bind(vehicleController)); -router.delete('/:id', authenticate, requirePermission('vehicles:delete'), vehicleController.deleteVehicle.bind(vehicleController)); +router.post('/', authenticate, requirePermission('fahrzeuge:create'), vehicleController.createVehicle.bind(vehicleController)); +router.patch('/:id', authenticate, requirePermission('fahrzeuge:create'), vehicleController.updateVehicle.bind(vehicleController)); +router.delete('/:id', authenticate, requirePermission('fahrzeuge:delete'), vehicleController.deleteVehicle.bind(vehicleController)); // ── Status + maintenance log — gruppenfuehrer+ ────────────────────────────── -router.patch('/:id/status', authenticate, requirePermission('vehicles:status'), vehicleController.updateVehicleStatus.bind(vehicleController)); -router.post('/:id/wartung', authenticate, requirePermission('vehicles:status'), vehicleController.addWartung.bind(vehicleController)); +router.patch('/:id/status', authenticate, requirePermission('fahrzeuge:change_status'), vehicleController.updateVehicleStatus.bind(vehicleController)); +router.post('/:id/wartung', authenticate, requirePermission('fahrzeuge:change_status'), vehicleController.addWartung.bind(vehicleController)); export default router; diff --git a/backend/src/server.ts b/backend/src/server.ts index 4039000..0e38617 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -4,6 +4,7 @@ import logger from './utils/logger'; import { testConnection, closePool, runMigrations } from './config/database'; import { startAuditCleanupJob, stopAuditCleanupJob } from './jobs/audit-cleanup.job'; import { startNotificationJob, stopNotificationJob } from './jobs/notification-generation.job'; +import { permissionService } from './services/permission.service'; const startServer = async (): Promise => { try { @@ -16,6 +17,9 @@ const startServer = async (): Promise => { } else { // Run pending migrations automatically on startup await runMigrations(); + + // Load permission cache after migrations + await permissionService.loadCache(); } // Start the GDPR IP anonymisation job diff --git a/backend/src/services/permission.service.ts b/backend/src/services/permission.service.ts new file mode 100644 index 0000000..9adc76d --- /dev/null +++ b/backend/src/services/permission.service.ts @@ -0,0 +1,172 @@ +import pool from '../config/database'; +import logger from '../utils/logger'; + +export interface FeatureGroupRow { + id: string; + label: string; + sort_order: number; + maintenance: boolean; +} + +export interface PermissionRow { + id: string; + feature_group_id: string; + label: string; + description: string | null; + sort_order: number; +} + +export interface MatrixData { + featureGroups: FeatureGroupRow[]; + permissions: PermissionRow[]; + groups: string[]; + grants: Record; + maintenance: Record; +} + +class PermissionService { + private groupPermissions: Map> = new Map(); + private maintenanceFlags: Map = new Map(); + + async loadCache(): Promise { + try { + // Load group permissions + const gpResult = await pool.query('SELECT authentik_group, permission_id FROM group_permissions'); + const newMap = new Map>(); + for (const row of gpResult.rows) { + if (!newMap.has(row.authentik_group)) { + newMap.set(row.authentik_group, new Set()); + } + newMap.get(row.authentik_group)!.add(row.permission_id); + } + this.groupPermissions = newMap; + + // Load maintenance flags + const mResult = await pool.query('SELECT id, maintenance FROM feature_groups'); + const newFlags = new Map(); + for (const row of mResult.rows) { + newFlags.set(row.id, row.maintenance); + } + this.maintenanceFlags = newFlags; + + logger.info('Permission cache loaded', { + groups: this.groupPermissions.size, + featureGroups: this.maintenanceFlags.size, + }); + } catch (error) { + logger.error('Failed to load permission cache', { error }); + // Don't throw — service can still function with empty cache + // dashboard_admin bypass ensures admins always have access + } + } + + getEffectivePermissions(groups: string[]): string[] { + const permSet = new Set(); + for (const group of groups) { + const perms = this.groupPermissions.get(group); + if (perms) { + for (const p of perms) { + permSet.add(p); + } + } + } + return Array.from(permSet); + } + + hasPermission(groups: string[], permission: string): boolean { + for (const group of groups) { + const perms = this.groupPermissions.get(group); + if (perms?.has(permission)) return true; + } + return false; + } + + isFeatureInMaintenance(featureGroup: string): boolean { + return this.maintenanceFlags.get(featureGroup) ?? false; + } + + getMaintenanceFlags(): Record { + const result: Record = {}; + for (const [k, v] of this.maintenanceFlags) { + result[k] = v; + } + return result; + } + + // ── Admin methods ── + + async getMatrix(): Promise { + const [fgResult, pResult, gpResult] = await Promise.all([ + pool.query('SELECT id, label, sort_order, maintenance FROM feature_groups ORDER BY sort_order'), + pool.query('SELECT id, feature_group_id, label, description, sort_order FROM permissions ORDER BY feature_group_id, sort_order'), + pool.query('SELECT authentik_group, permission_id FROM group_permissions'), + ]); + + const grants: Record = {}; + const groupSet = new Set(); + for (const row of gpResult.rows) { + groupSet.add(row.authentik_group); + if (!grants[row.authentik_group]) grants[row.authentik_group] = []; + grants[row.authentik_group].push(row.permission_id); + } + + const maintenance: Record = {}; + for (const row of fgResult.rows) { + maintenance[row.id] = row.maintenance; + } + + return { + featureGroups: fgResult.rows, + permissions: pResult.rows, + groups: Array.from(groupSet).sort(), + grants, + maintenance, + }; + } + + async getKnownGroups(): Promise { + const result = await pool.query( + 'SELECT DISTINCT authentik_group FROM group_permissions ORDER BY authentik_group' + ); + return result.rows.map((r: any) => r.authentik_group); + } + + async setGroupPermissions(group: string, permIds: string[], grantedBy: string): Promise { + const client = await pool.connect(); + try { + await client.query('BEGIN'); + // Remove all existing permissions for this group + await client.query('DELETE FROM group_permissions WHERE authentik_group = $1', [group]); + // Insert new permissions + for (const permId of permIds) { + await client.query( + 'INSERT INTO group_permissions (authentik_group, permission_id, granted_by) VALUES ($1, $2, $3) ON CONFLICT DO NOTHING', + [group, permId, grantedBy] + ); + } + await client.query('COMMIT'); + + // Reload cache + await this.loadCache(); + + logger.info('Group permissions updated', { group, permissionCount: permIds.length, grantedBy }); + } catch (error) { + await client.query('ROLLBACK'); + throw error; + } finally { + client.release(); + } + } + + async setMaintenanceFlag(featureGroup: string, active: boolean): Promise { + await pool.query( + 'UPDATE feature_groups SET maintenance = $1 WHERE id = $2', + [active, featureGroup] + ); + // Reload cache + await this.loadCache(); + logger.info('Maintenance flag updated', { featureGroup, active }); + } +} + +export const permissionService = new PermissionService(); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 2eda427..ccbafd6 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,6 +1,7 @@ import { Routes, Route } from 'react-router-dom'; import { NotificationProvider } from './contexts/NotificationContext'; import { AuthProvider } from './contexts/AuthContext'; +import { PermissionProvider } from './contexts/PermissionContext'; import ErrorBoundary from './components/shared/ErrorBoundary'; import ProtectedRoute from './components/auth/ProtectedRoute'; import LoginCallback from './components/auth/LoginCallback'; @@ -34,6 +35,7 @@ function App() { + } /> } /> @@ -232,6 +234,7 @@ function App() { /> } /> + diff --git a/frontend/src/components/admin/PermissionMatrixTab.tsx b/frontend/src/components/admin/PermissionMatrixTab.tsx new file mode 100644 index 0000000..e00e0cf --- /dev/null +++ b/frontend/src/components/admin/PermissionMatrixTab.tsx @@ -0,0 +1,294 @@ +import React, { useState, useCallback } from 'react'; +import { + Box, + Card, + CardContent, + Checkbox, + Chip, + CircularProgress, + Collapse, + FormControlLabel, + IconButton, + Switch, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + Tooltip, + Typography, +} from '@mui/material'; +import { ExpandMore, ExpandLess } from '@mui/icons-material'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { permissionsApi } from '../../services/permissions'; +import { useNotification } from '../../contexts/NotificationContext'; +import type { PermissionMatrix, FeatureGroup, Permission } from '../../types/permissions.types'; + +function PermissionMatrixTab() { + const queryClient = useQueryClient(); + const { showSuccess, showError } = useNotification(); + + const { data: matrix, isLoading } = useQuery({ + queryKey: ['admin-permission-matrix'], + queryFn: permissionsApi.getMatrix, + }); + + // Track which feature groups are expanded + const [expandedGroups, setExpandedGroups] = useState>({}); + + const toggleGroup = (groupId: string) => { + setExpandedGroups(prev => ({ ...prev, [groupId]: !prev[groupId] })); + }; + + // ── Maintenance toggle mutation ── + const maintenanceMutation = useMutation({ + mutationFn: ({ featureGroup, active }: { featureGroup: string; active: boolean }) => + permissionsApi.setMaintenanceFlag(featureGroup, active), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['admin-permission-matrix'] }); + queryClient.invalidateQueries({ queryKey: ['my-permissions'] }); + showSuccess('Wartungsmodus aktualisiert'); + }, + onError: () => showError('Fehler beim Aktualisieren des Wartungsmodus'), + }); + + // ── Permission toggle mutation (saves full group permissions) ── + const permissionMutation = useMutation({ + mutationFn: ({ group, permissions }: { group: string; permissions: string[] }) => + permissionsApi.setGroupPermissions(group, permissions), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['admin-permission-matrix'] }); + queryClient.invalidateQueries({ queryKey: ['my-permissions'] }); + showSuccess('Berechtigungen gespeichert'); + }, + onError: () => showError('Fehler beim Speichern der Berechtigungen'), + }); + + const handlePermissionToggle = useCallback( + (group: string, permId: string, currentGrants: Record) => { + const current = currentGrants[group] || []; + const newPerms = current.includes(permId) + ? current.filter(p => p !== permId) + : [...current, permId]; + permissionMutation.mutate({ group, permissions: newPerms }); + }, + [permissionMutation] + ); + + const handleSelectAllForGroup = useCallback( + ( + authentikGroup: string, + featureGroupId: string, + permissions: Permission[], + currentGrants: Record, + selectAll: boolean + ) => { + const fgPermIds = permissions + .filter(p => p.feature_group_id === featureGroupId) + .map(p => p.id); + const current = currentGrants[authentikGroup] || []; + let newPerms: string[]; + if (selectAll) { + const permSet = new Set([...current, ...fgPermIds]); + newPerms = Array.from(permSet); + } else { + const removeSet = new Set(fgPermIds); + newPerms = current.filter(p => !removeSet.has(p)); + } + permissionMutation.mutate({ group: authentikGroup, permissions: newPerms }); + }, + [permissionMutation] + ); + + if (isLoading || !matrix) { + return ( + + + + ); + } + + const { featureGroups, permissions, groups, grants, maintenance } = matrix; + const nonAdminGroups = groups.filter(g => g !== 'dashboard_admin'); + + return ( + + {/* Section 1: Maintenance Toggles */} + + + + Wartungsmodus + + + Wenn aktiviert, wird die Funktion für alle Benutzer ausser Administratoren ausgeblendet. + + {featureGroups.map((fg: FeatureGroup) => ( + + + maintenanceMutation.mutate({ + featureGroup: fg.id, + active: !(maintenance[fg.id] ?? false), + }) + } + disabled={maintenanceMutation.isPending} + /> + } + label={fg.label} + /> + {maintenance[fg.id] && ( + + )} + + ))} + + + + {/* Section 2: Permission Matrix */} + + + + Berechtigungsmatrix + + + Berechtigungen pro Authentik-Gruppe zuweisen. Die Gruppe "dashboard_admin" hat immer vollen Zugriff. + + + + + + + + Berechtigung + + {/* dashboard_admin column */} + + + dashboard_admin + + + {nonAdminGroups.map(g => ( + + {g.replace('dashboard_', '')} + + ))} + + + + {featureGroups.map((fg: FeatureGroup) => { + const fgPerms = permissions.filter((p: Permission) => p.feature_group_id === fg.id); + const isExpanded = expandedGroups[fg.id] !== false; // default expanded + + return ( + + {/* Feature group header row */} + + toggleGroup(fg.id)} + > + + + {isExpanded ? : } + + {fg.label} + {maintenance[fg.id] && ( + + )} + + + {/* Admin: all checked */} + + + + {/* Per-group: select all / deselect all */} + {nonAdminGroups.map(g => { + const groupGrants = grants[g] || []; + const allGranted = fgPerms.every((p: Permission) => groupGrants.includes(p.id)); + const someGranted = fgPerms.some((p: Permission) => groupGrants.includes(p.id)); + return ( + + + handleSelectAllForGroup(g, fg.id, permissions, grants, !allGranted) + } + disabled={permissionMutation.isPending} + size="small" + /> + + ); + })} + + + {/* Individual permission rows */} + + + +
+ + {fgPerms.map((perm: Permission) => ( + + + + {perm.label} + + + {/* Admin: always checked */} + + + + {nonAdminGroups.map(g => { + const isGranted = (grants[g] || []).includes(perm.id); + return ( + + handlePermissionToggle(g, perm.id, grants)} + disabled={permissionMutation.isPending} + size="small" + /> + + ); + })} + + ))} + +
+ + + + + ); + })} + + +
+
+
+
+ ); +} + +export default PermissionMatrixTab; diff --git a/frontend/src/components/dashboard/EventQuickAddWidget.tsx b/frontend/src/components/dashboard/EventQuickAddWidget.tsx index ebf308c..95f2add 100644 --- a/frontend/src/components/dashboard/EventQuickAddWidget.tsx +++ b/frontend/src/components/dashboard/EventQuickAddWidget.tsx @@ -8,16 +8,13 @@ import { Button, Switch, FormControlLabel, - Skeleton, } from '@mui/material'; import { CalendarMonth } from '@mui/icons-material'; import { useMutation, useQueryClient } from '@tanstack/react-query'; import { eventsApi } from '../../services/events'; import type { CreateVeranstaltungInput } from '../../types/events.types'; import { useNotification } from '../../contexts/NotificationContext'; -import { useAuth } from '../../contexts/AuthContext'; - -const WRITE_GROUPS = ['dashboard_admin', 'dashboard_kommando', 'dashboard_moderator', 'dashboard_gruppenfuehrer']; +import { usePermissionContext } from '../../contexts/PermissionContext'; function toDatetimeLocal(date: Date): string { const pad = (n: number) => String(n).padStart(2, '0'); @@ -42,8 +39,8 @@ function makeDefaults() { } const EventQuickAddWidget: React.FC = () => { - const { user } = useAuth(); - const canWrite = user?.groups?.some(g => WRITE_GROUPS.includes(g)) ?? false; + const { hasPermission } = usePermissionContext(); + const canWrite = hasPermission('kalender:create_events'); const defaults = makeDefaults(); const [titel, setTitel] = useState(''); diff --git a/frontend/src/components/shared/Sidebar.tsx b/frontend/src/components/shared/Sidebar.tsx index 91902dc..d55a551 100644 --- a/frontend/src/components/shared/Sidebar.tsx +++ b/frontend/src/components/shared/Sidebar.tsx @@ -29,7 +29,7 @@ import { import { useNavigate, useLocation } from 'react-router-dom'; import { useQuery } from '@tanstack/react-query'; import { useLayout, DRAWER_WIDTH, DRAWER_WIDTH_COLLAPSED } from '../../contexts/LayoutContext'; -import { useAuth } from '../../contexts/AuthContext'; +import { usePermissionContext } from '../../contexts/PermissionContext'; import { vehiclesApi } from '../../services/vehicles'; export { DRAWER_WIDTH, DRAWER_WIDTH_COLLAPSED }; @@ -44,6 +44,7 @@ interface NavigationItem { icon: JSX.Element; path: string; subItems?: SubItem[]; + permission?: string; } const kalenderSubItems: SubItem[] = [ @@ -58,6 +59,8 @@ const adminSubItems: SubItem[] = [ { text: 'Broadcast', path: '/admin?tab=3' }, { text: 'Banner', path: '/admin?tab=4' }, { text: 'Wartung', path: '/admin?tab=5' }, + { text: 'FDISK Sync', path: '/admin?tab=6' }, + { text: 'Berechtigungen', path: '/admin?tab=7' }, ]; const baseNavigationItems: NavigationItem[] = [ @@ -71,31 +74,37 @@ const baseNavigationItems: NavigationItem[] = [ icon: , path: '/kalender', subItems: kalenderSubItems, + permission: 'kalender:access', }, { text: 'Fahrzeuge', icon: , path: '/fahrzeuge', + permission: 'fahrzeuge:access', }, { text: 'Ausrüstung', icon: , path: '/ausruestung', + permission: 'ausruestung:access', }, { text: 'Mitglieder', icon: , path: '/mitglieder', + permission: 'mitglieder:access', }, { text: 'Atemschutz', icon: , path: '/atemschutz', + permission: 'atemschutz:access', }, { text: 'Wissen', icon: , path: '/wissen', + permission: 'wissen:access', }, ]; @@ -121,9 +130,7 @@ function Sidebar({ mobileOpen, onMobileClose }: SidebarProps) { const navigate = useNavigate(); const location = useLocation(); const { sidebarCollapsed, toggleSidebar } = useLayout(); - const { user } = useAuth(); - - const isAdmin = user?.groups?.includes('dashboard_admin') ?? false; + const { hasPermission, isAdmin } = usePermissionContext(); // Fetch vehicle list for dynamic dropdown sub-items const { data: vehicleList } = useQuery({ @@ -147,12 +154,13 @@ function Sidebar({ mobileOpen, onMobileClose }: SidebarProps) { icon: , path: '/fahrzeuge', subItems: vehicleSubItems.length > 0 ? vehicleSubItems : undefined, + permission: 'fahrzeuge:access', }; - const items = baseNavigationItems.map((item) => - item.path === '/fahrzeuge' ? fahrzeugeItem : item, - ); + const items = baseNavigationItems + .map((item) => item.path === '/fahrzeuge' ? fahrzeugeItem : item) + .filter((item) => !item.permission || hasPermission(item.permission)); return isAdmin ? [...items, adminItem, adminSettingsItem] : items; - }, [isAdmin, vehicleSubItems]); + }, [isAdmin, vehicleSubItems, hasPermission]); // Expand state for items with sub-items — auto-expand when route matches const [expandedItems, setExpandedItems] = useState>({}); diff --git a/frontend/src/contexts/PermissionContext.tsx b/frontend/src/contexts/PermissionContext.tsx new file mode 100644 index 0000000..631785f --- /dev/null +++ b/frontend/src/contexts/PermissionContext.tsx @@ -0,0 +1,98 @@ +import React, { createContext, useContext, useMemo, useCallback, ReactNode } from 'react'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { useAuth } from './AuthContext'; +import { permissionsApi } from '../services/permissions'; + +interface PermissionContextType { + permissions: Set; + maintenance: Record; + isAdmin: boolean; + isLoading: boolean; + hasPermission: (perm: string) => boolean; + hasAnyPermission: (...perms: string[]) => boolean; + isFeatureEnabled: (featureGroup: string) => boolean; + refetch: () => void; +} + +const PermissionContext = createContext(undefined); + +interface PermissionProviderProps { + children: ReactNode; +} + +export const PermissionProvider: React.FC = ({ children }) => { + const { isAuthenticated } = useAuth(); + const queryClient = useQueryClient(); + + const { data, isLoading } = useQuery({ + queryKey: ['my-permissions'], + queryFn: permissionsApi.getMyPermissions, + enabled: isAuthenticated, + staleTime: 5 * 60 * 1000, + }); + + const permissions = useMemo( + () => new Set(data?.permissions ?? []), + [data?.permissions] + ); + + const maintenance = data?.maintenance ?? {}; + const isAdmin = data?.isAdmin ?? false; + + const isFeatureEnabled = useCallback( + (featureGroup: string): boolean => { + if (isAdmin) return true; + return !maintenance[featureGroup]; + }, + [isAdmin, maintenance] + ); + + const hasPermission = useCallback( + (perm: string): boolean => { + if (isAdmin) return true; + const featureGroup = perm.split(':')[0]; + if (!isFeatureEnabled(featureGroup)) return false; + return permissions.has(perm); + }, + [isAdmin, permissions, isFeatureEnabled] + ); + + const hasAnyPermission = useCallback( + (...perms: string[]): boolean => { + return perms.some(p => hasPermission(p)); + }, + [hasPermission] + ); + + const refetch = useCallback(() => { + queryClient.invalidateQueries({ queryKey: ['my-permissions'] }); + }, [queryClient]); + + const value = useMemo( + (): PermissionContextType => ({ + permissions, + maintenance, + isAdmin, + isLoading: isAuthenticated ? isLoading : false, + hasPermission, + hasAnyPermission, + isFeatureEnabled, + refetch, + }), + [permissions, maintenance, isAdmin, isAuthenticated, isLoading, hasPermission, hasAnyPermission, isFeatureEnabled, refetch] + ); + + return ( + + {children} + + ); +}; + +export const usePermissionContext = (): PermissionContextType => { + const context = useContext(PermissionContext); + if (context === undefined) { + throw new Error('usePermissionContext must be used within a PermissionProvider'); + } + return context; +}; diff --git a/frontend/src/hooks/usePermissions.ts b/frontend/src/hooks/usePermissions.ts index 7e56a9f..c02cb3c 100644 --- a/frontend/src/hooks/usePermissions.ts +++ b/frontend/src/hooks/usePermissions.ts @@ -1,27 +1,30 @@ -import { useAuth } from '../contexts/AuthContext'; +import { usePermissionContext } from '../contexts/PermissionContext'; import { AusruestungKategorie } from '../types/equipment.types'; export function usePermissions() { - const { user } = useAuth(); - const groups = user?.groups ?? []; - - const isAdmin = groups.includes('dashboard_admin'); - const isFahrmeister = groups.includes('dashboard_fahrmeister'); - const isZeugmeister = groups.includes('dashboard_zeugmeister'); + const { hasPermission, hasAnyPermission, isAdmin, isFeatureEnabled, isLoading, permissions, maintenance, refetch } = usePermissionContext(); return { + // Core API + hasPermission, + hasAnyPermission, + isFeatureEnabled, isAdmin, - isFahrmeister, - isZeugmeister, - canChangeStatus: isAdmin || isFahrmeister || isZeugmeister, - canManageEquipment: isAdmin || isFahrmeister || isZeugmeister, - canManageMotorizedEquipment: isAdmin || isFahrmeister, - canManageNonMotorizedEquipment: isAdmin || isZeugmeister, - canManageCategory: (kategorie: AusruestungKategorie | null | undefined): boolean => { - if (isAdmin) return true; - if (!kategorie) return false; - return kategorie.motorisiert ? isFahrmeister : isZeugmeister; + isLoading, + permissions, + maintenance, + refetch, + + // Backward-compatible convenience flags + isFahrmeister: false, // No longer needed — use hasPermission() instead + isZeugmeister: false, // No longer needed — use hasPermission() instead + canChangeStatus: hasPermission('fahrzeuge:change_status'), + canManageEquipment: hasPermission('ausruestung:create'), + canManageMotorizedEquipment: hasPermission('ausruestung:create'), + canManageNonMotorizedEquipment: hasPermission('ausruestung:create'), + canManageCategory: (_kategorie: AusruestungKategorie | null | undefined): boolean => { + return hasPermission('ausruestung:create'); }, - groups, + groups: [] as string[], // Deprecated — use hasPermission() instead }; } diff --git a/frontend/src/pages/AdminDashboard.tsx b/frontend/src/pages/AdminDashboard.tsx index 5d14254..3b9da14 100644 --- a/frontend/src/pages/AdminDashboard.tsx +++ b/frontend/src/pages/AdminDashboard.tsx @@ -9,6 +9,7 @@ import NotificationBroadcastTab from '../components/admin/NotificationBroadcastT import BannerManagementTab from '../components/admin/BannerManagementTab'; import ServiceModeTab from '../components/admin/ServiceModeTab'; import FdiskSyncTab from '../components/admin/FdiskSyncTab'; +import PermissionMatrixTab from '../components/admin/PermissionMatrixTab'; import { useAuth } from '../contexts/AuthContext'; interface TabPanelProps { @@ -22,7 +23,7 @@ function TabPanel({ children, value, index }: TabPanelProps) { return {children}; } -const ADMIN_TAB_COUNT = 7; +const ADMIN_TAB_COUNT = 8; function AdminDashboard() { const navigate = useNavigate(); @@ -57,6 +58,7 @@ function AdminDashboard() { + @@ -81,6 +83,9 @@ function AdminDashboard() { + + + ); } diff --git a/frontend/src/pages/Atemschutz.tsx b/frontend/src/pages/Atemschutz.tsx index 43ffa73..a43bc06 100644 --- a/frontend/src/pages/Atemschutz.tsx +++ b/frontend/src/pages/Atemschutz.tsx @@ -45,7 +45,7 @@ import ChatAwareFab from '../components/shared/ChatAwareFab'; import { atemschutzApi } from '../services/atemschutz'; import { membersService } from '../services/members'; import { useNotification } from '../contexts/NotificationContext'; -import { useAuth } from '../contexts/AuthContext'; +import { usePermissionContext } from '../contexts/PermissionContext'; import { toGermanDate, fromGermanDate, isValidGermanDate } from '../utils/dateInput'; import type { AtemschutzUebersicht, @@ -142,10 +142,9 @@ const StatCard: React.FC = ({ label, value, color, bgcolor }) => function Atemschutz() { const notification = useNotification(); - const { user } = useAuth(); - const ATEMSCHUTZ_PRIVILEGED = ['dashboard_admin', 'dashboard_kommando', 'dashboard_atemschutz', 'dashboard_moderator']; - const canViewAll = user?.groups?.some(g => ATEMSCHUTZ_PRIVILEGED.includes(g)) ?? false; - const canWrite = canViewAll; + const { hasPermission } = usePermissionContext(); + const canViewAll = hasPermission('atemschutz:view'); + const canWrite = hasPermission('atemschutz:create'); // Data state const [traeger, setTraeger] = useState([]); diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index 35b76b2..020af34 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -6,6 +6,7 @@ import { } from '@mui/material'; import { useQuery } from '@tanstack/react-query'; import { useAuth } from '../contexts/AuthContext'; +import { usePermissionContext } from '../contexts/PermissionContext'; import DashboardLayout from '../components/dashboard/DashboardLayout'; import SkeletonCard from '../components/shared/SkeletonCard'; import UserProfile from '../components/dashboard/UserProfile'; @@ -33,13 +34,7 @@ import { WidgetKey } from '../constants/widgets'; function Dashboard() { const { user } = useAuth(); - const isAdmin = user?.groups?.includes('dashboard_admin') ?? false; - const canViewAtemschutz = user?.groups?.some(g => - ['dashboard_admin', 'dashboard_kommando', 'dashboard_atemschutz', 'dashboard_moderator'].includes(g) - ) ?? false; - const canWrite = user?.groups?.some(g => - ['dashboard_admin', 'dashboard_kommando', 'dashboard_moderator', 'dashboard_gruppenfuehrer'].includes(g) - ) ?? false; + const { hasPermission, isAdmin } = usePermissionContext(); const [dataLoading, setDataLoading] = useState(true); const { data: preferences } = useQuery({ @@ -120,7 +115,7 @@ function Dashboard() { )} - {canViewAtemschutz && widgetVisible('atemschutz') && ( + {hasPermission('atemschutz:widget') && widgetVisible('atemschutz') && ( @@ -163,7 +158,7 @@ function Dashboard() { )} - {canWrite && widgetVisible('eventQuickAdd') && ( + {hasPermission('kalender:widget_quick_add') && widgetVisible('eventQuickAdd') && ( diff --git a/frontend/src/pages/Einsaetze.tsx b/frontend/src/pages/Einsaetze.tsx index 03b33df..c2a631c 100644 --- a/frontend/src/pages/Einsaetze.tsx +++ b/frontend/src/pages/Einsaetze.tsx @@ -49,7 +49,7 @@ import { EINSATZ_STATUS_LABELS, } from '../services/incidents'; import CreateEinsatzDialog from '../components/incidents/CreateEinsatzDialog'; -import { useAuth } from '../contexts/AuthContext'; +import { usePermissionContext } from '../contexts/PermissionContext'; // --------------------------------------------------------------------------- // COLOUR MAP for Einsatzart chips @@ -176,10 +176,8 @@ function StatsSummaryBar({ stats, loading }: StatsSummaryProps) { // --------------------------------------------------------------------------- function Einsaetze() { const navigate = useNavigate(); - const { user } = useAuth(); - const canWrite = user?.groups?.some((g: string) => - ['dashboard_admin', 'dashboard_kommando', 'dashboard_gruppenfuehrer'].includes(g) - ) ?? false; + const { hasPermission } = usePermissionContext(); + const canWrite = hasPermission('einsaetze:create'); // List state const [items, setItems] = useState([]); diff --git a/frontend/src/pages/EinsatzDetail.tsx b/frontend/src/pages/EinsatzDetail.tsx index c9ffd25..2790345 100644 --- a/frontend/src/pages/EinsatzDetail.tsx +++ b/frontend/src/pages/EinsatzDetail.tsx @@ -43,7 +43,7 @@ import { EinsatzArt, } from '../services/incidents'; import { useNotification } from '../contexts/NotificationContext'; -import { useAuth } from '../contexts/AuthContext'; +import { usePermissionContext } from '../contexts/PermissionContext'; // --------------------------------------------------------------------------- // COLOUR MAPS @@ -165,10 +165,8 @@ function EinsatzDetail() { const { id } = useParams<{ id: string }>(); const navigate = useNavigate(); const notification = useNotification(); - const { user } = useAuth(); - const canWrite = user?.groups?.some((g: string) => - ['dashboard_admin', 'dashboard_kommando', 'dashboard_gruppenfuehrer'].includes(g) - ) ?? false; + const { hasPermission } = usePermissionContext(); + const canWrite = hasPermission('einsaetze:create'); const [einsatz, setEinsatz] = useState(null); const [loading, setLoading] = useState(true); diff --git a/frontend/src/pages/FahrzeugBuchungen.tsx b/frontend/src/pages/FahrzeugBuchungen.tsx index dbf3677..f172c84 100644 --- a/frontend/src/pages/FahrzeugBuchungen.tsx +++ b/frontend/src/pages/FahrzeugBuchungen.tsx @@ -46,6 +46,7 @@ import { import DashboardLayout from '../components/dashboard/DashboardLayout'; import ChatAwareFab from '../components/shared/ChatAwareFab'; import { useAuth } from '../contexts/AuthContext'; +import { usePermissionContext } from '../contexts/PermissionContext'; import { useNotification } from '../contexts/NotificationContext'; import { bookingApi, fetchVehicles } from '../services/bookings'; import type { @@ -85,21 +86,17 @@ const EMPTY_FORM: CreateBuchungInput = { kontaktTelefon: '', }; -const WRITE_GROUPS = ['dashboard_admin', 'dashboard_fahrmeister', 'dashboard_moderator']; -const MANAGE_ART_GROUPS = ['dashboard_admin', 'dashboard_moderator']; - // --------------------------------------------------------------------------- // Main Page // --------------------------------------------------------------------------- function FahrzeugBuchungen() { const { user } = useAuth(); + const { hasPermission } = usePermissionContext(); const notification = useNotification(); - const canCreate = !!user; // All authenticated users can create bookings - const canWrite = - user?.groups?.some((g) => WRITE_GROUPS.includes(g)) ?? false; // Can edit/cancel - const canChangeBuchungsArt = - user?.groups?.some((g) => MANAGE_ART_GROUPS.includes(g)) ?? false; // Can change booking type + const canCreate = hasPermission('kalender:create_bookings'); + const canWrite = hasPermission('kalender:edit_bookings'); + const canChangeBuchungsArt = hasPermission('kalender:manage_categories'); // ── Week navigation ──────────────────────────────────────────────────────── const [currentWeekStart, setCurrentWeekStart] = useState(() => diff --git a/frontend/src/pages/Kalender.tsx b/frontend/src/pages/Kalender.tsx index 4594101..c213af9 100644 --- a/frontend/src/pages/Kalender.tsx +++ b/frontend/src/pages/Kalender.tsx @@ -72,6 +72,7 @@ import DashboardLayout from '../components/dashboard/DashboardLayout'; import ChatAwareFab from '../components/shared/ChatAwareFab'; import { toGermanDateTime, fromGermanDate, fromGermanDateTime, isValidGermanDateTime } from '../utils/dateInput'; import { useAuth } from '../contexts/AuthContext'; +import { usePermissionContext } from '../contexts/PermissionContext'; import { useNotification } from '../contexts/NotificationContext'; import { trainingApi } from '../services/training'; import { eventsApi } from '../services/events'; @@ -117,9 +118,6 @@ import { de } from 'date-fns/locale'; // Constants // ────────────────────────────────────────────────────────────────────────────── -const WRITE_GROUPS_EVENTS = ['dashboard_admin', 'dashboard_moderator']; -const WRITE_GROUPS_BOOKINGS = ['dashboard_admin', 'dashboard_fahrmeister', 'dashboard_moderator']; - const WEEKDAY_LABELS = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So']; const MONTH_LABELS = [ 'Januar', 'Februar', 'März', 'April', 'Mai', 'Juni', @@ -1704,15 +1702,14 @@ export default function Kalender() { const navigate = useNavigate(); const [searchParams] = useSearchParams(); const { user } = useAuth(); + const { hasPermission } = usePermissionContext(); const notification = useNotification(); const theme = useTheme(); const isMobile = useMediaQuery(theme.breakpoints.down('sm')); - const canWriteEvents = - user?.groups?.some((g) => WRITE_GROUPS_EVENTS.includes(g)) ?? false; - const canWriteBookings = - user?.groups?.some((g) => WRITE_GROUPS_BOOKINGS.includes(g)) ?? false; - const canCreateBookings = !!user; + const canWriteEvents = hasPermission('kalender:create_events'); + const canWriteBookings = hasPermission('kalender:edit_bookings'); + const canCreateBookings = hasPermission('kalender:create_bookings'); // ── Tab ───────────────────────────────────────────────────────────────────── const [activeTab, setActiveTab] = useState(() => { diff --git a/frontend/src/pages/MitgliedDetail.tsx b/frontend/src/pages/MitgliedDetail.tsx index dbba153..6fe5398 100644 --- a/frontend/src/pages/MitgliedDetail.tsx +++ b/frontend/src/pages/MitgliedDetail.tsx @@ -40,6 +40,7 @@ import { import { useParams, useNavigate } from 'react-router-dom'; import DashboardLayout from '../components/dashboard/DashboardLayout'; import { useAuth } from '../contexts/AuthContext'; +import { usePermissionContext } from '../contexts/PermissionContext'; import { membersService } from '../services/members'; import { atemschutzApi } from '../services/atemschutz'; import { toGermanDate, fromGermanDate } from '../utils/dateInput'; @@ -67,9 +68,8 @@ import { UntersuchungErgebnisLabel } from '../types/atemschutz.types'; // Role helpers // ---------------------------------------------------------------- function useCanWrite(): boolean { - const { user } = useAuth(); - const groups: string[] = (user as any)?.groups ?? []; - return groups.includes('dashboard_admin') || groups.includes('dashboard_kommando'); + const { hasPermission } = usePermissionContext(); + return hasPermission('mitglieder:edit'); } function useCurrentUserId(): string | undefined { diff --git a/frontend/src/pages/Mitglieder.tsx b/frontend/src/pages/Mitglieder.tsx index 4038e41..7480889 100644 --- a/frontend/src/pages/Mitglieder.tsx +++ b/frontend/src/pages/Mitglieder.tsx @@ -34,6 +34,7 @@ import { useNavigate } from 'react-router-dom'; import DashboardLayout from '../components/dashboard/DashboardLayout'; import ChatAwareFab from '../components/shared/ChatAwareFab'; import { useAuth } from '../contexts/AuthContext'; +import { usePermissionContext } from '../contexts/PermissionContext'; import { membersService } from '../services/members'; import { MemberListItem, @@ -51,9 +52,8 @@ import { // Helper: determine whether the current user can write member data // ---------------------------------------------------------------- function useCanWrite(): boolean { - const { user } = useAuth(); - const groups: string[] = (user as any)?.groups ?? []; - return groups.includes('dashboard_admin') || groups.includes('dashboard_kommando'); + const { hasPermission } = usePermissionContext(); + return hasPermission('mitglieder:edit'); } // ---------------------------------------------------------------- @@ -73,17 +73,17 @@ function useDebounce(value: T, delay: number): T { // ---------------------------------------------------------------- function Mitglieder() { const navigate = useNavigate(); - const { user } = useAuth(); const canWrite = useCanWrite(); + const { user } = useAuth(); + const canWrite = useCanWrite(); + const { hasPermission } = usePermissionContext(); - // --- redirect non-admin/non-kommando users to their own profile --- + // --- redirect non-privileged users to their own profile --- useEffect(() => { if (!user) return; - const groups: string[] = (user as any)?.groups ?? []; - const isAdmin = groups.includes('dashboard_admin') || groups.includes('dashboard_kommando'); - if (!isAdmin) { + if (!hasPermission('mitglieder:edit')) { navigate(`/mitglieder/${(user as any).id}`, { replace: true }); } - }, [user, navigate]); + }, [user, navigate, hasPermission]); // --- data state --- const [members, setMembers] = useState([]); diff --git a/frontend/src/pages/VeranstaltungKategorien.tsx b/frontend/src/pages/VeranstaltungKategorien.tsx index 0fb6f9e..06abcab 100644 --- a/frontend/src/pages/VeranstaltungKategorien.tsx +++ b/frontend/src/pages/VeranstaltungKategorien.tsx @@ -34,7 +34,7 @@ import { Category as CategoryIcon, } from '@mui/icons-material'; import DashboardLayout from '../components/dashboard/DashboardLayout'; -import { useAuth } from '../contexts/AuthContext'; +import { usePermissionContext } from '../contexts/PermissionContext'; import { useNotification } from '../contexts/NotificationContext'; import { eventsApi } from '../services/events'; import type { VeranstaltungKategorie, GroupInfo } from '../types/events.types'; @@ -298,10 +298,9 @@ function DeleteDialog({ open, kategorie, onClose, onDeleted }: DeleteDialogProps // --------------------------------------------------------------------------- export default function VeranstaltungKategorien() { - const { user } = useAuth(); + const { hasPermission } = usePermissionContext(); - const canManage = - user?.groups?.some((g) => ['dashboard_admin', 'dashboard_moderator'].includes(g)) ?? false; + const canManage = hasPermission('kalender:manage_categories'); const [kategorien, setKategorien] = useState([]); const [groups, setGroups] = useState([]); diff --git a/frontend/src/pages/Veranstaltungen.tsx b/frontend/src/pages/Veranstaltungen.tsx index eccd496..821437e 100644 --- a/frontend/src/pages/Veranstaltungen.tsx +++ b/frontend/src/pages/Veranstaltungen.tsx @@ -52,7 +52,7 @@ import { import DashboardLayout from '../components/dashboard/DashboardLayout'; import ChatAwareFab from '../components/shared/ChatAwareFab'; import { toGermanDate, toGermanDateTime, fromGermanDate, fromGermanDateTime } from '../utils/dateInput'; -import { useAuth } from '../contexts/AuthContext'; +import { usePermissionContext } from '../contexts/PermissionContext'; import { useNotification } from '../contexts/NotificationContext'; import { eventsApi } from '../services/events'; import type { @@ -1069,13 +1069,12 @@ function EventListView({ events, canWrite, onEdit, onCancel, onDelete }: ListVie // --------------------------------------------------------------------------- export default function Veranstaltungen() { - const { user } = useAuth(); + const { hasPermission } = usePermissionContext(); const notification = useNotification(); const theme = useTheme(); const isMobile = useMediaQuery(theme.breakpoints.down('sm')); - const canWrite = - user?.groups?.some((g) => ['dashboard_admin', 'dashboard_moderator'].includes(g)) ?? false; + const canWrite = hasPermission('kalender:create_events'); const today = new Date(); const [viewMonth, setViewMonth] = useState({ year: today.getFullYear(), month: today.getMonth() }); diff --git a/frontend/src/services/permissions.ts b/frontend/src/services/permissions.ts new file mode 100644 index 0000000..4ab7614 --- /dev/null +++ b/frontend/src/services/permissions.ts @@ -0,0 +1,27 @@ +import { api } from './api'; +import type { MyPermissions, PermissionMatrix } from '../types/permissions.types'; + +export const permissionsApi = { + getMyPermissions: async (): Promise => { + const r = await api.get('/api/permissions/me'); + return r.data.data; + }, + + getMatrix: async (): Promise => { + const r = await api.get('/api/permissions/admin/matrix'); + return r.data.data; + }, + + getGroups: async (): Promise => { + const r = await api.get('/api/permissions/admin/groups'); + return r.data.data; + }, + + setGroupPermissions: async (group: string, permissions: string[]): Promise => { + await api.put(`/api/permissions/admin/group/${encodeURIComponent(group)}`, { permissions }); + }, + + setMaintenanceFlag: async (featureGroup: string, active: boolean): Promise => { + await api.put(`/api/permissions/admin/maintenance/${encodeURIComponent(featureGroup)}`, { active }); + }, +}; diff --git a/frontend/src/types/permissions.types.ts b/frontend/src/types/permissions.types.ts new file mode 100644 index 0000000..34d669d --- /dev/null +++ b/frontend/src/types/permissions.types.ts @@ -0,0 +1,28 @@ +export interface FeatureGroup { + id: string; + label: string; + sort_order: number; + maintenance: boolean; +} + +export interface Permission { + id: string; + feature_group_id: string; + label: string; + description?: string; + sort_order: number; +} + +export interface MyPermissions { + permissions: string[]; + maintenance: Record; + isAdmin: boolean; +} + +export interface PermissionMatrix { + featureGroups: FeatureGroup[]; + permissions: Permission[]; + groups: string[]; + grants: Record; + maintenance: Record; +}