From 515f14956ec0f48563b987f73b06e677ad769d73 Mon Sep 17 00:00:00 2001 From: Matthias Hochmeister Date: Mon, 23 Mar 2026 10:50:52 +0100 Subject: [PATCH] rights system --- backend/src/controllers/booking.controller.ts | 12 +- .../src/controllers/permission.controller.ts | 14 + .../037_create_permission_system.sql | 344 ++++++------- backend/src/middleware/rbac.middleware.ts | 20 - backend/src/routes/admin.routes.ts | 8 +- backend/src/routes/banner.routes.ts | 2 +- backend/src/routes/events.routes.ts | 10 +- backend/src/routes/member.routes.ts | 14 +- backend/src/routes/permission.routes.ts | 9 +- backend/src/routes/serviceMonitor.routes.ts | 2 +- backend/src/routes/settings.routes.ts | 2 +- backend/src/routes/training.routes.ts | 8 +- backend/src/services/permission.service.ts | 13 + .../components/admin/PermissionMatrixTab.tsx | 450 +++++++++++++++--- .../dashboard/EventQuickAddWidget.tsx | 2 +- .../src/components/dashboard/WidgetGroup.tsx | 6 + frontend/src/components/shared/Sidebar.tsx | 18 +- frontend/src/pages/AdminDashboard.tsx | 8 +- frontend/src/pages/Dashboard.tsx | 36 +- frontend/src/pages/FahrzeugBuchungen.tsx | 3 +- frontend/src/pages/Kalender.tsx | 2 +- frontend/src/pages/Mitglieder.tsx | 2 +- frontend/src/pages/Veranstaltungen.tsx | 2 +- frontend/src/services/permissions.ts | 5 + 24 files changed, 629 insertions(+), 363 deletions(-) diff --git a/backend/src/controllers/booking.controller.ts b/backend/src/controllers/booking.controller.ts index 7afd4ae..0fbc6e8 100644 --- a/backend/src/controllers/booking.controller.ts +++ b/backend/src/controllers/booking.controller.ts @@ -2,7 +2,7 @@ import { Request, Response } from 'express'; import { ZodError } from 'zod'; import bookingService from '../services/booking.service'; import vehicleService from '../services/vehicle.service'; -import { hasPermission, resolveRequestRole } from '../middleware/rbac.middleware'; +import { permissionService } from '../services/permission.service'; import { CreateBuchungSchema, UpdateBuchungSchema, @@ -217,15 +217,19 @@ class BookingController { return; } - // Check ownership: creator can always cancel their own booking + // Check ownership: creator can cancel if they have cancel_own_bookings permission const booking = await bookingService.getById(id); if (!booking) { res.status(404).json({ success: false, message: 'Buchung nicht gefunden' }); return; } const isOwner = booking.gebucht_von === req.user!.id; - const role = resolveRequestRole(req); - if (!isOwner && !hasPermission(role, 'bookings:write')) { + const groups: string[] = req.user?.groups ?? []; + const isAdmin = groups.includes('dashboard_admin'); + const canCancelOwn = isAdmin || permissionService.hasPermission(groups, 'kalender:cancel_own_bookings'); + const canCancelAny = isAdmin || permissionService.hasPermission(groups, 'kalender:delete_bookings'); + + if (!(isOwner && canCancelOwn) && !canCancelAny) { res.status(403).json({ success: false, message: 'Keine Berechtigung' }); return; } diff --git a/backend/src/controllers/permission.controller.ts b/backend/src/controllers/permission.controller.ts index f4eeb32..5cf5c0e 100644 --- a/backend/src/controllers/permission.controller.ts +++ b/backend/src/controllers/permission.controller.ts @@ -90,6 +90,20 @@ class PermissionController { } } + /** + * GET /api/admin/permissions/unknown-groups + * Returns Authentik groups found in users table but not in the permission matrix. + */ + async getUnknownGroups(_req: Request, res: Response): Promise { + try { + const groups = await permissionService.getUnknownGroups(); + res.json({ success: true, data: groups }); + } catch (error) { + logger.error('Failed to get unknown groups', { error }); + res.status(500).json({ success: false, message: 'Fehler beim Laden der unbekannten Gruppen' }); + } + } + /** * PUT /api/admin/permissions/maintenance/:featureGroupId * Toggles maintenance mode for a feature group. diff --git a/backend/src/database/migrations/037_create_permission_system.sql b/backend/src/database/migrations/037_create_permission_system.sql index 213c7ef..989a5de 100644 --- a/backend/src/database/migrations/037_create_permission_system.sql +++ b/backend/src/database/migrations/037_create_permission_system.sql @@ -1,11 +1,20 @@ -- Migration 037: DB-driven permission system -- Replaces hardcoded RBAC with per-Authentik-group permission assignments +-- DROP + recreate is safe because this feature has not been deployed yet. + +-- ═══════════════════════════════════════════════════════════════════════════ +-- 0. Clean slate +-- ═══════════════════════════════════════════════════════════════════════════ + +DROP TABLE IF EXISTS group_permissions CASCADE; +DROP TABLE IF EXISTS permissions CASCADE; +DROP TABLE IF EXISTS feature_groups CASCADE; -- ═══════════════════════════════════════════════════════════════════════════ -- 1. Feature Groups -- ═══════════════════════════════════════════════════════════════════════════ -CREATE TABLE IF NOT EXISTS feature_groups ( +CREATE TABLE feature_groups ( id VARCHAR(50) PRIMARY KEY, label VARCHAR(100) NOT NULL, sort_order INT NOT NULL DEFAULT 0, @@ -21,15 +30,15 @@ INSERT INTO feature_groups (id, label, sort_order) VALUES ('atemschutz', 'Atemschutz', 6), ('wissen', 'Wissen', 7), ('vikunja', 'Vikunja', 8), - ('nextcloud', 'Nextcloud', 9), - ('dashboard', 'Dashboard', 10) + ('dashboard', 'Dashboard', 9), + ('admin', 'Admin', 10) ON CONFLICT (id) DO NOTHING; -- ═══════════════════════════════════════════════════════════════════════════ -- 2. Permissions -- ═══════════════════════════════════════════════════════════════════════════ -CREATE TABLE IF NOT EXISTS permissions ( +CREATE TABLE 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, @@ -39,90 +48,77 @@ CREATE TABLE IF NOT EXISTS permissions ( -- 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) + ('kalender:view', 'kalender', 'Ansehen', 'Kalender einsehen (Termine, Übungen, Buchungen)', 1), + ('kalender:create', 'kalender', 'Erstellen', 'Termine und Übungen erstellen', 2), + ('kalender:cancel', 'kalender', 'Absagen', 'Termine und Übungen absagen', 3), + ('kalender:mark_attendance', 'kalender', 'Anwesenheit eintragen', 'Teilnahme bestätigen', 4), + ('kalender:create_bookings', 'kalender', 'Buchungen erstellen', 'Neue Fahrzeugbuchungen anlegen', 5), + ('kalender:edit_bookings', 'kalender', 'Buchungen bearbeiten', 'Bestehende Buchungen ändern', 6), + ('kalender:cancel_own_bookings','kalender', 'Eigene Buchungen stornieren','Eigene Buchungen stornieren', 7), + ('kalender:delete_bookings', 'kalender', 'Alle Buchungen stornieren/löschen', 'Alle Buchungen stornieren oder löschen', 8), + ('kalender:manage_categories', 'kalender', 'Kategorien verwalten', 'Veranstaltungskategorien verwalten', 9), + ('kalender:view_reports', 'kalender', 'Berichte ansehen', 'Übungsstatistiken und Berichte einsehen', 10), + ('kalender:widget_events', 'kalender', 'Widget: Termine', 'Dashboard-Widget für Termine', 11), + ('kalender:widget_bookings', 'kalender', 'Widget: Buchungen', 'Dashboard-Widget für Buchungen', 12), + ('kalender:widget_quick_add', 'kalender', 'Widget: Schnell-Termin', 'Dashboard-Widget zum schnellen Erstellen', 13) 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) + ('fahrzeuge:view', 'fahrzeuge', 'Ansehen', 'Fahrzeugdetails einsehen', 1), + ('fahrzeuge:create', 'fahrzeuge', 'Erstellen', 'Neue Fahrzeuge anlegen', 2), + ('fahrzeuge:change_status', 'fahrzeuge', 'Status ändern', 'Fahrzeugstatus ändern', 3), + ('fahrzeuge:manage_maintenance', 'fahrzeuge', 'Wartung verwalten', 'Wartungseinträge erstellen/bearbeiten', 4), + ('fahrzeuge:delete', 'fahrzeuge', 'Löschen', 'Fahrzeuge löschen', 5), + ('fahrzeuge:widget', 'fahrzeuge', 'Widget', 'Dashboard-Widget für Fahrzeuge', 6) 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) + ('einsaetze:view', 'einsaetze', 'Ansehen', 'Einsätze einsehen', 1), + ('einsaetze:view_reports', 'einsaetze', 'Berichte ansehen', 'Einsatzberichte/Berichtstext einsehen', 2), + ('einsaetze:create', 'einsaetze', 'Erstellen', 'Neue Einsätze anlegen und bearbeiten', 3), + ('einsaetze:delete', 'einsaetze', 'Löschen', 'Einsätze archivieren/löschen', 4), + ('einsaetze:manage_personnel', 'einsaetze', 'Personal verwalten', 'Einsatzpersonal und Fahrzeuge zuweisen', 5) 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) + ('ausruestung:view', 'ausruestung', 'Ansehen', 'Ausrüstung einsehen', 1), + ('ausruestung:create', 'ausruestung', 'Erstellen', 'Neue Ausrüstung anlegen und bearbeiten', 2), + ('ausruestung:manage_maintenance', 'ausruestung', 'Wartung verwalten', 'Wartungseinträge verwalten', 3), + ('ausruestung:delete', 'ausruestung', 'Löschen', 'Ausrüstung löschen', 4), + ('ausruestung:widget', 'ausruestung', 'Widget', 'Dashboard-Widget für Ausrüstung', 5) 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) + ('mitglieder:view_own', 'mitglieder', 'Eigenes Profil', 'Eigenes Profil einsehen', 1), + ('mitglieder:view_all', 'mitglieder', 'Alle Profile', 'Alle 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) + ('atemschutz:view', 'atemschutz', 'Ansehen', 'Atemschutz-Daten aller Träger sehen', 1), + ('atemschutz:create', 'atemschutz', 'Erstellen', 'Atemschutz-Einträge anlegen/ändern', 2), + ('atemschutz:delete', 'atemschutz', 'Löschen', 'Atemschutz-Einträge löschen', 3), + ('atemschutz:widget', 'atemschutz', 'Widget', 'Dashboard-Widget für Atemschutz', 4) 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) + ('wissen:view', 'wissen', 'Ansehen', '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) + ('vikunja:create_tasks', 'vikunja', 'Aufgaben erstellen', 'Neue Vikunja-Aufgaben erstellen', 1), + ('vikunja:widget_tasks', 'vikunja', 'Widget: Aufgaben', 'Dashboard-Widget für Vikunja-Aufgaben', 2), + ('vikunja:widget_quick_add', 'vikunja', 'Widget: Schnell-Task', 'Dashboard-Widget zum schnellen Erstellen', 3) ON CONFLICT (id) DO NOTHING; -- Dashboard permissions @@ -131,11 +127,17 @@ INSERT INTO permissions (id, feature_group_id, label, description, sort_order) V ('dashboard:widget_banner', 'dashboard', 'Widget: Banner', 'Dashboard-Widget für Banner', 2) ON CONFLICT (id) DO NOTHING; +-- Admin permissions +INSERT INTO permissions (id, feature_group_id, label, description, sort_order) VALUES + ('admin:view', 'admin', 'Ansehen', 'Admin-Panel einsehen', 1), + ('admin:write', 'admin', 'Bearbeiten', 'Admin-Einstellungen ändern', 2) +ON CONFLICT (id) DO NOTHING; + -- ═══════════════════════════════════════════════════════════════════════════ -- 3. Group Permissions -- ═══════════════════════════════════════════════════════════════════════════ -CREATE TABLE IF NOT EXISTS group_permissions ( +CREATE TABLE 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(), @@ -143,296 +145,245 @@ CREATE TABLE IF NOT EXISTS group_permissions ( PRIMARY KEY (authentik_group, permission_id) ); -CREATE INDEX IF NOT EXISTS idx_group_permissions_group ON group_permissions(authentik_group); +CREATE INDEX idx_group_permissions_group ON group_permissions(authentik_group); -- ═══════════════════════════════════════════════════════════════════════════ -- 4. Seed data — replicate current RBAC behavior -- ═══════════════════════════════════════════════════════════════════════════ +-- NOTE: dashboard_admin is NOT seeded — it has hardwired full access in code. --- 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) +-- ── dashboard_kommando — near-full access ── 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'), + -- Kalender (ALL) + ('dashboard_kommando', 'kalender:view'), + ('dashboard_kommando', 'kalender:create'), + ('dashboard_kommando', 'kalender:cancel'), ('dashboard_kommando', 'kalender:mark_attendance'), ('dashboard_kommando', 'kalender:create_bookings'), ('dashboard_kommando', 'kalender:edit_bookings'), + ('dashboard_kommando', 'kalender:cancel_own_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'), + -- Fahrzeuge (ALL) ('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'), + -- Einsätze (ALL) ('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'), + -- Ausrüstung (ALL) ('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'), + -- Mitglieder (ALL) + ('dashboard_kommando', 'mitglieder:view_own'), + ('dashboard_kommando', 'mitglieder:view_all'), ('dashboard_kommando', 'mitglieder:edit'), ('dashboard_kommando', 'mitglieder:create_profile'), - -- Atemschutz - ('dashboard_kommando', 'atemschutz:access'), + -- Atemschutz (ALL) ('dashboard_kommando', 'atemschutz:view'), ('dashboard_kommando', 'atemschutz:create'), ('dashboard_kommando', 'atemschutz:delete'), ('dashboard_kommando', 'atemschutz:widget'), - -- Wissen - ('dashboard_kommando', 'wissen:access'), + -- Wissen (ALL) + ('dashboard_kommando', 'wissen:view'), ('dashboard_kommando', 'wissen:widget_recent'), ('dashboard_kommando', 'wissen:widget_search'), - -- Vikunja - ('dashboard_kommando', 'vikunja:access'), + -- Vikunja (ALL) ('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 (ALL) ('dashboard_kommando', 'dashboard:widget_links'), - ('dashboard_kommando', 'dashboard:widget_banner') + ('dashboard_kommando', 'dashboard:widget_banner'), + -- Admin (view only) + ('dashboard_kommando', 'admin:view') ON CONFLICT DO NOTHING; --- dashboard_gruppenfuehrer — write-level for most features +-- ── dashboard_gruppenfuehrer — write level for most ── 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:view'), + ('dashboard_gruppenfuehrer', 'kalender:create'), ('dashboard_gruppenfuehrer', 'kalender:mark_attendance'), ('dashboard_gruppenfuehrer', 'kalender:create_bookings'), ('dashboard_gruppenfuehrer', 'kalender:edit_bookings'), + ('dashboard_gruppenfuehrer', 'kalender:cancel_own_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'), + ('dashboard_gruppenfuehrer', 'mitglieder:view_own'), + ('dashboard_gruppenfuehrer', 'mitglieder:view_all'), -- 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:view'), ('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 +-- ── 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:view'), ('dashboard_fahrmeister', 'kalender:create_bookings'), ('dashboard_fahrmeister', 'kalender:edit_bookings'), + ('dashboard_fahrmeister', 'kalender:cancel_own_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'), + ('dashboard_fahrmeister', 'mitglieder:view_own'), + ('dashboard_fahrmeister', 'mitglieder:view_all'), -- Atemschutz - ('dashboard_fahrmeister', 'atemschutz:access'), ('dashboard_fahrmeister', 'atemschutz:widget'), -- Wissen - ('dashboard_fahrmeister', 'wissen:access'), + ('dashboard_fahrmeister', 'wissen:view'), ('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 +-- ── 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:view'), ('dashboard_zeugmeister', 'kalender:create_bookings'), + ('dashboard_zeugmeister', 'kalender:cancel_own_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'), + ('dashboard_zeugmeister', 'mitglieder:view_own'), + ('dashboard_zeugmeister', 'mitglieder:view_all'), -- Atemschutz - ('dashboard_zeugmeister', 'atemschutz:access'), ('dashboard_zeugmeister', 'atemschutz:widget'), -- Wissen - ('dashboard_zeugmeister', 'wissen:access'), + ('dashboard_zeugmeister', 'wissen:view'), ('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 +-- ── dashboard_chargen — mid level ── 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:view'), ('dashboard_chargen', 'kalender:create_bookings'), + ('dashboard_chargen', 'kalender:cancel_own_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'), + ('dashboard_chargen', 'mitglieder:view_own'), + ('dashboard_chargen', 'mitglieder:view_all'), -- 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:view'), ('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 +-- ── dashboard_moderator — event/calendar management ── 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:view'), + ('dashboard_moderator', 'kalender:create'), + ('dashboard_moderator', 'kalender:cancel_own_bookings'), ('dashboard_moderator', 'kalender:create_bookings'), ('dashboard_moderator', 'kalender:edit_bookings'), ('dashboard_moderator', 'kalender:manage_categories'), @@ -440,123 +391,96 @@ INSERT INTO group_permissions (authentik_group, permission_id) VALUES ('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'), + ('dashboard_moderator', 'mitglieder:view_own'), + ('dashboard_moderator', 'mitglieder:view_all'), -- Atemschutz - ('dashboard_moderator', 'atemschutz:access'), ('dashboard_moderator', 'atemschutz:view'), ('dashboard_moderator', 'atemschutz:widget'), -- Wissen - ('dashboard_moderator', 'wissen:access'), + ('dashboard_moderator', 'wissen:view'), ('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 +-- ── 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'), + -- Kalender + ('dashboard_atemschutz', 'kalender:view'), ('dashboard_atemschutz', 'kalender:create_bookings'), + ('dashboard_atemschutz', 'kalender:cancel_own_bookings'), ('dashboard_atemschutz', 'kalender:widget_events'), ('dashboard_atemschutz', 'kalender:widget_bookings'), - -- Fahrzeuge (read) - ('dashboard_atemschutz', 'fahrzeuge:access'), + -- Fahrzeuge ('dashboard_atemschutz', 'fahrzeuge:view'), ('dashboard_atemschutz', 'fahrzeuge:widget'), - -- Einsätze (read) - ('dashboard_atemschutz', 'einsaetze:access'), + -- Einsätze ('dashboard_atemschutz', 'einsaetze:view'), - -- Ausrüstung (read) - ('dashboard_atemschutz', 'ausruestung:access'), + -- Ausrüstung ('dashboard_atemschutz', 'ausruestung:view'), ('dashboard_atemschutz', 'ausruestung:widget'), - -- Mitglieder (read) - ('dashboard_atemschutz', 'mitglieder:access'), - ('dashboard_atemschutz', 'mitglieder:view'), - -- Atemschutz (full) - ('dashboard_atemschutz', 'atemschutz:access'), + -- Mitglieder + ('dashboard_atemschutz', 'mitglieder:view_own'), + ('dashboard_atemschutz', 'mitglieder:view_all'), + -- Atemschutz ('dashboard_atemschutz', 'atemschutz:view'), ('dashboard_atemschutz', 'atemschutz:create'), ('dashboard_atemschutz', 'atemschutz:widget'), -- Wissen - ('dashboard_atemschutz', 'wissen:access'), + ('dashboard_atemschutz', 'wissen:view'), ('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) +-- ── dashboard_mitglied — basic member ── 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:view'), ('dashboard_mitglied', 'kalender:create_bookings'), + ('dashboard_mitglied', 'kalender:cancel_own_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'), + ('dashboard_mitglied', 'mitglieder:view_own'), -- Atemschutz - ('dashboard_mitglied', 'atemschutz:access'), ('dashboard_mitglied', 'atemschutz:widget'), -- Wissen - ('dashboard_mitglied', 'wissen:access'), + ('dashboard_mitglied', 'wissen:view'), ('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') diff --git a/backend/src/middleware/rbac.middleware.ts b/backend/src/middleware/rbac.middleware.ts index 55bd0c4..aea7f71 100644 --- a/backend/src/middleware/rbac.middleware.ts +++ b/backend/src/middleware/rbac.middleware.ts @@ -20,7 +20,6 @@ export type AppRole = * * 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) { @@ -44,25 +43,6 @@ export function requirePermission(permission: string) { 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, - permission, - path: req.path, - }); - - auditPermissionDenied(req, AuditResourceType.SYSTEM, undefined, { - required_permission: permission, - }); - - 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)) { diff --git a/backend/src/routes/admin.routes.ts b/backend/src/routes/admin.routes.ts index 665d740..fc6b05d 100644 --- a/backend/src/routes/admin.routes.ts +++ b/backend/src/routes/admin.routes.ts @@ -85,7 +85,7 @@ function parseAuditQuery(query: Record): AuditFilters { router.get( '/audit-log', authenticate, - requirePermission('admin:access'), + requirePermission('admin:view'), async (req: Request, res: Response): Promise => { try { const filters = parseAuditQuery(req.query as Record); @@ -122,7 +122,7 @@ router.get( router.get( '/audit-log/export', authenticate, - requirePermission('admin:access'), + requirePermission('admin:view'), async (req: Request, res: Response): Promise => { try { // For CSV exports we fetch up to 10,000 rows (no pagination). @@ -176,7 +176,7 @@ const FDISK_SYNC_URL = process.env.FDISK_SYNC_URL ?? ''; router.get( '/fdisk-sync/logs', authenticate, - requirePermission('admin:access'), + requirePermission('admin:view'), async (_req: Request, res: Response): Promise => { if (!FDISK_SYNC_URL) { res.status(503).json({ success: false, message: 'FDISK sync service not configured' }); @@ -194,7 +194,7 @@ router.get( router.post( '/fdisk-sync/trigger', authenticate, - requirePermission('admin:access'), + requirePermission('admin:view'), async (req: Request, res: Response): Promise => { if (!FDISK_SYNC_URL) { res.status(503).json({ success: false, message: 'FDISK sync service not configured' }); diff --git a/backend/src/routes/banner.routes.ts b/backend/src/routes/banner.routes.ts index 4e65732..4c699f3 100644 --- a/backend/src/routes/banner.routes.ts +++ b/backend/src/routes/banner.routes.ts @@ -4,7 +4,7 @@ import { authenticate } from '../middleware/auth.middleware'; import { requirePermission } from '../middleware/rbac.middleware'; const router = Router(); -const adminAuth = [authenticate, requirePermission('admin:access')] as const; +const adminAuth = [authenticate, requirePermission('admin:write')] as const; // Public (authenticated): get active banners router.get('/active', authenticate, bannerController.getActive.bind(bannerController)); diff --git a/backend/src/routes/events.routes.ts b/backend/src/routes/events.routes.ts index 6178b69..baf3238 100644 --- a/backend/src/routes/events.routes.ts +++ b/backend/src/routes/events.routes.ts @@ -118,7 +118,7 @@ router.get( router.post( '/import', authenticate, - requirePermission('kalender:create_events'), + requirePermission('kalender:create'), eventsController.importEvents.bind(eventsController) ); @@ -129,7 +129,7 @@ router.post( router.post( '/', authenticate, - requirePermission('kalender:create_events'), + requirePermission('kalender:create'), eventsController.createEvent.bind(eventsController) ); @@ -146,7 +146,7 @@ router.get('/:id', authenticate, eventsController.getById.bind(eventsController) router.patch( '/:id', authenticate, - requirePermission('kalender:create_events'), + requirePermission('kalender:create'), eventsController.updateEvent.bind(eventsController) ); @@ -157,7 +157,7 @@ router.patch( router.delete( '/:id', authenticate, - requirePermission('kalender:create_events'), + requirePermission('kalender:create'), eventsController.cancelEvent.bind(eventsController) ); @@ -168,7 +168,7 @@ router.delete( router.post( '/:id/delete', authenticate, - requirePermission('kalender:create_events'), + requirePermission('kalender:create'), eventsController.deleteEvent.bind(eventsController) ); diff --git a/backend/src/routes/member.routes.ts b/backend/src/routes/member.routes.ts index 9e6bf91..a28fa55 100644 --- a/backend/src/routes/member.routes.ts +++ b/backend/src/routes/member.routes.ts @@ -17,19 +17,19 @@ router.use(authenticate); // "stats" as a userId parameter. router.get( '/stats', - requirePermission('mitglieder:view'), + requirePermission('mitglieder:view_all'), memberController.getMemberStats.bind(memberController) ); router.get( '/', - requirePermission('mitglieder:view'), + requirePermission('mitglieder:view_all'), memberController.getMembers.bind(memberController) ); router.get( '/:userId', - requirePermission('mitglieder:view'), + requirePermission('mitglieder:view_all'), memberController.getMemberById.bind(memberController) ); @@ -41,25 +41,25 @@ router.post( router.get( '/:userId/befoerderungen', - requirePermission('mitglieder:view'), + requirePermission('mitglieder:view_all'), memberController.getBefoerderungen.bind(memberController) ); router.get( '/:userId/untersuchungen', - requirePermission('mitglieder:view'), + requirePermission('mitglieder:view_all'), memberController.getUntersuchungen.bind(memberController) ); router.get( '/:userId/fahrgenehmigungen', - requirePermission('mitglieder:view'), + requirePermission('mitglieder:view_all'), memberController.getFahrgenehmigungen.bind(memberController) ); router.get( '/:userId/ausbildungen', - requirePermission('mitglieder:view'), + requirePermission('mitglieder:view_all'), memberController.getAusbildungen.bind(memberController) ); diff --git a/backend/src/routes/permission.routes.ts b/backend/src/routes/permission.routes.ts index 7b154a3..133420e 100644 --- a/backend/src/routes/permission.routes.ts +++ b/backend/src/routes/permission.routes.ts @@ -9,9 +9,10 @@ const router = Router(); 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)); +router.get('/admin/matrix', authenticate, requirePermission('admin:view'), permissionController.getMatrix.bind(permissionController)); +router.get('/admin/groups', authenticate, requirePermission('admin:view'), permissionController.getGroups.bind(permissionController)); +router.get('/admin/unknown-groups', authenticate, requirePermission('admin:view'), permissionController.getUnknownGroups.bind(permissionController)); +router.put('/admin/group/:groupName', authenticate, requirePermission('admin:write'), permissionController.setGroupPermissions.bind(permissionController)); +router.put('/admin/maintenance/:featureGroupId', authenticate, requirePermission('admin:write'), permissionController.setMaintenanceFlag.bind(permissionController)); export default router; diff --git a/backend/src/routes/serviceMonitor.routes.ts b/backend/src/routes/serviceMonitor.routes.ts index 088ee63..f02fd06 100644 --- a/backend/src/routes/serviceMonitor.routes.ts +++ b/backend/src/routes/serviceMonitor.routes.ts @@ -4,7 +4,7 @@ import { authenticate } from '../middleware/auth.middleware'; import { requirePermission } from '../middleware/rbac.middleware'; const router = Router(); -const auth = [authenticate, requirePermission('admin:access')] as const; +const auth = [authenticate, requirePermission('admin:view')] as const; // Static routes first (before parameterized :id routes) router.get('/services/ping', ...auth, serviceMonitorController.pingAll.bind(serviceMonitorController)); diff --git a/backend/src/routes/settings.routes.ts b/backend/src/routes/settings.routes.ts index 3657f45..73dc0d4 100644 --- a/backend/src/routes/settings.routes.ts +++ b/backend/src/routes/settings.routes.ts @@ -4,7 +4,7 @@ import { authenticate } from '../middleware/auth.middleware'; import { requirePermission } from '../middleware/rbac.middleware'; const router = Router(); -const auth = [authenticate, requirePermission('admin:access')] as const; +const auth = [authenticate, requirePermission('admin:write')] as const; router.get('/', ...auth, settingsController.getAll.bind(settingsController)); router.get('/preferences', authenticate, settingsController.getUserPreferences.bind(settingsController)); diff --git a/backend/src/routes/training.routes.ts b/backend/src/routes/training.routes.ts index 9a7922d..88af479 100644 --- a/backend/src/routes/training.routes.ts +++ b/backend/src/routes/training.routes.ts @@ -91,12 +91,12 @@ router.get( /** * POST /api/training * Create a new training event. - * Requires Gruppenführer or above (requirePermission('kalender:create_training')). + * Requires Gruppenführer or above (requirePermission('kalender:create')). */ router.post( '/', authenticate, - requirePermission('kalender:create_training'), + requirePermission('kalender:create'), trainingController.createEvent ); @@ -108,7 +108,7 @@ router.post( router.patch( '/:id', authenticate, - requirePermission('kalender:create_training'), + requirePermission('kalender:create'), trainingController.updateEvent ); @@ -120,7 +120,7 @@ router.patch( router.delete( '/:id', authenticate, - requirePermission('kalender:cancel_training'), + requirePermission('kalender:cancel'), trainingController.cancelEvent ); diff --git a/backend/src/services/permission.service.ts b/backend/src/services/permission.service.ts index 9adc76d..2703fb3 100644 --- a/backend/src/services/permission.service.ts +++ b/backend/src/services/permission.service.ts @@ -131,6 +131,19 @@ class PermissionService { return result.rows.map((r: any) => r.authentik_group); } + async getUnknownGroups(): Promise { + // Groups from users table that are not yet in the permission matrix + const result = await pool.query(` + SELECT DISTINCT g AS group_name + FROM users, unnest(authentik_groups) AS g + WHERE g LIKE 'dashboard_%' + AND g NOT IN (SELECT DISTINCT authentik_group FROM group_permissions) + AND g != 'dashboard_admin' + ORDER BY group_name + `); + return result.rows.map((r: any) => r.group_name); + } + async setGroupPermissions(group: string, permIds: string[], grantedBy: string): Promise { const client = await pool.connect(); try { diff --git a/frontend/src/components/admin/PermissionMatrixTab.tsx b/frontend/src/components/admin/PermissionMatrixTab.tsx index e00e0cf..85389a1 100644 --- a/frontend/src/components/admin/PermissionMatrixTab.tsx +++ b/frontend/src/components/admin/PermissionMatrixTab.tsx @@ -1,6 +1,8 @@ -import React, { useState, useCallback } from 'react'; +import React, { useState, useCallback, useMemo } from 'react'; import { + Alert, Box, + Button, Card, CardContent, Checkbox, @@ -19,12 +21,141 @@ import { Tooltip, Typography, } from '@mui/material'; -import { ExpandMore, ExpandLess } from '@mui/icons-material'; +import { ExpandMore, ExpandLess, Add as AddIcon } 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'; +// ── Permission dependency map ── +// Each permission lists its prerequisites (must also be granted). +const PERMISSION_DEPS: Record = { + // kalender + 'kalender:create': ['kalender:view'], + 'kalender:cancel': ['kalender:view'], + 'kalender:mark_attendance': ['kalender:view'], + 'kalender:create_bookings': ['kalender:view'], + 'kalender:edit_bookings': ['kalender:view', 'kalender:create_bookings'], + 'kalender:cancel_own_bookings': ['kalender:view'], + 'kalender:delete_bookings': ['kalender:view', 'kalender:edit_bookings'], + 'kalender:manage_categories': ['kalender:view'], + 'kalender:view_reports': ['kalender:view'], + 'kalender:widget_events': ['kalender:view'], + 'kalender:widget_bookings': ['kalender:view'], + 'kalender:widget_quick_add': ['kalender:view', 'kalender:create'], + // fahrzeuge + 'fahrzeuge:create': ['fahrzeuge:view'], + 'fahrzeuge:change_status': ['fahrzeuge:view'], + 'fahrzeuge:manage_maintenance': ['fahrzeuge:view'], + 'fahrzeuge:delete': ['fahrzeuge:view', 'fahrzeuge:create'], + 'fahrzeuge:widget': ['fahrzeuge:view'], + // einsaetze + 'einsaetze:view_reports': ['einsaetze:view'], + 'einsaetze:create': ['einsaetze:view'], + 'einsaetze:delete': ['einsaetze:view', 'einsaetze:create'], + 'einsaetze:manage_personnel': ['einsaetze:view'], + // ausruestung + 'ausruestung:create': ['ausruestung:view'], + 'ausruestung:manage_maintenance': ['ausruestung:view'], + 'ausruestung:delete': ['ausruestung:view', 'ausruestung:create'], + 'ausruestung:widget': ['ausruestung:view'], + // mitglieder + 'mitglieder:view_all': ['mitglieder:view_own'], + 'mitglieder:edit': ['mitglieder:view_own', 'mitglieder:view_all'], + 'mitglieder:create_profile': ['mitglieder:view_own', 'mitglieder:view_all', 'mitglieder:edit'], + // atemschutz + 'atemschutz:create': ['atemschutz:view'], + 'atemschutz:delete': ['atemschutz:view', 'atemschutz:create'], + 'atemschutz:widget': ['atemschutz:view'], + // wissen + 'wissen:widget_recent': ['wissen:view'], + 'wissen:widget_search': ['wissen:view'], + // admin + 'admin:write': ['admin:view'], +}; + +// ── Group hierarchy ── +// When a permission is granted to a group, it is also auto-granted to all listed groups. +const GROUP_HIERARCHY: Record = { + 'dashboard_mitglied': ['dashboard_chargen', 'dashboard_atemschutz', 'dashboard_moderator', 'dashboard_zeugmeister', 'dashboard_fahrmeister', 'dashboard_gruppenfuehrer', 'dashboard_kommando'], + 'dashboard_chargen': ['dashboard_gruppenfuehrer', 'dashboard_kommando'], + 'dashboard_atemschutz': ['dashboard_kommando'], + 'dashboard_moderator': ['dashboard_kommando'], + 'dashboard_zeugmeister': ['dashboard_kommando'], + 'dashboard_fahrmeister': ['dashboard_kommando'], + 'dashboard_gruppenfuehrer': ['dashboard_kommando'], + 'dashboard_kommando': [], +}; + +// Build reverse hierarchy: for each group, which groups propagate DOWN to it +// i.e. if group X lists Y in its hierarchy, then removing from Y should remove from X +function buildReverseHierarchy(): Record { + const reverse: Record = {}; + for (const [group, inheritors] of Object.entries(GROUP_HIERARCHY)) { + for (const inheritor of inheritors) { + if (!reverse[inheritor]) reverse[inheritor] = []; + reverse[inheritor].push(group); + } + } + return reverse; +} + +const REVERSE_HIERARCHY = buildReverseHierarchy(); + +// ── Dependency helpers ── + +/** Recursively collect all prerequisite permissions for `permId`. */ +function collectAllDeps(permId: string, visited = new Set()): Set { + if (visited.has(permId)) return visited; + visited.add(permId); + const deps = PERMISSION_DEPS[permId] || []; + for (const dep of deps) { + collectAllDeps(dep, visited); + } + return visited; +} + +/** Build a reverse dependency map: for each permission, which permissions depend on it. */ +function buildReverseDeps(): Record { + const rev: Record = {}; + for (const [perm, deps] of Object.entries(PERMISSION_DEPS)) { + for (const dep of deps) { + if (!rev[dep]) rev[dep] = []; + rev[dep].push(perm); + } + } + return rev; +} + +const REVERSE_DEPS = buildReverseDeps(); + +/** Recursively collect all permissions that depend on `permId`. */ +function collectAllDependents(permId: string, visited = new Set()): Set { + if (visited.has(permId)) return visited; + visited.add(permId); + const dependents = REVERSE_DEPS[permId] || []; + for (const dep of dependents) { + collectAllDependents(dep, visited); + } + return visited; +} + +/** Add a permission with all its deps to a set. */ +function addPermWithDeps(current: Set, permId: string): Set { + const allNeeded = collectAllDeps(permId); + for (const p of allNeeded) current.add(p); + return current; +} + +/** Remove a permission and all its dependents from a set. */ +function removePermWithDependents(current: Set, permId: string): Set { + const allToRemove = collectAllDependents(permId); + for (const p of allToRemove) current.delete(p); + return current; +} + +// ── Component ── + function PermissionMatrixTab() { const queryClient = useQueryClient(); const { showSuccess, showError } = useNotification(); @@ -34,14 +165,18 @@ function PermissionMatrixTab() { queryFn: permissionsApi.getMatrix, }); - // Track which feature groups are expanded + const { data: unknownGroups } = useQuery({ + queryKey: ['admin-unknown-groups'], + queryFn: permissionsApi.getUnknownGroups, + }); + const [expandedGroups, setExpandedGroups] = useState>({}); const toggleGroup = (groupId: string) => { setExpandedGroups(prev => ({ ...prev, [groupId]: !prev[groupId] })); }; - // ── Maintenance toggle mutation ── + // ── Maintenance toggle ── const maintenanceMutation = useMutation({ mutationFn: ({ featureGroup, active }: { featureGroup: string; active: boolean }) => permissionsApi.setMaintenanceFlag(featureGroup, active), @@ -53,10 +188,10 @@ function PermissionMatrixTab() { onError: () => showError('Fehler beim Aktualisieren des Wartungsmodus'), }); - // ── Permission toggle mutation (saves full group permissions) ── + // ── Permission save (saves full group permissions) ── const permissionMutation = useMutation({ - mutationFn: ({ group, permissions }: { group: string; permissions: string[] }) => - permissionsApi.setGroupPermissions(group, permissions), + mutationFn: (updates: { group: string; permissions: string[] }[]) => + Promise.all(updates.map(u => permissionsApi.setGroupPermissions(u.group, u.permissions))), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['admin-permission-matrix'] }); queryClient.invalidateQueries({ queryKey: ['my-permissions'] }); @@ -65,40 +200,185 @@ function PermissionMatrixTab() { 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 }); + // ── Add unknown group ── + const addGroupMutation = useMutation({ + mutationFn: (groupName: string) => permissionsApi.setGroupPermissions(groupName, []), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['admin-permission-matrix'] }); + queryClient.invalidateQueries({ queryKey: ['admin-unknown-groups'] }); + showSuccess('Gruppe hinzugefügt'); }, - [permissionMutation] + onError: () => showError('Fehler beim Hinzufügen der Gruppe'), + }); + + // ── Compute affected groups when toggling a permission ── + const computeUpdates = useCallback( + ( + group: string, + permId: string, + grants: Record, + allGroups: string[], + ): { group: string; permissions: string[] }[] => { + const currentPerms = new Set(grants[group] || []); + const isAdding = !currentPerms.has(permId); + const updates: { group: string; permissions: string[] }[] = []; + + if (isAdding) { + // Add perm + deps to this group + const newPerms = new Set(currentPerms); + addPermWithDeps(newPerms, permId); + updates.push({ group, permissions: Array.from(newPerms) }); + + // Hierarchy: also add to all groups that should inherit upward + const inheritors = GROUP_HIERARCHY[group] || []; + for (const inheritor of inheritors) { + if (!allGroups.includes(inheritor) || inheritor === 'dashboard_admin') continue; + const inhPerms = new Set(grants[inheritor] || []); + const beforeSize = inhPerms.size; + addPermWithDeps(inhPerms, permId); + if (inhPerms.size !== beforeSize || !inhPerms.has(permId)) { + // Only push if something changed + inhPerms.add(permId); // ensure the perm itself is there + // Re-add deps just in case + const allNeeded = collectAllDeps(permId); + for (const p of allNeeded) inhPerms.add(p); + updates.push({ group: inheritor, permissions: Array.from(inhPerms) }); + } + } + // Deduplicate: if a group already in updates, skip + const seen = new Set(); + return updates.filter(u => { + if (seen.has(u.group)) return false; + seen.add(u.group); + return true; + }); + } else { + // Remove perm + dependents from this group + const newPerms = new Set(currentPerms); + removePermWithDependents(newPerms, permId); + updates.push({ group, permissions: Array.from(newPerms) }); + + // Hierarchy: also remove from all groups that are LOWER (reverse hierarchy) + // i.e. groups for which this group appears in their hierarchy list + const lowerGroups = REVERSE_HIERARCHY[group] || []; + for (const lower of lowerGroups) { + if (!allGroups.includes(lower) || lower === 'dashboard_admin') continue; + const lowerPerms = new Set(grants[lower] || []); + const beforeSize = lowerPerms.size; + const hadPerm = lowerPerms.has(permId); + removePermWithDependents(lowerPerms, permId); + if (lowerPerms.size !== beforeSize || hadPerm) { + updates.push({ group: lower, permissions: Array.from(lowerPerms) }); + } + } + const seen = new Set(); + return updates.filter(u => { + if (seen.has(u.group)) return false; + seen.add(u.group); + return true; + }); + } + }, + [], + ); + + const handlePermissionToggle = useCallback( + (group: string, permId: string, grants: Record, allGroups: string[]) => { + const updates = computeUpdates(group, permId, grants, allGroups); + if (updates.length > 0) { + permissionMutation.mutate(updates); + } + }, + [computeUpdates, permissionMutation], ); const handleSelectAllForGroup = useCallback( ( authentikGroup: string, featureGroupId: string, - permissions: Permission[], - currentGrants: Record, - selectAll: boolean + allPermissions: Permission[], + grants: Record, + allGroups: string[], + selectAll: boolean, ) => { - const fgPermIds = permissions + const fgPermIds = allPermissions .filter(p => p.feature_group_id === featureGroupId) .map(p => p.id); - const current = currentGrants[authentikGroup] || []; - let newPerms: string[]; + + // Build combined updates across all affected groups + const allUpdates = new Map>(); + + // Initialize with current grants for potentially affected groups + const initGroup = (g: string) => { + if (!allUpdates.has(g)) { + allUpdates.set(g, new Set(grants[g] || [])); + } + }; + + initGroup(authentikGroup); + if (selectAll) { - const permSet = new Set([...current, ...fgPermIds]); - newPerms = Array.from(permSet); + // Add all feature group perms with deps + for (const permId of fgPermIds) { + addPermWithDeps(allUpdates.get(authentikGroup)!, permId); + // Hierarchy upward + const inheritors = GROUP_HIERARCHY[authentikGroup] || []; + for (const inheritor of inheritors) { + if (!allGroups.includes(inheritor) || inheritor === 'dashboard_admin') continue; + initGroup(inheritor); + addPermWithDeps(allUpdates.get(inheritor)!, permId); + } + } } else { - const removeSet = new Set(fgPermIds); - newPerms = current.filter(p => !removeSet.has(p)); + // Remove all feature group perms with dependents + for (const permId of fgPermIds) { + removePermWithDependents(allUpdates.get(authentikGroup)!, permId); + // Hierarchy downward + const lowerGroups = REVERSE_HIERARCHY[authentikGroup] || []; + for (const lower of lowerGroups) { + if (!allGroups.includes(lower) || lower === 'dashboard_admin') continue; + initGroup(lower); + removePermWithDependents(allUpdates.get(lower)!, permId); + } + } + } + + // Build final update list, only groups that actually changed + const updates: { group: string; permissions: string[] }[] = []; + for (const [g, perms] of allUpdates) { + const original = new Set(grants[g] || []); + const newArr = Array.from(perms); + if (newArr.length !== original.size || newArr.some(p => !original.has(p))) { + updates.push({ group: g, permissions: newArr }); + } + } + + if (updates.length > 0) { + permissionMutation.mutate(updates); } - permissionMutation.mutate({ group: authentikGroup, permissions: newPerms }); }, - [permissionMutation] + [permissionMutation], + ); + + // ── All known permission IDs for dependency tooltip ── + const allPermissionIds = useMemo( + () => (matrix ? new Set(matrix.permissions.map(p => p.id)) : new Set()), + [matrix], + ); + + const getDepTooltip = useCallback( + (permId: string): string => { + const deps = PERMISSION_DEPS[permId]; + if (!deps || deps.length === 0) return ''; + const labels = deps + .filter(d => allPermissionIds.has(d)) + .map(d => { + const p = matrix?.permissions.find(p => p.id === d); + return p ? p.label : d; + }); + return labels.length > 0 ? `Benötigt: ${labels.join(', ')}` : ''; + }, + [allPermissionIds, matrix], ); if (isLoading || !matrix) { @@ -114,6 +394,29 @@ function PermissionMatrixTab() { return ( + {/* Unknown Groups Alert */} + {unknownGroups && unknownGroups.length > 0 && ( + + + Folgende Gruppen wurden in der Benutzertabelle gefunden, sind aber noch nicht in der Berechtigungsmatrix: + + + {unknownGroups.map(g => ( + + ))} + + + )} + {/* Section 1: Maintenance Toggles */} @@ -162,13 +465,22 @@ function PermissionMatrixTab() { - + Berechtigung {/* dashboard_admin column */} - dashboard_admin + admin {nonAdminGroups.map(g => ( @@ -215,7 +527,7 @@ function PermissionMatrixTab() { {/* Per-group: select all / deselect all */} {nonAdminGroups.map(g => { const groupGrants = grants[g] || []; - const allGranted = fgPerms.every((p: Permission) => groupGrants.includes(p.id)); + const allGranted = fgPerms.length > 0 && fgPerms.every((p: Permission) => groupGrants.includes(p.id)); const someGranted = fgPerms.some((p: Permission) => groupGrants.includes(p.id)); return ( @@ -223,7 +535,7 @@ function PermissionMatrixTab() { checked={allGranted} indeterminate={someGranted && !allGranted} onChange={() => - handleSelectAllForGroup(g, fg.id, permissions, grants, !allGranted) + handleSelectAllForGroup(g, fg.id, permissions, grants, groups, !allGranted) } disabled={permissionMutation.isPending} size="small" @@ -239,41 +551,47 @@ function PermissionMatrixTab() {
- {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" - /> - - ); - })} - - ))} + {fgPerms.map((perm: Permission) => { + const depTooltip = getDepTooltip(perm.id); + const tooltipText = [perm.description, depTooltip].filter(Boolean).join('\n'); + return ( + + + + {perm.label} + + + {/* Admin: always checked */} + + + + {nonAdminGroups.map(g => { + const isGranted = (grants[g] || []).includes(perm.id); + return ( + + + handlePermissionToggle(g, perm.id, grants, groups) + } + disabled={permissionMutation.isPending} + size="small" + /> + + ); + })} + + ); + })}
diff --git a/frontend/src/components/dashboard/EventQuickAddWidget.tsx b/frontend/src/components/dashboard/EventQuickAddWidget.tsx index 95f2add..b66f702 100644 --- a/frontend/src/components/dashboard/EventQuickAddWidget.tsx +++ b/frontend/src/components/dashboard/EventQuickAddWidget.tsx @@ -40,7 +40,7 @@ function makeDefaults() { const EventQuickAddWidget: React.FC = () => { const { hasPermission } = usePermissionContext(); - const canWrite = hasPermission('kalender:create_events'); + const canWrite = hasPermission('kalender:create'); const defaults = makeDefaults(); const [titel, setTitel] = useState(''); diff --git a/frontend/src/components/dashboard/WidgetGroup.tsx b/frontend/src/components/dashboard/WidgetGroup.tsx index 634a57e..e735f9b 100644 --- a/frontend/src/components/dashboard/WidgetGroup.tsx +++ b/frontend/src/components/dashboard/WidgetGroup.tsx @@ -1,3 +1,4 @@ +import React from 'react'; import { Box, Typography } from '@mui/material'; interface WidgetGroupProps { @@ -7,6 +8,11 @@ interface WidgetGroupProps { } function WidgetGroup({ title, children, gridColumn }: WidgetGroupProps) { + // Count non-null children to hide empty groups + const validChildren = React.Children.toArray(children).filter(Boolean); + + if (validChildren.length === 0) return null; + return ( , path: '/kalender', subItems: kalenderSubItems, - permission: 'kalender:access', + permission: 'kalender:view', }, { text: 'Fahrzeuge', @@ -86,25 +86,25 @@ const baseNavigationItems: NavigationItem[] = [ text: 'Ausrüstung', icon: , path: '/ausruestung', - permission: 'ausruestung:access', + permission: 'ausruestung:view', }, { text: 'Mitglieder', icon: , path: '/mitglieder', - permission: 'mitglieder:access', + permission: 'mitglieder:view_own', }, { text: 'Atemschutz', icon: , path: '/atemschutz', - permission: 'atemschutz:access', + permission: 'atemschutz:view', }, { text: 'Wissen', icon: , path: '/wissen', - permission: 'wissen:access', + permission: 'wissen:view', }, ]; @@ -130,7 +130,7 @@ function Sidebar({ mobileOpen, onMobileClose }: SidebarProps) { const navigate = useNavigate(); const location = useLocation(); const { sidebarCollapsed, toggleSidebar } = useLayout(); - const { hasPermission, isAdmin } = usePermissionContext(); + const { hasPermission } = usePermissionContext(); // Fetch vehicle list for dynamic dropdown sub-items const { data: vehicleList } = useQuery({ @@ -154,13 +154,13 @@ function Sidebar({ mobileOpen, onMobileClose }: SidebarProps) { icon: , path: '/fahrzeuge', subItems: vehicleSubItems.length > 0 ? vehicleSubItems : undefined, - permission: 'fahrzeuge:access', + permission: 'fahrzeuge:view', }; 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, hasPermission]); + return hasPermission('admin:view') ? [...items, adminItem, adminSettingsItem] : items; + }, [vehicleSubItems, hasPermission]); // Expand state for items with sub-items — auto-expand when route matches const [expandedItems, setExpandedItems] = useState>({}); diff --git a/frontend/src/pages/AdminDashboard.tsx b/frontend/src/pages/AdminDashboard.tsx index 3b9da14..2a5c370 100644 --- a/frontend/src/pages/AdminDashboard.tsx +++ b/frontend/src/pages/AdminDashboard.tsx @@ -10,7 +10,7 @@ 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'; +import { usePermissionContext } from '../contexts/PermissionContext'; interface TabPanelProps { children: React.ReactNode; @@ -37,11 +37,9 @@ function AdminDashboard() { const t = Number(searchParams.get('tab')); if (t >= 0 && t < ADMIN_TAB_COUNT) setTab(t); }, [searchParams]); - const { user } = useAuth(); + const { hasPermission } = usePermissionContext(); - const isAdmin = user?.groups?.includes('dashboard_admin') ?? false; - - if (!isAdmin) { + if (!hasPermission('admin:view')) { return ; } diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index 020af34..06c1102 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -34,7 +34,7 @@ import { WidgetKey } from '../constants/widgets'; function Dashboard() { const { user } = useAuth(); - const { hasPermission, isAdmin } = usePermissionContext(); + const { hasPermission } = usePermissionContext(); const [dataLoading, setDataLoading] = useState(true); const { data: preferences } = useQuery({ @@ -99,7 +99,7 @@ function Dashboard() { {/* Status Group */} - {widgetVisible('vehicles') && ( + {hasPermission('fahrzeuge:widget') && widgetVisible('vehicles') && ( @@ -107,7 +107,7 @@ function Dashboard() { )} - {widgetVisible('equipment') && ( + {hasPermission('ausruestung:widget') && widgetVisible('equipment') && ( @@ -123,7 +123,7 @@ function Dashboard() { )} - {isAdmin && widgetVisible('adminStatus') && ( + {hasPermission('admin:view') && widgetVisible('adminStatus') && ( @@ -134,7 +134,7 @@ function Dashboard() { {/* Kalender Group */} - {widgetVisible('events') && ( + {hasPermission('kalender:widget_events') && widgetVisible('events') && ( @@ -142,7 +142,7 @@ function Dashboard() { )} - {widgetVisible('vehicleBookingList') && ( + {hasPermission('kalender:widget_bookings') && widgetVisible('vehicleBookingList') && ( @@ -150,7 +150,7 @@ function Dashboard() { )} - {widgetVisible('vehicleBooking') && ( + {hasPermission('kalender:create_bookings') && widgetVisible('vehicleBooking') && ( @@ -169,7 +169,7 @@ function Dashboard() { {/* Dienste Group */} - {widgetVisible('bookstackRecent') && ( + {hasPermission('wissen:widget_recent') && widgetVisible('bookstackRecent') && ( @@ -177,7 +177,7 @@ function Dashboard() { )} - {widgetVisible('bookstackSearch') && ( + {hasPermission('wissen:widget_search') && widgetVisible('bookstackSearch') && ( @@ -185,7 +185,7 @@ function Dashboard() { )} - {widgetVisible('vikunjaTasks') && ( + {hasPermission('vikunja:widget_tasks') && widgetVisible('vikunjaTasks') && ( @@ -193,7 +193,7 @@ function Dashboard() { )} - {widgetVisible('vikunjaQuickAdd') && ( + {hasPermission('vikunja:widget_quick_add') && widgetVisible('vikunjaQuickAdd') && ( @@ -204,7 +204,7 @@ function Dashboard() { {/* Information Group */} - {widgetVisible('links') && linkCollections.map((collection, idx) => ( + {hasPermission('dashboard:widget_links') && widgetVisible('links') && linkCollections.map((collection, idx) => ( @@ -212,11 +212,13 @@ function Dashboard() { ))} - - - - - + {hasPermission('dashboard:widget_banner') && ( + + + + + + )} diff --git a/frontend/src/pages/FahrzeugBuchungen.tsx b/frontend/src/pages/FahrzeugBuchungen.tsx index f172c84..db367b9 100644 --- a/frontend/src/pages/FahrzeugBuchungen.tsx +++ b/frontend/src/pages/FahrzeugBuchungen.tsx @@ -96,6 +96,7 @@ function FahrzeugBuchungen() { const notification = useNotification(); const canCreate = hasPermission('kalender:create_bookings'); const canWrite = hasPermission('kalender:edit_bookings'); + const canCancelOwn = hasPermission('kalender:cancel_own_bookings'); const canChangeBuchungsArt = hasPermission('kalender:manage_categories'); // ── Week navigation ──────────────────────────────────────────────────────── @@ -691,7 +692,7 @@ function FahrzeugBuchungen() { Von: {detailBooking.gebucht_von_name} )} - {(canWrite || detailBooking.gebucht_von === user?.id) && ( + {(canWrite || (canCancelOwn && detailBooking.gebucht_von === user?.id)) && ( {canWrite && (