rights system

This commit is contained in:
Matthias Hochmeister
2026-03-23 10:50:52 +01:00
parent 2bb22850f4
commit 515f14956e
24 changed files with 629 additions and 363 deletions

View File

@@ -2,7 +2,7 @@ import { Request, Response } from 'express';
import { ZodError } from 'zod'; import { ZodError } from 'zod';
import bookingService from '../services/booking.service'; import bookingService from '../services/booking.service';
import vehicleService from '../services/vehicle.service'; import vehicleService from '../services/vehicle.service';
import { hasPermission, resolveRequestRole } from '../middleware/rbac.middleware'; import { permissionService } from '../services/permission.service';
import { import {
CreateBuchungSchema, CreateBuchungSchema,
UpdateBuchungSchema, UpdateBuchungSchema,
@@ -217,15 +217,19 @@ class BookingController {
return; 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); const booking = await bookingService.getById(id);
if (!booking) { if (!booking) {
res.status(404).json({ success: false, message: 'Buchung nicht gefunden' }); res.status(404).json({ success: false, message: 'Buchung nicht gefunden' });
return; return;
} }
const isOwner = booking.gebucht_von === req.user!.id; const isOwner = booking.gebucht_von === req.user!.id;
const role = resolveRequestRole(req); const groups: string[] = req.user?.groups ?? [];
if (!isOwner && !hasPermission(role, 'bookings:write')) { 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' }); res.status(403).json({ success: false, message: 'Keine Berechtigung' });
return; return;
} }

View File

@@ -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<void> {
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 * PUT /api/admin/permissions/maintenance/:featureGroupId
* Toggles maintenance mode for a feature group. * Toggles maintenance mode for a feature group.

View File

@@ -1,11 +1,20 @@
-- Migration 037: DB-driven permission system -- Migration 037: DB-driven permission system
-- Replaces hardcoded RBAC with per-Authentik-group permission assignments -- 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 -- 1. Feature Groups
-- ═══════════════════════════════════════════════════════════════════════════ -- ═══════════════════════════════════════════════════════════════════════════
CREATE TABLE IF NOT EXISTS feature_groups ( CREATE TABLE feature_groups (
id VARCHAR(50) PRIMARY KEY, id VARCHAR(50) PRIMARY KEY,
label VARCHAR(100) NOT NULL, label VARCHAR(100) NOT NULL,
sort_order INT NOT NULL DEFAULT 0, sort_order INT NOT NULL DEFAULT 0,
@@ -21,15 +30,15 @@ INSERT INTO feature_groups (id, label, sort_order) VALUES
('atemschutz', 'Atemschutz', 6), ('atemschutz', 'Atemschutz', 6),
('wissen', 'Wissen', 7), ('wissen', 'Wissen', 7),
('vikunja', 'Vikunja', 8), ('vikunja', 'Vikunja', 8),
('nextcloud', 'Nextcloud', 9), ('dashboard', 'Dashboard', 9),
('dashboard', 'Dashboard', 10) ('admin', 'Admin', 10)
ON CONFLICT (id) DO NOTHING; ON CONFLICT (id) DO NOTHING;
-- ═══════════════════════════════════════════════════════════════════════════ -- ═══════════════════════════════════════════════════════════════════════════
-- 2. Permissions -- 2. Permissions
-- ═══════════════════════════════════════════════════════════════════════════ -- ═══════════════════════════════════════════════════════════════════════════
CREATE TABLE IF NOT EXISTS permissions ( CREATE TABLE permissions (
id VARCHAR(100) PRIMARY KEY, id VARCHAR(100) PRIMARY KEY,
feature_group_id VARCHAR(50) NOT NULL REFERENCES feature_groups(id) ON DELETE CASCADE, feature_group_id VARCHAR(50) NOT NULL REFERENCES feature_groups(id) ON DELETE CASCADE,
label VARCHAR(150) NOT NULL, label VARCHAR(150) NOT NULL,
@@ -39,90 +48,77 @@ CREATE TABLE IF NOT EXISTS permissions (
-- Kalender permissions -- Kalender permissions
INSERT INTO permissions (id, feature_group_id, label, description, sort_order) VALUES INSERT INTO permissions (id, feature_group_id, label, description, sort_order) VALUES
('kalender:access', 'kalender', 'Zugriff', 'Kalender-Seite anzeigen', 1), ('kalender:view', 'kalender', 'Ansehen', 'Kalender einsehen (Termine, Übungen, Buchungen)', 1),
('kalender:view_events', 'kalender', 'Termine ansehen', 'Termine und Übungen einsehen', 2), ('kalender:create', 'kalender', 'Erstellen', 'Termine und Übungen erstellen', 2),
('kalender:view_bookings', 'kalender', 'Buchungen ansehen', 'Fahrzeugbuchungen einsehen', 3), ('kalender:cancel', 'kalender', 'Absagen', 'Termine und Übungen absagen', 3),
('kalender:create_events', 'kalender', 'Termine erstellen', 'Neue Termine/Veranstaltungen erstellen', 4), ('kalender:mark_attendance', 'kalender', 'Anwesenheit eintragen', 'Teilnahme bestätigen', 4),
('kalender:create_training', 'kalender', 'Übungen erstellen', 'Neue Übungen anlegen', 5), ('kalender:create_bookings', 'kalender', 'Buchungen erstellen', 'Neue Fahrzeugbuchungen anlegen', 5),
('kalender:cancel_training', 'kalender', 'Übungen absagen', 'Übungen absagen/löschen', 6), ('kalender:edit_bookings', 'kalender', 'Buchungen bearbeiten', 'Bestehende Buchungen ändern', 6),
('kalender:mark_attendance', 'kalender', 'Anwesenheit eintragen', 'Teilnahme an Übungen bestätigen', 7), ('kalender:cancel_own_bookings','kalender', 'Eigene Buchungen stornieren','Eigene Buchungen stornieren', 7),
('kalender:create_bookings', 'kalender', 'Buchungen erstellen', 'Neue Fahrzeugbuchungen anlegen', 8), ('kalender:delete_bookings', 'kalender', 'Alle Buchungen stornieren/löschen', 'Alle Buchungen stornieren oder löschen', 8),
('kalender:edit_bookings', 'kalender', 'Buchungen bearbeiten', 'Bestehende Buchungen ändern', 9), ('kalender:manage_categories', 'kalender', 'Kategorien verwalten', 'Veranstaltungskategorien verwalten', 9),
('kalender:delete_bookings', 'kalender', 'Buchungen löschen', 'Buchungen endgültig löschen', 10), ('kalender:view_reports', 'kalender', 'Berichte ansehen', 'Übungsstatistiken und Berichte einsehen', 10),
('kalender:manage_categories', 'kalender', 'Kategorien verwalten', 'Veranstaltungskategorien verwalten', 11), ('kalender:widget_events', 'kalender', 'Widget: Termine', 'Dashboard-Widget für Termine', 11),
('kalender:view_reports', 'kalender', 'Berichte ansehen', 'Übungsstatistiken und Berichte einsehen', 12), ('kalender:widget_bookings', 'kalender', 'Widget: Buchungen', 'Dashboard-Widget für Buchungen', 12),
('kalender:widget_events', 'kalender', 'Widget: Termine', 'Dashboard-Widget für Termine', 13), ('kalender:widget_quick_add', 'kalender', 'Widget: Schnell-Termin', 'Dashboard-Widget zum schnellen Erstellen', 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; ON CONFLICT (id) DO NOTHING;
-- Fahrzeuge permissions -- Fahrzeuge permissions
INSERT INTO permissions (id, feature_group_id, label, description, sort_order) VALUES 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', 1),
('fahrzeuge:view', 'fahrzeuge', 'Ansehen', 'Fahrzeugdetails einsehen', 2), ('fahrzeuge:create', 'fahrzeuge', 'Erstellen', 'Neue Fahrzeuge anlegen', 2),
('fahrzeuge:create', 'fahrzeuge', 'Erstellen', 'Neue Fahrzeuge anlegen', 3), ('fahrzeuge:change_status', 'fahrzeuge', 'Status ändern', 'Fahrzeugstatus ändern', 3),
('fahrzeuge:change_status', 'fahrzeuge', 'Status ändern', 'Fahrzeugstatus ändern', 4), ('fahrzeuge:manage_maintenance', 'fahrzeuge', 'Wartung verwalten', 'Wartungseinträge erstellen/bearbeiten', 4),
('fahrzeuge:manage_maintenance', 'fahrzeuge', 'Wartung verwalten', 'Wartungseinträge erstellen/bearbeiten', 5), ('fahrzeuge:delete', 'fahrzeuge', 'Löschen', 'Fahrzeuge löschen', 5),
('fahrzeuge:delete', 'fahrzeuge', 'Löschen', 'Fahrzeuge löschen', 6), ('fahrzeuge:widget', 'fahrzeuge', 'Widget', 'Dashboard-Widget für Fahrzeuge', 6)
('fahrzeuge:widget', 'fahrzeuge', 'Widget', 'Dashboard-Widget für Fahrzeuge', 7)
ON CONFLICT (id) DO NOTHING; ON CONFLICT (id) DO NOTHING;
-- Einsätze permissions -- Einsätze permissions
INSERT INTO permissions (id, feature_group_id, label, description, sort_order) VALUES 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', 1),
('einsaetze:view', 'einsaetze', 'Ansehen', 'Einsätze einsehen', 2), ('einsaetze:view_reports', 'einsaetze', 'Berichte ansehen', 'Einsatzberichte/Berichtstext einsehen', 2),
('einsaetze:view_reports', 'einsaetze', 'Berichte ansehen', 'Einsatzberichte/Berichtstext einsehen', 3), ('einsaetze:create', 'einsaetze', 'Erstellen', 'Neue Einsätze anlegen und bearbeiten', 3),
('einsaetze:create', 'einsaetze', 'Erstellen', 'Neue Einsätze anlegen und bearbeiten', 4), ('einsaetze:delete', 'einsaetze', 'Löschen', 'Einsätze archivieren/löschen', 4),
('einsaetze:delete', 'einsaetze', 'Löschen', 'Einsätze archivieren/löschen', 5), ('einsaetze:manage_personnel', 'einsaetze', 'Personal verwalten', 'Einsatzpersonal und Fahrzeuge zuweisen', 5)
('einsaetze:manage_personnel', 'einsaetze', 'Personal verwalten', 'Einsatzpersonal und Fahrzeuge zuweisen', 6)
ON CONFLICT (id) DO NOTHING; ON CONFLICT (id) DO NOTHING;
-- Ausrüstung permissions -- Ausrüstung permissions
INSERT INTO permissions (id, feature_group_id, label, description, sort_order) VALUES 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', 1),
('ausruestung:view', 'ausruestung', 'Ansehen', 'Ausrüstung einsehen', 2), ('ausruestung:create', 'ausruestung', 'Erstellen', 'Neue Ausrüstung anlegen und bearbeiten', 2),
('ausruestung:create', 'ausruestung', 'Erstellen', 'Neue Ausrüstung anlegen und bearbeiten', 3), ('ausruestung:manage_maintenance', 'ausruestung', 'Wartung verwalten', 'Wartungseinträge verwalten', 3),
('ausruestung:manage_maintenance', 'ausruestung', 'Wartung verwalten', 'Wartungseinträge verwalten', 4), ('ausruestung:delete', 'ausruestung', 'Löschen', 'Ausrüstung löschen', 4),
('ausruestung:delete', 'ausruestung', 'Löschen', 'Ausrüstung löschen', 5), ('ausruestung:widget', 'ausruestung', 'Widget', 'Dashboard-Widget für Ausrüstung', 5)
('ausruestung:widget', 'ausruestung', 'Widget', 'Dashboard-Widget für Ausrüstung', 6)
ON CONFLICT (id) DO NOTHING; ON CONFLICT (id) DO NOTHING;
-- Mitglieder permissions -- Mitglieder permissions
INSERT INTO permissions (id, feature_group_id, label, description, sort_order) VALUES INSERT INTO permissions (id, feature_group_id, label, description, sort_order) VALUES
('mitglieder:access', 'mitglieder', 'Zugriff', 'Mitglieder-Seite anzeigen', 1), ('mitglieder:view_own', 'mitglieder', 'Eigenes Profil', 'Eigenes Profil einsehen', 1),
('mitglieder:view', 'mitglieder', 'Ansehen', 'Mitglieder-Profile einsehen', 2), ('mitglieder:view_all', 'mitglieder', 'Alle Profile', 'Alle Mitglieder-Profile einsehen', 2),
('mitglieder:edit', 'mitglieder', 'Bearbeiten', 'Mitglieder-Profile bearbeiten', 3), ('mitglieder:edit', 'mitglieder', 'Bearbeiten', 'Mitglieder-Profile bearbeiten', 3),
('mitglieder:create_profile', 'mitglieder', 'Profil erstellen', 'Neue Mitglieder-Profile anlegen', 4) ('mitglieder:create_profile','mitglieder', 'Profil erstellen', 'Neue Mitglieder-Profile anlegen', 4)
ON CONFLICT (id) DO NOTHING; ON CONFLICT (id) DO NOTHING;
-- Atemschutz permissions -- Atemschutz permissions
INSERT INTO permissions (id, feature_group_id, label, description, sort_order) VALUES 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', 1),
('atemschutz:view', 'atemschutz', 'Ansehen', 'Atemschutz-Daten aller Träger sehen', 2), ('atemschutz:create', 'atemschutz', 'Erstellen', 'Atemschutz-Einträge anlegen/ändern', 2),
('atemschutz:create', 'atemschutz', 'Erstellen', 'Atemschutz-Einträge anlegen/ändern', 3), ('atemschutz:delete', 'atemschutz', 'Löschen', 'Atemschutz-Einträge löschen', 3),
('atemschutz:delete', 'atemschutz', 'Löschen', 'Atemschutz-Einträge löschen', 4), ('atemschutz:widget', 'atemschutz', 'Widget', 'Dashboard-Widget für Atemschutz', 4)
('atemschutz:widget', 'atemschutz', 'Widget', 'Dashboard-Widget für Atemschutz', 5)
ON CONFLICT (id) DO NOTHING; ON CONFLICT (id) DO NOTHING;
-- Wissen permissions -- Wissen permissions
INSERT INTO permissions (id, feature_group_id, label, description, sort_order) VALUES INSERT INTO permissions (id, feature_group_id, label, description, sort_order) VALUES
('wissen:access', 'wissen', 'Zugriff', 'Wissen-Seite anzeigen', 1), ('wissen:view', 'wissen', 'Ansehen', 'Wissen-Seite anzeigen', 1),
('wissen:widget_recent', 'wissen', 'Widget: Letzte', 'Dashboard-Widget letzte Seiten', 2), ('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:widget_search', 'wissen', 'Widget: Suche', 'Dashboard-Widget für BookStack-Suche', 3)
ON CONFLICT (id) DO NOTHING; ON CONFLICT (id) DO NOTHING;
-- Vikunja permissions -- Vikunja permissions
INSERT INTO permissions (id, feature_group_id, label, description, sort_order) VALUES 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', 1),
('vikunja:create_tasks', 'vikunja', 'Aufgaben erstellen', 'Neue Vikunja-Aufgaben erstellen', 2), ('vikunja:widget_tasks', 'vikunja', 'Widget: Aufgaben', 'Dashboard-Widget für Vikunja-Aufgaben', 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', 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; ON CONFLICT (id) DO NOTHING;
-- Dashboard permissions -- 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) ('dashboard:widget_banner', 'dashboard', 'Widget: Banner', 'Dashboard-Widget für Banner', 2)
ON CONFLICT (id) DO NOTHING; 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 -- 3. Group Permissions
-- ═══════════════════════════════════════════════════════════════════════════ -- ═══════════════════════════════════════════════════════════════════════════
CREATE TABLE IF NOT EXISTS group_permissions ( CREATE TABLE group_permissions (
authentik_group VARCHAR(100) NOT NULL, authentik_group VARCHAR(100) NOT NULL,
permission_id VARCHAR(100) NOT NULL REFERENCES permissions(id) ON DELETE CASCADE, permission_id VARCHAR(100) NOT NULL REFERENCES permissions(id) ON DELETE CASCADE,
granted_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), granted_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
@@ -143,296 +145,245 @@ CREATE TABLE IF NOT EXISTS group_permissions (
PRIMARY KEY (authentik_group, permission_id) 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 -- 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 -- ── dashboard_kommando — near-full access ──
-- 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 INSERT INTO group_permissions (authentik_group, permission_id) VALUES
-- Kalender -- Kalender (ALL)
('dashboard_kommando', 'kalender:access'), ('dashboard_kommando', 'kalender:view'),
('dashboard_kommando', 'kalender:view_events'), ('dashboard_kommando', 'kalender:create'),
('dashboard_kommando', 'kalender:view_bookings'), ('dashboard_kommando', 'kalender:cancel'),
('dashboard_kommando', 'kalender:create_events'),
('dashboard_kommando', 'kalender:create_training'),
('dashboard_kommando', 'kalender:cancel_training'),
('dashboard_kommando', 'kalender:mark_attendance'), ('dashboard_kommando', 'kalender:mark_attendance'),
('dashboard_kommando', 'kalender:create_bookings'), ('dashboard_kommando', 'kalender:create_bookings'),
('dashboard_kommando', 'kalender:edit_bookings'), ('dashboard_kommando', 'kalender:edit_bookings'),
('dashboard_kommando', 'kalender:cancel_own_bookings'),
('dashboard_kommando', 'kalender:delete_bookings'), ('dashboard_kommando', 'kalender:delete_bookings'),
('dashboard_kommando', 'kalender:manage_categories'), ('dashboard_kommando', 'kalender:manage_categories'),
('dashboard_kommando', 'kalender:view_reports'), ('dashboard_kommando', 'kalender:view_reports'),
('dashboard_kommando', 'kalender:widget_events'), ('dashboard_kommando', 'kalender:widget_events'),
('dashboard_kommando', 'kalender:widget_bookings'), ('dashboard_kommando', 'kalender:widget_bookings'),
('dashboard_kommando', 'kalender:widget_quick_add'), ('dashboard_kommando', 'kalender:widget_quick_add'),
-- Fahrzeuge -- Fahrzeuge (ALL)
('dashboard_kommando', 'fahrzeuge:access'),
('dashboard_kommando', 'fahrzeuge:view'), ('dashboard_kommando', 'fahrzeuge:view'),
('dashboard_kommando', 'fahrzeuge:create'), ('dashboard_kommando', 'fahrzeuge:create'),
('dashboard_kommando', 'fahrzeuge:change_status'), ('dashboard_kommando', 'fahrzeuge:change_status'),
('dashboard_kommando', 'fahrzeuge:manage_maintenance'), ('dashboard_kommando', 'fahrzeuge:manage_maintenance'),
('dashboard_kommando', 'fahrzeuge:delete'), ('dashboard_kommando', 'fahrzeuge:delete'),
('dashboard_kommando', 'fahrzeuge:widget'), ('dashboard_kommando', 'fahrzeuge:widget'),
-- Einsätze -- Einsätze (ALL)
('dashboard_kommando', 'einsaetze:access'),
('dashboard_kommando', 'einsaetze:view'), ('dashboard_kommando', 'einsaetze:view'),
('dashboard_kommando', 'einsaetze:view_reports'), ('dashboard_kommando', 'einsaetze:view_reports'),
('dashboard_kommando', 'einsaetze:create'), ('dashboard_kommando', 'einsaetze:create'),
('dashboard_kommando', 'einsaetze:delete'), ('dashboard_kommando', 'einsaetze:delete'),
('dashboard_kommando', 'einsaetze:manage_personnel'), ('dashboard_kommando', 'einsaetze:manage_personnel'),
-- Ausrüstung -- Ausrüstung (ALL)
('dashboard_kommando', 'ausruestung:access'),
('dashboard_kommando', 'ausruestung:view'), ('dashboard_kommando', 'ausruestung:view'),
('dashboard_kommando', 'ausruestung:create'), ('dashboard_kommando', 'ausruestung:create'),
('dashboard_kommando', 'ausruestung:manage_maintenance'), ('dashboard_kommando', 'ausruestung:manage_maintenance'),
('dashboard_kommando', 'ausruestung:delete'), ('dashboard_kommando', 'ausruestung:delete'),
('dashboard_kommando', 'ausruestung:widget'), ('dashboard_kommando', 'ausruestung:widget'),
-- Mitglieder -- Mitglieder (ALL)
('dashboard_kommando', 'mitglieder:access'), ('dashboard_kommando', 'mitglieder:view_own'),
('dashboard_kommando', 'mitglieder:view'), ('dashboard_kommando', 'mitglieder:view_all'),
('dashboard_kommando', 'mitglieder:edit'), ('dashboard_kommando', 'mitglieder:edit'),
('dashboard_kommando', 'mitglieder:create_profile'), ('dashboard_kommando', 'mitglieder:create_profile'),
-- Atemschutz -- Atemschutz (ALL)
('dashboard_kommando', 'atemschutz:access'),
('dashboard_kommando', 'atemschutz:view'), ('dashboard_kommando', 'atemschutz:view'),
('dashboard_kommando', 'atemschutz:create'), ('dashboard_kommando', 'atemschutz:create'),
('dashboard_kommando', 'atemschutz:delete'), ('dashboard_kommando', 'atemschutz:delete'),
('dashboard_kommando', 'atemschutz:widget'), ('dashboard_kommando', 'atemschutz:widget'),
-- Wissen -- Wissen (ALL)
('dashboard_kommando', 'wissen:access'), ('dashboard_kommando', 'wissen:view'),
('dashboard_kommando', 'wissen:widget_recent'), ('dashboard_kommando', 'wissen:widget_recent'),
('dashboard_kommando', 'wissen:widget_search'), ('dashboard_kommando', 'wissen:widget_search'),
-- Vikunja -- Vikunja (ALL)
('dashboard_kommando', 'vikunja:access'),
('dashboard_kommando', 'vikunja:create_tasks'), ('dashboard_kommando', 'vikunja:create_tasks'),
('dashboard_kommando', 'vikunja:widget_tasks'), ('dashboard_kommando', 'vikunja:widget_tasks'),
('dashboard_kommando', 'vikunja:widget_quick_add'), ('dashboard_kommando', 'vikunja:widget_quick_add'),
-- Nextcloud -- Dashboard (ALL)
('dashboard_kommando', 'nextcloud:access'),
('dashboard_kommando', 'nextcloud:widget'),
-- Dashboard
('dashboard_kommando', 'dashboard:widget_links'), ('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; 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 INSERT INTO group_permissions (authentik_group, permission_id) VALUES
-- Kalender -- Kalender
('dashboard_gruppenfuehrer', 'kalender:access'), ('dashboard_gruppenfuehrer', 'kalender:view'),
('dashboard_gruppenfuehrer', 'kalender:view_events'), ('dashboard_gruppenfuehrer', 'kalender:create'),
('dashboard_gruppenfuehrer', 'kalender:view_bookings'),
('dashboard_gruppenfuehrer', 'kalender:create_events'),
('dashboard_gruppenfuehrer', 'kalender:create_training'),
('dashboard_gruppenfuehrer', 'kalender:mark_attendance'), ('dashboard_gruppenfuehrer', 'kalender:mark_attendance'),
('dashboard_gruppenfuehrer', 'kalender:create_bookings'), ('dashboard_gruppenfuehrer', 'kalender:create_bookings'),
('dashboard_gruppenfuehrer', 'kalender:edit_bookings'), ('dashboard_gruppenfuehrer', 'kalender:edit_bookings'),
('dashboard_gruppenfuehrer', 'kalender:cancel_own_bookings'),
('dashboard_gruppenfuehrer', 'kalender:manage_categories'), ('dashboard_gruppenfuehrer', 'kalender:manage_categories'),
('dashboard_gruppenfuehrer', 'kalender:widget_events'), ('dashboard_gruppenfuehrer', 'kalender:widget_events'),
('dashboard_gruppenfuehrer', 'kalender:widget_bookings'), ('dashboard_gruppenfuehrer', 'kalender:widget_bookings'),
('dashboard_gruppenfuehrer', 'kalender:widget_quick_add'), ('dashboard_gruppenfuehrer', 'kalender:widget_quick_add'),
-- Fahrzeuge -- Fahrzeuge
('dashboard_gruppenfuehrer', 'fahrzeuge:access'),
('dashboard_gruppenfuehrer', 'fahrzeuge:view'), ('dashboard_gruppenfuehrer', 'fahrzeuge:view'),
('dashboard_gruppenfuehrer', 'fahrzeuge:change_status'), ('dashboard_gruppenfuehrer', 'fahrzeuge:change_status'),
('dashboard_gruppenfuehrer', 'fahrzeuge:manage_maintenance'), ('dashboard_gruppenfuehrer', 'fahrzeuge:manage_maintenance'),
('dashboard_gruppenfuehrer', 'fahrzeuge:widget'), ('dashboard_gruppenfuehrer', 'fahrzeuge:widget'),
-- Einsätze -- Einsätze
('dashboard_gruppenfuehrer', 'einsaetze:access'),
('dashboard_gruppenfuehrer', 'einsaetze:view'), ('dashboard_gruppenfuehrer', 'einsaetze:view'),
('dashboard_gruppenfuehrer', 'einsaetze:create'), ('dashboard_gruppenfuehrer', 'einsaetze:create'),
('dashboard_gruppenfuehrer', 'einsaetze:manage_personnel'), ('dashboard_gruppenfuehrer', 'einsaetze:manage_personnel'),
-- Ausrüstung -- Ausrüstung
('dashboard_gruppenfuehrer', 'ausruestung:access'),
('dashboard_gruppenfuehrer', 'ausruestung:view'), ('dashboard_gruppenfuehrer', 'ausruestung:view'),
('dashboard_gruppenfuehrer', 'ausruestung:create'), ('dashboard_gruppenfuehrer', 'ausruestung:create'),
('dashboard_gruppenfuehrer', 'ausruestung:manage_maintenance'), ('dashboard_gruppenfuehrer', 'ausruestung:manage_maintenance'),
('dashboard_gruppenfuehrer', 'ausruestung:widget'), ('dashboard_gruppenfuehrer', 'ausruestung:widget'),
-- Mitglieder -- Mitglieder
('dashboard_gruppenfuehrer', 'mitglieder:access'), ('dashboard_gruppenfuehrer', 'mitglieder:view_own'),
('dashboard_gruppenfuehrer', 'mitglieder:view'), ('dashboard_gruppenfuehrer', 'mitglieder:view_all'),
-- Atemschutz -- Atemschutz
('dashboard_gruppenfuehrer', 'atemschutz:access'),
('dashboard_gruppenfuehrer', 'atemschutz:view'), ('dashboard_gruppenfuehrer', 'atemschutz:view'),
('dashboard_gruppenfuehrer', 'atemschutz:create'), ('dashboard_gruppenfuehrer', 'atemschutz:create'),
('dashboard_gruppenfuehrer', 'atemschutz:widget'), ('dashboard_gruppenfuehrer', 'atemschutz:widget'),
-- Wissen -- Wissen
('dashboard_gruppenfuehrer', 'wissen:access'), ('dashboard_gruppenfuehrer', 'wissen:view'),
('dashboard_gruppenfuehrer', 'wissen:widget_recent'), ('dashboard_gruppenfuehrer', 'wissen:widget_recent'),
('dashboard_gruppenfuehrer', 'wissen:widget_search'), ('dashboard_gruppenfuehrer', 'wissen:widget_search'),
-- Vikunja -- Vikunja
('dashboard_gruppenfuehrer', 'vikunja:access'),
('dashboard_gruppenfuehrer', 'vikunja:create_tasks'), ('dashboard_gruppenfuehrer', 'vikunja:create_tasks'),
('dashboard_gruppenfuehrer', 'vikunja:widget_tasks'), ('dashboard_gruppenfuehrer', 'vikunja:widget_tasks'),
('dashboard_gruppenfuehrer', 'vikunja:widget_quick_add'), ('dashboard_gruppenfuehrer', 'vikunja:widget_quick_add'),
-- Nextcloud
('dashboard_gruppenfuehrer', 'nextcloud:access'),
('dashboard_gruppenfuehrer', 'nextcloud:widget'),
-- Dashboard -- Dashboard
('dashboard_gruppenfuehrer', 'dashboard:widget_links'), ('dashboard_gruppenfuehrer', 'dashboard:widget_links'),
('dashboard_gruppenfuehrer', 'dashboard:widget_banner') ('dashboard_gruppenfuehrer', 'dashboard:widget_banner')
ON CONFLICT DO NOTHING; ON CONFLICT DO NOTHING;
-- dashboard_fahrmeister — vehicle specialist -- ── dashboard_fahrmeister — vehicle specialist ──
INSERT INTO group_permissions (authentik_group, permission_id) VALUES INSERT INTO group_permissions (authentik_group, permission_id) VALUES
-- Kalender -- Kalender
('dashboard_fahrmeister', 'kalender:access'), ('dashboard_fahrmeister', 'kalender:view'),
('dashboard_fahrmeister', 'kalender:view_events'),
('dashboard_fahrmeister', 'kalender:view_bookings'),
('dashboard_fahrmeister', 'kalender:create_bookings'), ('dashboard_fahrmeister', 'kalender:create_bookings'),
('dashboard_fahrmeister', 'kalender:edit_bookings'), ('dashboard_fahrmeister', 'kalender:edit_bookings'),
('dashboard_fahrmeister', 'kalender:cancel_own_bookings'),
('dashboard_fahrmeister', 'kalender:widget_events'), ('dashboard_fahrmeister', 'kalender:widget_events'),
('dashboard_fahrmeister', 'kalender:widget_bookings'), ('dashboard_fahrmeister', 'kalender:widget_bookings'),
-- Fahrzeuge -- Fahrzeuge
('dashboard_fahrmeister', 'fahrzeuge:access'),
('dashboard_fahrmeister', 'fahrzeuge:view'), ('dashboard_fahrmeister', 'fahrzeuge:view'),
('dashboard_fahrmeister', 'fahrzeuge:change_status'), ('dashboard_fahrmeister', 'fahrzeuge:change_status'),
('dashboard_fahrmeister', 'fahrzeuge:manage_maintenance'), ('dashboard_fahrmeister', 'fahrzeuge:manage_maintenance'),
('dashboard_fahrmeister', 'fahrzeuge:widget'), ('dashboard_fahrmeister', 'fahrzeuge:widget'),
-- Einsätze -- Einsätze
('dashboard_fahrmeister', 'einsaetze:access'),
('dashboard_fahrmeister', 'einsaetze:view'), ('dashboard_fahrmeister', 'einsaetze:view'),
-- Ausrüstung -- Ausrüstung
('dashboard_fahrmeister', 'ausruestung:access'),
('dashboard_fahrmeister', 'ausruestung:view'), ('dashboard_fahrmeister', 'ausruestung:view'),
('dashboard_fahrmeister', 'ausruestung:create'), ('dashboard_fahrmeister', 'ausruestung:create'),
('dashboard_fahrmeister', 'ausruestung:manage_maintenance'), ('dashboard_fahrmeister', 'ausruestung:manage_maintenance'),
('dashboard_fahrmeister', 'ausruestung:widget'), ('dashboard_fahrmeister', 'ausruestung:widget'),
-- Mitglieder -- Mitglieder
('dashboard_fahrmeister', 'mitglieder:access'), ('dashboard_fahrmeister', 'mitglieder:view_own'),
('dashboard_fahrmeister', 'mitglieder:view'), ('dashboard_fahrmeister', 'mitglieder:view_all'),
-- Atemschutz -- Atemschutz
('dashboard_fahrmeister', 'atemschutz:access'),
('dashboard_fahrmeister', 'atemschutz:widget'), ('dashboard_fahrmeister', 'atemschutz:widget'),
-- Wissen -- Wissen
('dashboard_fahrmeister', 'wissen:access'), ('dashboard_fahrmeister', 'wissen:view'),
('dashboard_fahrmeister', 'wissen:widget_recent'), ('dashboard_fahrmeister', 'wissen:widget_recent'),
('dashboard_fahrmeister', 'wissen:widget_search'), ('dashboard_fahrmeister', 'wissen:widget_search'),
-- Vikunja -- Vikunja
('dashboard_fahrmeister', 'vikunja:access'),
('dashboard_fahrmeister', 'vikunja:create_tasks'), ('dashboard_fahrmeister', 'vikunja:create_tasks'),
('dashboard_fahrmeister', 'vikunja:widget_tasks'), ('dashboard_fahrmeister', 'vikunja:widget_tasks'),
('dashboard_fahrmeister', 'vikunja:widget_quick_add'), ('dashboard_fahrmeister', 'vikunja:widget_quick_add'),
-- Nextcloud
('dashboard_fahrmeister', 'nextcloud:access'),
('dashboard_fahrmeister', 'nextcloud:widget'),
-- Dashboard -- Dashboard
('dashboard_fahrmeister', 'dashboard:widget_links'), ('dashboard_fahrmeister', 'dashboard:widget_links'),
('dashboard_fahrmeister', 'dashboard:widget_banner') ('dashboard_fahrmeister', 'dashboard:widget_banner')
ON CONFLICT DO NOTHING; ON CONFLICT DO NOTHING;
-- dashboard_zeugmeister — equipment specialist -- ── dashboard_zeugmeister — equipment specialist ──
INSERT INTO group_permissions (authentik_group, permission_id) VALUES INSERT INTO group_permissions (authentik_group, permission_id) VALUES
-- Kalender -- Kalender
('dashboard_zeugmeister', 'kalender:access'), ('dashboard_zeugmeister', 'kalender:view'),
('dashboard_zeugmeister', 'kalender:view_events'),
('dashboard_zeugmeister', 'kalender:view_bookings'),
('dashboard_zeugmeister', 'kalender:create_bookings'), ('dashboard_zeugmeister', 'kalender:create_bookings'),
('dashboard_zeugmeister', 'kalender:cancel_own_bookings'),
('dashboard_zeugmeister', 'kalender:widget_events'), ('dashboard_zeugmeister', 'kalender:widget_events'),
('dashboard_zeugmeister', 'kalender:widget_bookings'), ('dashboard_zeugmeister', 'kalender:widget_bookings'),
-- Fahrzeuge -- Fahrzeuge
('dashboard_zeugmeister', 'fahrzeuge:access'),
('dashboard_zeugmeister', 'fahrzeuge:view'), ('dashboard_zeugmeister', 'fahrzeuge:view'),
('dashboard_zeugmeister', 'fahrzeuge:change_status'), ('dashboard_zeugmeister', 'fahrzeuge:change_status'),
('dashboard_zeugmeister', 'fahrzeuge:manage_maintenance'), ('dashboard_zeugmeister', 'fahrzeuge:manage_maintenance'),
('dashboard_zeugmeister', 'fahrzeuge:widget'), ('dashboard_zeugmeister', 'fahrzeuge:widget'),
-- Einsätze -- Einsätze
('dashboard_zeugmeister', 'einsaetze:access'),
('dashboard_zeugmeister', 'einsaetze:view'), ('dashboard_zeugmeister', 'einsaetze:view'),
-- Ausrüstung -- Ausrüstung
('dashboard_zeugmeister', 'ausruestung:access'),
('dashboard_zeugmeister', 'ausruestung:view'), ('dashboard_zeugmeister', 'ausruestung:view'),
('dashboard_zeugmeister', 'ausruestung:create'), ('dashboard_zeugmeister', 'ausruestung:create'),
('dashboard_zeugmeister', 'ausruestung:manage_maintenance'), ('dashboard_zeugmeister', 'ausruestung:manage_maintenance'),
('dashboard_zeugmeister', 'ausruestung:widget'), ('dashboard_zeugmeister', 'ausruestung:widget'),
-- Mitglieder -- Mitglieder
('dashboard_zeugmeister', 'mitglieder:access'), ('dashboard_zeugmeister', 'mitglieder:view_own'),
('dashboard_zeugmeister', 'mitglieder:view'), ('dashboard_zeugmeister', 'mitglieder:view_all'),
-- Atemschutz -- Atemschutz
('dashboard_zeugmeister', 'atemschutz:access'),
('dashboard_zeugmeister', 'atemschutz:widget'), ('dashboard_zeugmeister', 'atemschutz:widget'),
-- Wissen -- Wissen
('dashboard_zeugmeister', 'wissen:access'), ('dashboard_zeugmeister', 'wissen:view'),
('dashboard_zeugmeister', 'wissen:widget_recent'), ('dashboard_zeugmeister', 'wissen:widget_recent'),
('dashboard_zeugmeister', 'wissen:widget_search'), ('dashboard_zeugmeister', 'wissen:widget_search'),
-- Vikunja -- Vikunja
('dashboard_zeugmeister', 'vikunja:access'),
('dashboard_zeugmeister', 'vikunja:create_tasks'), ('dashboard_zeugmeister', 'vikunja:create_tasks'),
('dashboard_zeugmeister', 'vikunja:widget_tasks'), ('dashboard_zeugmeister', 'vikunja:widget_tasks'),
('dashboard_zeugmeister', 'vikunja:widget_quick_add'), ('dashboard_zeugmeister', 'vikunja:widget_quick_add'),
-- Nextcloud
('dashboard_zeugmeister', 'nextcloud:access'),
('dashboard_zeugmeister', 'nextcloud:widget'),
-- Dashboard -- Dashboard
('dashboard_zeugmeister', 'dashboard:widget_links'), ('dashboard_zeugmeister', 'dashboard:widget_links'),
('dashboard_zeugmeister', 'dashboard:widget_banner') ('dashboard_zeugmeister', 'dashboard:widget_banner')
ON CONFLICT DO NOTHING; ON CONFLICT DO NOTHING;
-- dashboard_chargen — similar to gruppenfuehrer -- ── dashboard_chargen — mid level ──
INSERT INTO group_permissions (authentik_group, permission_id) VALUES INSERT INTO group_permissions (authentik_group, permission_id) VALUES
-- Kalender -- Kalender
('dashboard_chargen', 'kalender:access'), ('dashboard_chargen', 'kalender:view'),
('dashboard_chargen', 'kalender:view_events'),
('dashboard_chargen', 'kalender:view_bookings'),
('dashboard_chargen', 'kalender:create_bookings'), ('dashboard_chargen', 'kalender:create_bookings'),
('dashboard_chargen', 'kalender:cancel_own_bookings'),
('dashboard_chargen', 'kalender:widget_events'), ('dashboard_chargen', 'kalender:widget_events'),
('dashboard_chargen', 'kalender:widget_bookings'), ('dashboard_chargen', 'kalender:widget_bookings'),
-- Fahrzeuge -- Fahrzeuge
('dashboard_chargen', 'fahrzeuge:access'),
('dashboard_chargen', 'fahrzeuge:view'), ('dashboard_chargen', 'fahrzeuge:view'),
('dashboard_chargen', 'fahrzeuge:change_status'), ('dashboard_chargen', 'fahrzeuge:change_status'),
('dashboard_chargen', 'fahrzeuge:manage_maintenance'), ('dashboard_chargen', 'fahrzeuge:manage_maintenance'),
('dashboard_chargen', 'fahrzeuge:widget'), ('dashboard_chargen', 'fahrzeuge:widget'),
-- Einsätze -- Einsätze
('dashboard_chargen', 'einsaetze:access'),
('dashboard_chargen', 'einsaetze:view'), ('dashboard_chargen', 'einsaetze:view'),
('dashboard_chargen', 'einsaetze:create'), ('dashboard_chargen', 'einsaetze:create'),
('dashboard_chargen', 'einsaetze:manage_personnel'), ('dashboard_chargen', 'einsaetze:manage_personnel'),
-- Ausrüstung -- Ausrüstung
('dashboard_chargen', 'ausruestung:access'),
('dashboard_chargen', 'ausruestung:view'), ('dashboard_chargen', 'ausruestung:view'),
('dashboard_chargen', 'ausruestung:create'), ('dashboard_chargen', 'ausruestung:create'),
('dashboard_chargen', 'ausruestung:manage_maintenance'), ('dashboard_chargen', 'ausruestung:manage_maintenance'),
('dashboard_chargen', 'ausruestung:widget'), ('dashboard_chargen', 'ausruestung:widget'),
-- Mitglieder -- Mitglieder
('dashboard_chargen', 'mitglieder:access'), ('dashboard_chargen', 'mitglieder:view_own'),
('dashboard_chargen', 'mitglieder:view'), ('dashboard_chargen', 'mitglieder:view_all'),
-- Atemschutz -- Atemschutz
('dashboard_chargen', 'atemschutz:access'),
('dashboard_chargen', 'atemschutz:view'), ('dashboard_chargen', 'atemschutz:view'),
('dashboard_chargen', 'atemschutz:create'), ('dashboard_chargen', 'atemschutz:create'),
('dashboard_chargen', 'atemschutz:widget'), ('dashboard_chargen', 'atemschutz:widget'),
-- Wissen -- Wissen
('dashboard_chargen', 'wissen:access'), ('dashboard_chargen', 'wissen:view'),
('dashboard_chargen', 'wissen:widget_recent'), ('dashboard_chargen', 'wissen:widget_recent'),
('dashboard_chargen', 'wissen:widget_search'), ('dashboard_chargen', 'wissen:widget_search'),
-- Vikunja -- Vikunja
('dashboard_chargen', 'vikunja:access'),
('dashboard_chargen', 'vikunja:create_tasks'), ('dashboard_chargen', 'vikunja:create_tasks'),
('dashboard_chargen', 'vikunja:widget_tasks'), ('dashboard_chargen', 'vikunja:widget_tasks'),
('dashboard_chargen', 'vikunja:widget_quick_add'), ('dashboard_chargen', 'vikunja:widget_quick_add'),
-- Nextcloud
('dashboard_chargen', 'nextcloud:access'),
('dashboard_chargen', 'nextcloud:widget'),
-- Dashboard -- Dashboard
('dashboard_chargen', 'dashboard:widget_links'), ('dashboard_chargen', 'dashboard:widget_links'),
('dashboard_chargen', 'dashboard:widget_banner') ('dashboard_chargen', 'dashboard:widget_banner')
ON CONFLICT DO NOTHING; 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 INSERT INTO group_permissions (authentik_group, permission_id) VALUES
-- Kalender -- Kalender
('dashboard_moderator', 'kalender:access'), ('dashboard_moderator', 'kalender:view'),
('dashboard_moderator', 'kalender:view_events'), ('dashboard_moderator', 'kalender:create'),
('dashboard_moderator', 'kalender:view_bookings'), ('dashboard_moderator', 'kalender:cancel_own_bookings'),
('dashboard_moderator', 'kalender:create_events'),
('dashboard_moderator', 'kalender:create_bookings'), ('dashboard_moderator', 'kalender:create_bookings'),
('dashboard_moderator', 'kalender:edit_bookings'), ('dashboard_moderator', 'kalender:edit_bookings'),
('dashboard_moderator', 'kalender:manage_categories'), ('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_bookings'),
('dashboard_moderator', 'kalender:widget_quick_add'), ('dashboard_moderator', 'kalender:widget_quick_add'),
-- Fahrzeuge -- Fahrzeuge
('dashboard_moderator', 'fahrzeuge:access'),
('dashboard_moderator', 'fahrzeuge:view'), ('dashboard_moderator', 'fahrzeuge:view'),
('dashboard_moderator', 'fahrzeuge:widget'), ('dashboard_moderator', 'fahrzeuge:widget'),
-- Einsätze -- Einsätze
('dashboard_moderator', 'einsaetze:access'),
('dashboard_moderator', 'einsaetze:view'), ('dashboard_moderator', 'einsaetze:view'),
-- Ausrüstung -- Ausrüstung
('dashboard_moderator', 'ausruestung:access'),
('dashboard_moderator', 'ausruestung:view'), ('dashboard_moderator', 'ausruestung:view'),
('dashboard_moderator', 'ausruestung:widget'), ('dashboard_moderator', 'ausruestung:widget'),
-- Mitglieder -- Mitglieder
('dashboard_moderator', 'mitglieder:access'), ('dashboard_moderator', 'mitglieder:view_own'),
('dashboard_moderator', 'mitglieder:view'), ('dashboard_moderator', 'mitglieder:view_all'),
-- Atemschutz -- Atemschutz
('dashboard_moderator', 'atemschutz:access'),
('dashboard_moderator', 'atemschutz:view'), ('dashboard_moderator', 'atemschutz:view'),
('dashboard_moderator', 'atemschutz:widget'), ('dashboard_moderator', 'atemschutz:widget'),
-- Wissen -- Wissen
('dashboard_moderator', 'wissen:access'), ('dashboard_moderator', 'wissen:view'),
('dashboard_moderator', 'wissen:widget_recent'), ('dashboard_moderator', 'wissen:widget_recent'),
('dashboard_moderator', 'wissen:widget_search'), ('dashboard_moderator', 'wissen:widget_search'),
-- Vikunja -- Vikunja
('dashboard_moderator', 'vikunja:access'),
('dashboard_moderator', 'vikunja:create_tasks'), ('dashboard_moderator', 'vikunja:create_tasks'),
('dashboard_moderator', 'vikunja:widget_tasks'), ('dashboard_moderator', 'vikunja:widget_tasks'),
('dashboard_moderator', 'vikunja:widget_quick_add'), ('dashboard_moderator', 'vikunja:widget_quick_add'),
-- Nextcloud
('dashboard_moderator', 'nextcloud:access'),
('dashboard_moderator', 'nextcloud:widget'),
-- Dashboard -- Dashboard
('dashboard_moderator', 'dashboard:widget_links'), ('dashboard_moderator', 'dashboard:widget_links'),
('dashboard_moderator', 'dashboard:widget_banner') ('dashboard_moderator', 'dashboard:widget_banner')
ON CONFLICT DO NOTHING; ON CONFLICT DO NOTHING;
-- dashboard_atemschutz — atemschutz specialist -- ── dashboard_atemschutz — atemschutz specialist ──
INSERT INTO group_permissions (authentik_group, permission_id) VALUES INSERT INTO group_permissions (authentik_group, permission_id) VALUES
-- Kalender (basic access) -- Kalender
('dashboard_atemschutz', 'kalender:access'), ('dashboard_atemschutz', 'kalender:view'),
('dashboard_atemschutz', 'kalender:view_events'),
('dashboard_atemschutz', 'kalender:view_bookings'),
('dashboard_atemschutz', 'kalender:create_bookings'), ('dashboard_atemschutz', 'kalender:create_bookings'),
('dashboard_atemschutz', 'kalender:cancel_own_bookings'),
('dashboard_atemschutz', 'kalender:widget_events'), ('dashboard_atemschutz', 'kalender:widget_events'),
('dashboard_atemschutz', 'kalender:widget_bookings'), ('dashboard_atemschutz', 'kalender:widget_bookings'),
-- Fahrzeuge (read) -- Fahrzeuge
('dashboard_atemschutz', 'fahrzeuge:access'),
('dashboard_atemschutz', 'fahrzeuge:view'), ('dashboard_atemschutz', 'fahrzeuge:view'),
('dashboard_atemschutz', 'fahrzeuge:widget'), ('dashboard_atemschutz', 'fahrzeuge:widget'),
-- Einsätze (read) -- Einsätze
('dashboard_atemschutz', 'einsaetze:access'),
('dashboard_atemschutz', 'einsaetze:view'), ('dashboard_atemschutz', 'einsaetze:view'),
-- Ausrüstung (read) -- Ausrüstung
('dashboard_atemschutz', 'ausruestung:access'),
('dashboard_atemschutz', 'ausruestung:view'), ('dashboard_atemschutz', 'ausruestung:view'),
('dashboard_atemschutz', 'ausruestung:widget'), ('dashboard_atemschutz', 'ausruestung:widget'),
-- Mitglieder (read) -- Mitglieder
('dashboard_atemschutz', 'mitglieder:access'), ('dashboard_atemschutz', 'mitglieder:view_own'),
('dashboard_atemschutz', 'mitglieder:view'), ('dashboard_atemschutz', 'mitglieder:view_all'),
-- Atemschutz (full) -- Atemschutz
('dashboard_atemschutz', 'atemschutz:access'),
('dashboard_atemschutz', 'atemschutz:view'), ('dashboard_atemschutz', 'atemschutz:view'),
('dashboard_atemschutz', 'atemschutz:create'), ('dashboard_atemschutz', 'atemschutz:create'),
('dashboard_atemschutz', 'atemschutz:widget'), ('dashboard_atemschutz', 'atemschutz:widget'),
-- Wissen -- Wissen
('dashboard_atemschutz', 'wissen:access'), ('dashboard_atemschutz', 'wissen:view'),
('dashboard_atemschutz', 'wissen:widget_recent'), ('dashboard_atemschutz', 'wissen:widget_recent'),
('dashboard_atemschutz', 'wissen:widget_search'), ('dashboard_atemschutz', 'wissen:widget_search'),
-- Vikunja -- Vikunja
('dashboard_atemschutz', 'vikunja:access'),
('dashboard_atemschutz', 'vikunja:create_tasks'), ('dashboard_atemschutz', 'vikunja:create_tasks'),
('dashboard_atemschutz', 'vikunja:widget_tasks'), ('dashboard_atemschutz', 'vikunja:widget_tasks'),
('dashboard_atemschutz', 'vikunja:widget_quick_add'), ('dashboard_atemschutz', 'vikunja:widget_quick_add'),
-- Nextcloud
('dashboard_atemschutz', 'nextcloud:access'),
('dashboard_atemschutz', 'nextcloud:widget'),
-- Dashboard -- Dashboard
('dashboard_atemschutz', 'dashboard:widget_links'), ('dashboard_atemschutz', 'dashboard:widget_links'),
('dashboard_atemschutz', 'dashboard:widget_banner') ('dashboard_atemschutz', 'dashboard:widget_banner')
ON CONFLICT DO NOTHING; 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 INSERT INTO group_permissions (authentik_group, permission_id) VALUES
-- Kalender -- Kalender
('dashboard_mitglied', 'kalender:access'), ('dashboard_mitglied', 'kalender:view'),
('dashboard_mitglied', 'kalender:view_events'),
('dashboard_mitglied', 'kalender:view_bookings'),
('dashboard_mitglied', 'kalender:create_bookings'), ('dashboard_mitglied', 'kalender:create_bookings'),
('dashboard_mitglied', 'kalender:cancel_own_bookings'),
('dashboard_mitglied', 'kalender:widget_events'), ('dashboard_mitglied', 'kalender:widget_events'),
('dashboard_mitglied', 'kalender:widget_bookings'), ('dashboard_mitglied', 'kalender:widget_bookings'),
-- Fahrzeuge -- Fahrzeuge
('dashboard_mitglied', 'fahrzeuge:access'),
('dashboard_mitglied', 'fahrzeuge:view'), ('dashboard_mitglied', 'fahrzeuge:view'),
('dashboard_mitglied', 'fahrzeuge:widget'), ('dashboard_mitglied', 'fahrzeuge:widget'),
-- Einsätze -- Einsätze
('dashboard_mitglied', 'einsaetze:access'),
('dashboard_mitglied', 'einsaetze:view'), ('dashboard_mitglied', 'einsaetze:view'),
-- Ausrüstung -- Ausrüstung
('dashboard_mitglied', 'ausruestung:access'),
('dashboard_mitglied', 'ausruestung:view'), ('dashboard_mitglied', 'ausruestung:view'),
('dashboard_mitglied', 'ausruestung:widget'), ('dashboard_mitglied', 'ausruestung:widget'),
-- Mitglieder -- Mitglieder
('dashboard_mitglied', 'mitglieder:access'), ('dashboard_mitglied', 'mitglieder:view_own'),
('dashboard_mitglied', 'mitglieder:view'),
-- Atemschutz -- Atemschutz
('dashboard_mitglied', 'atemschutz:access'),
('dashboard_mitglied', 'atemschutz:widget'), ('dashboard_mitglied', 'atemschutz:widget'),
-- Wissen -- Wissen
('dashboard_mitglied', 'wissen:access'), ('dashboard_mitglied', 'wissen:view'),
('dashboard_mitglied', 'wissen:widget_recent'), ('dashboard_mitglied', 'wissen:widget_recent'),
('dashboard_mitglied', 'wissen:widget_search'), ('dashboard_mitglied', 'wissen:widget_search'),
-- Vikunja -- Vikunja
('dashboard_mitglied', 'vikunja:access'),
('dashboard_mitglied', 'vikunja:create_tasks'), ('dashboard_mitglied', 'vikunja:create_tasks'),
('dashboard_mitglied', 'vikunja:widget_tasks'), ('dashboard_mitglied', 'vikunja:widget_tasks'),
('dashboard_mitglied', 'vikunja:widget_quick_add'), ('dashboard_mitglied', 'vikunja:widget_quick_add'),
-- Nextcloud
('dashboard_mitglied', 'nextcloud:access'),
('dashboard_mitglied', 'nextcloud:widget'),
-- Dashboard -- Dashboard
('dashboard_mitglied', 'dashboard:widget_links'), ('dashboard_mitglied', 'dashboard:widget_links'),
('dashboard_mitglied', 'dashboard:widget_banner') ('dashboard_mitglied', 'dashboard:widget_banner')

View File

@@ -20,7 +20,6 @@ export type AppRole =
* *
* Hardwired rules: * Hardwired rules:
* - `dashboard_admin` group always passes (full access). * - `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. * - Maintenance mode blocks non-admin access per feature group.
*/ */
export function requirePermission(permission: string) { export function requirePermission(permission: string) {
@@ -44,25 +43,6 @@ export function requirePermission(permission: string) {
return; 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 // Check maintenance mode for the feature group
const featureGroup = permission.split(':')[0]; const featureGroup = permission.split(':')[0];
if (permissionService.isFeatureInMaintenance(featureGroup)) { if (permissionService.isFeatureInMaintenance(featureGroup)) {

View File

@@ -85,7 +85,7 @@ function parseAuditQuery(query: Record<string, unknown>): AuditFilters {
router.get( router.get(
'/audit-log', '/audit-log',
authenticate, authenticate,
requirePermission('admin:access'), requirePermission('admin:view'),
async (req: Request, res: Response): Promise<void> => { async (req: Request, res: Response): Promise<void> => {
try { try {
const filters = parseAuditQuery(req.query as Record<string, unknown>); const filters = parseAuditQuery(req.query as Record<string, unknown>);
@@ -122,7 +122,7 @@ router.get(
router.get( router.get(
'/audit-log/export', '/audit-log/export',
authenticate, authenticate,
requirePermission('admin:access'), requirePermission('admin:view'),
async (req: Request, res: Response): Promise<void> => { async (req: Request, res: Response): Promise<void> => {
try { try {
// For CSV exports we fetch up to 10,000 rows (no pagination). // 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( router.get(
'/fdisk-sync/logs', '/fdisk-sync/logs',
authenticate, authenticate,
requirePermission('admin:access'), requirePermission('admin:view'),
async (_req: Request, res: Response): Promise<void> => { async (_req: Request, res: Response): Promise<void> => {
if (!FDISK_SYNC_URL) { if (!FDISK_SYNC_URL) {
res.status(503).json({ success: false, message: 'FDISK sync service not configured' }); res.status(503).json({ success: false, message: 'FDISK sync service not configured' });
@@ -194,7 +194,7 @@ router.get(
router.post( router.post(
'/fdisk-sync/trigger', '/fdisk-sync/trigger',
authenticate, authenticate,
requirePermission('admin:access'), requirePermission('admin:view'),
async (req: Request, res: Response): Promise<void> => { async (req: Request, res: Response): Promise<void> => {
if (!FDISK_SYNC_URL) { if (!FDISK_SYNC_URL) {
res.status(503).json({ success: false, message: 'FDISK sync service not configured' }); res.status(503).json({ success: false, message: 'FDISK sync service not configured' });

View File

@@ -4,7 +4,7 @@ import { authenticate } from '../middleware/auth.middleware';
import { requirePermission } from '../middleware/rbac.middleware'; import { requirePermission } from '../middleware/rbac.middleware';
const router = Router(); const router = Router();
const adminAuth = [authenticate, requirePermission('admin:access')] as const; const adminAuth = [authenticate, requirePermission('admin:write')] as const;
// Public (authenticated): get active banners // Public (authenticated): get active banners
router.get('/active', authenticate, bannerController.getActive.bind(bannerController)); router.get('/active', authenticate, bannerController.getActive.bind(bannerController));

View File

@@ -118,7 +118,7 @@ router.get(
router.post( router.post(
'/import', '/import',
authenticate, authenticate,
requirePermission('kalender:create_events'), requirePermission('kalender:create'),
eventsController.importEvents.bind(eventsController) eventsController.importEvents.bind(eventsController)
); );
@@ -129,7 +129,7 @@ router.post(
router.post( router.post(
'/', '/',
authenticate, authenticate,
requirePermission('kalender:create_events'), requirePermission('kalender:create'),
eventsController.createEvent.bind(eventsController) eventsController.createEvent.bind(eventsController)
); );
@@ -146,7 +146,7 @@ router.get('/:id', authenticate, eventsController.getById.bind(eventsController)
router.patch( router.patch(
'/:id', '/:id',
authenticate, authenticate,
requirePermission('kalender:create_events'), requirePermission('kalender:create'),
eventsController.updateEvent.bind(eventsController) eventsController.updateEvent.bind(eventsController)
); );
@@ -157,7 +157,7 @@ router.patch(
router.delete( router.delete(
'/:id', '/:id',
authenticate, authenticate,
requirePermission('kalender:create_events'), requirePermission('kalender:create'),
eventsController.cancelEvent.bind(eventsController) eventsController.cancelEvent.bind(eventsController)
); );
@@ -168,7 +168,7 @@ router.delete(
router.post( router.post(
'/:id/delete', '/:id/delete',
authenticate, authenticate,
requirePermission('kalender:create_events'), requirePermission('kalender:create'),
eventsController.deleteEvent.bind(eventsController) eventsController.deleteEvent.bind(eventsController)
); );

View File

@@ -17,19 +17,19 @@ router.use(authenticate);
// "stats" as a userId parameter. // "stats" as a userId parameter.
router.get( router.get(
'/stats', '/stats',
requirePermission('mitglieder:view'), requirePermission('mitglieder:view_all'),
memberController.getMemberStats.bind(memberController) memberController.getMemberStats.bind(memberController)
); );
router.get( router.get(
'/', '/',
requirePermission('mitglieder:view'), requirePermission('mitglieder:view_all'),
memberController.getMembers.bind(memberController) memberController.getMembers.bind(memberController)
); );
router.get( router.get(
'/:userId', '/:userId',
requirePermission('mitglieder:view'), requirePermission('mitglieder:view_all'),
memberController.getMemberById.bind(memberController) memberController.getMemberById.bind(memberController)
); );
@@ -41,25 +41,25 @@ router.post(
router.get( router.get(
'/:userId/befoerderungen', '/:userId/befoerderungen',
requirePermission('mitglieder:view'), requirePermission('mitglieder:view_all'),
memberController.getBefoerderungen.bind(memberController) memberController.getBefoerderungen.bind(memberController)
); );
router.get( router.get(
'/:userId/untersuchungen', '/:userId/untersuchungen',
requirePermission('mitglieder:view'), requirePermission('mitglieder:view_all'),
memberController.getUntersuchungen.bind(memberController) memberController.getUntersuchungen.bind(memberController)
); );
router.get( router.get(
'/:userId/fahrgenehmigungen', '/:userId/fahrgenehmigungen',
requirePermission('mitglieder:view'), requirePermission('mitglieder:view_all'),
memberController.getFahrgenehmigungen.bind(memberController) memberController.getFahrgenehmigungen.bind(memberController)
); );
router.get( router.get(
'/:userId/ausbildungen', '/:userId/ausbildungen',
requirePermission('mitglieder:view'), requirePermission('mitglieder:view_all'),
memberController.getAusbildungen.bind(memberController) memberController.getAusbildungen.bind(memberController)
); );

View File

@@ -9,9 +9,10 @@ const router = Router();
router.get('/me', authenticate, permissionController.getMyPermissions.bind(permissionController)); router.get('/me', authenticate, permissionController.getMyPermissions.bind(permissionController));
// ── Admin-only routes ───────────────────────────────────────────────────── // ── Admin-only routes ─────────────────────────────────────────────────────
router.get('/admin/matrix', authenticate, requirePermission('admin:access'), permissionController.getMatrix.bind(permissionController)); router.get('/admin/matrix', authenticate, requirePermission('admin:view'), permissionController.getMatrix.bind(permissionController));
router.get('/admin/groups', authenticate, requirePermission('admin:access'), permissionController.getGroups.bind(permissionController)); router.get('/admin/groups', authenticate, requirePermission('admin:view'), permissionController.getGroups.bind(permissionController));
router.put('/admin/group/:groupName', authenticate, requirePermission('admin:access'), permissionController.setGroupPermissions.bind(permissionController)); router.get('/admin/unknown-groups', authenticate, requirePermission('admin:view'), permissionController.getUnknownGroups.bind(permissionController));
router.put('/admin/maintenance/:featureGroupId', authenticate, requirePermission('admin:access'), permissionController.setMaintenanceFlag.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; export default router;

View File

@@ -4,7 +4,7 @@ import { authenticate } from '../middleware/auth.middleware';
import { requirePermission } from '../middleware/rbac.middleware'; import { requirePermission } from '../middleware/rbac.middleware';
const router = Router(); 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) // Static routes first (before parameterized :id routes)
router.get('/services/ping', ...auth, serviceMonitorController.pingAll.bind(serviceMonitorController)); router.get('/services/ping', ...auth, serviceMonitorController.pingAll.bind(serviceMonitorController));

View File

@@ -4,7 +4,7 @@ import { authenticate } from '../middleware/auth.middleware';
import { requirePermission } from '../middleware/rbac.middleware'; import { requirePermission } from '../middleware/rbac.middleware';
const router = Router(); 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('/', ...auth, settingsController.getAll.bind(settingsController));
router.get('/preferences', authenticate, settingsController.getUserPreferences.bind(settingsController)); router.get('/preferences', authenticate, settingsController.getUserPreferences.bind(settingsController));

View File

@@ -91,12 +91,12 @@ router.get(
/** /**
* POST /api/training * POST /api/training
* Create a new training event. * Create a new training event.
* Requires Gruppenführer or above (requirePermission('kalender:create_training')). * Requires Gruppenführer or above (requirePermission('kalender:create')).
*/ */
router.post( router.post(
'/', '/',
authenticate, authenticate,
requirePermission('kalender:create_training'), requirePermission('kalender:create'),
trainingController.createEvent trainingController.createEvent
); );
@@ -108,7 +108,7 @@ router.post(
router.patch( router.patch(
'/:id', '/:id',
authenticate, authenticate,
requirePermission('kalender:create_training'), requirePermission('kalender:create'),
trainingController.updateEvent trainingController.updateEvent
); );
@@ -120,7 +120,7 @@ router.patch(
router.delete( router.delete(
'/:id', '/:id',
authenticate, authenticate,
requirePermission('kalender:cancel_training'), requirePermission('kalender:cancel'),
trainingController.cancelEvent trainingController.cancelEvent
); );

View File

@@ -131,6 +131,19 @@ class PermissionService {
return result.rows.map((r: any) => r.authentik_group); return result.rows.map((r: any) => r.authentik_group);
} }
async getUnknownGroups(): Promise<string[]> {
// 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<void> { async setGroupPermissions(group: string, permIds: string[], grantedBy: string): Promise<void> {
const client = await pool.connect(); const client = await pool.connect();
try { try {

View File

@@ -1,6 +1,8 @@
import React, { useState, useCallback } from 'react'; import React, { useState, useCallback, useMemo } from 'react';
import { import {
Alert,
Box, Box,
Button,
Card, Card,
CardContent, CardContent,
Checkbox, Checkbox,
@@ -19,12 +21,141 @@ import {
Tooltip, Tooltip,
Typography, Typography,
} from '@mui/material'; } 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 { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { permissionsApi } from '../../services/permissions'; import { permissionsApi } from '../../services/permissions';
import { useNotification } from '../../contexts/NotificationContext'; import { useNotification } from '../../contexts/NotificationContext';
import type { PermissionMatrix, FeatureGroup, Permission } from '../../types/permissions.types'; 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<string, string[]> = {
// 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<string, string[]> = {
'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<string, string[]> {
const reverse: Record<string, string[]> = {};
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<string>()): Set<string> {
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<string, string[]> {
const rev: Record<string, string[]> = {};
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<string>()): Set<string> {
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<string>, permId: string): Set<string> {
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<string>, permId: string): Set<string> {
const allToRemove = collectAllDependents(permId);
for (const p of allToRemove) current.delete(p);
return current;
}
// ── Component ──
function PermissionMatrixTab() { function PermissionMatrixTab() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { showSuccess, showError } = useNotification(); const { showSuccess, showError } = useNotification();
@@ -34,14 +165,18 @@ function PermissionMatrixTab() {
queryFn: permissionsApi.getMatrix, queryFn: permissionsApi.getMatrix,
}); });
// Track which feature groups are expanded const { data: unknownGroups } = useQuery<string[]>({
queryKey: ['admin-unknown-groups'],
queryFn: permissionsApi.getUnknownGroups,
});
const [expandedGroups, setExpandedGroups] = useState<Record<string, boolean>>({}); const [expandedGroups, setExpandedGroups] = useState<Record<string, boolean>>({});
const toggleGroup = (groupId: string) => { const toggleGroup = (groupId: string) => {
setExpandedGroups(prev => ({ ...prev, [groupId]: !prev[groupId] })); setExpandedGroups(prev => ({ ...prev, [groupId]: !prev[groupId] }));
}; };
// ── Maintenance toggle mutation ── // ── Maintenance toggle ──
const maintenanceMutation = useMutation({ const maintenanceMutation = useMutation({
mutationFn: ({ featureGroup, active }: { featureGroup: string; active: boolean }) => mutationFn: ({ featureGroup, active }: { featureGroup: string; active: boolean }) =>
permissionsApi.setMaintenanceFlag(featureGroup, active), permissionsApi.setMaintenanceFlag(featureGroup, active),
@@ -53,10 +188,10 @@ function PermissionMatrixTab() {
onError: () => showError('Fehler beim Aktualisieren des Wartungsmodus'), onError: () => showError('Fehler beim Aktualisieren des Wartungsmodus'),
}); });
// ── Permission toggle mutation (saves full group permissions) ── // ── Permission save (saves full group permissions) ──
const permissionMutation = useMutation({ const permissionMutation = useMutation({
mutationFn: ({ group, permissions }: { group: string; permissions: string[] }) => mutationFn: (updates: { group: string; permissions: string[] }[]) =>
permissionsApi.setGroupPermissions(group, permissions), Promise.all(updates.map(u => permissionsApi.setGroupPermissions(u.group, u.permissions))),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin-permission-matrix'] }); queryClient.invalidateQueries({ queryKey: ['admin-permission-matrix'] });
queryClient.invalidateQueries({ queryKey: ['my-permissions'] }); queryClient.invalidateQueries({ queryKey: ['my-permissions'] });
@@ -65,40 +200,185 @@ function PermissionMatrixTab() {
onError: () => showError('Fehler beim Speichern der Berechtigungen'), onError: () => showError('Fehler beim Speichern der Berechtigungen'),
}); });
const handlePermissionToggle = useCallback( // ── Add unknown group ──
(group: string, permId: string, currentGrants: Record<string, string[]>) => { const addGroupMutation = useMutation({
const current = currentGrants[group] || []; mutationFn: (groupName: string) => permissionsApi.setGroupPermissions(groupName, []),
const newPerms = current.includes(permId) onSuccess: () => {
? current.filter(p => p !== permId) queryClient.invalidateQueries({ queryKey: ['admin-permission-matrix'] });
: [...current, permId]; queryClient.invalidateQueries({ queryKey: ['admin-unknown-groups'] });
permissionMutation.mutate({ group, permissions: newPerms }); 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<string, string[]>,
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<string>();
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<string>();
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<string, string[]>, allGroups: string[]) => {
const updates = computeUpdates(group, permId, grants, allGroups);
if (updates.length > 0) {
permissionMutation.mutate(updates);
}
},
[computeUpdates, permissionMutation],
); );
const handleSelectAllForGroup = useCallback( const handleSelectAllForGroup = useCallback(
( (
authentikGroup: string, authentikGroup: string,
featureGroupId: string, featureGroupId: string,
permissions: Permission[], allPermissions: Permission[],
currentGrants: Record<string, string[]>, grants: Record<string, string[]>,
selectAll: boolean allGroups: string[],
selectAll: boolean,
) => { ) => {
const fgPermIds = permissions const fgPermIds = allPermissions
.filter(p => p.feature_group_id === featureGroupId) .filter(p => p.feature_group_id === featureGroupId)
.map(p => p.id); .map(p => p.id);
const current = currentGrants[authentikGroup] || [];
let newPerms: string[]; // Build combined updates across all affected groups
if (selectAll) { const allUpdates = new Map<string, Set<string>>();
const permSet = new Set([...current, ...fgPermIds]);
newPerms = Array.from(permSet); // Initialize with current grants for potentially affected groups
} else { const initGroup = (g: string) => {
const removeSet = new Set(fgPermIds); if (!allUpdates.has(g)) {
newPerms = current.filter(p => !removeSet.has(p)); allUpdates.set(g, new Set(grants[g] || []));
}
};
initGroup(authentikGroup);
if (selectAll) {
// 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 {
// 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<string>()),
[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) { if (isLoading || !matrix) {
@@ -114,6 +394,29 @@ function PermissionMatrixTab() {
return ( return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}> <Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
{/* Unknown Groups Alert */}
{unknownGroups && unknownGroups.length > 0 && (
<Alert severity="warning" sx={{ alignItems: 'center' }}>
<Typography variant="body2" sx={{ mb: 1 }}>
Folgende Gruppen wurden in der Benutzertabelle gefunden, sind aber noch nicht in der Berechtigungsmatrix:
</Typography>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
{unknownGroups.map(g => (
<Button
key={g}
variant="outlined"
size="small"
startIcon={<AddIcon />}
onClick={() => addGroupMutation.mutate(g)}
disabled={addGroupMutation.isPending}
>
{g} hinzufügen
</Button>
))}
</Box>
</Alert>
)}
{/* Section 1: Maintenance Toggles */} {/* Section 1: Maintenance Toggles */}
<Card> <Card>
<CardContent> <CardContent>
@@ -162,13 +465,22 @@ function PermissionMatrixTab() {
<Table size="small" stickyHeader> <Table size="small" stickyHeader>
<TableHead> <TableHead>
<TableRow> <TableRow>
<TableCell sx={{ minWidth: 250, fontWeight: 'bold', position: 'sticky', left: 0, zIndex: 3, bgcolor: 'background.paper' }}> <TableCell
sx={{
minWidth: 250,
fontWeight: 'bold',
position: 'sticky',
left: 0,
zIndex: 3,
bgcolor: 'background.paper',
}}
>
Berechtigung Berechtigung
</TableCell> </TableCell>
{/* dashboard_admin column */} {/* dashboard_admin column */}
<Tooltip title="Admin hat immer vollen Zugriff" placement="top"> <Tooltip title="Admin hat immer vollen Zugriff" placement="top">
<TableCell align="center" sx={{ minWidth: 120, fontWeight: 'bold', opacity: 0.5 }}> <TableCell align="center" sx={{ minWidth: 120, fontWeight: 'bold', opacity: 0.5 }}>
dashboard_admin admin
</TableCell> </TableCell>
</Tooltip> </Tooltip>
{nonAdminGroups.map(g => ( {nonAdminGroups.map(g => (
@@ -215,7 +527,7 @@ function PermissionMatrixTab() {
{/* Per-group: select all / deselect all */} {/* Per-group: select all / deselect all */}
{nonAdminGroups.map(g => { {nonAdminGroups.map(g => {
const groupGrants = grants[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)); const someGranted = fgPerms.some((p: Permission) => groupGrants.includes(p.id));
return ( return (
<TableCell key={g} align="center"> <TableCell key={g} align="center">
@@ -223,7 +535,7 @@ function PermissionMatrixTab() {
checked={allGranted} checked={allGranted}
indeterminate={someGranted && !allGranted} indeterminate={someGranted && !allGranted}
onChange={() => onChange={() =>
handleSelectAllForGroup(g, fg.id, permissions, grants, !allGranted) handleSelectAllForGroup(g, fg.id, permissions, grants, groups, !allGranted)
} }
disabled={permissionMutation.isPending} disabled={permissionMutation.isPending}
size="small" size="small"
@@ -239,7 +551,10 @@ function PermissionMatrixTab() {
<Collapse in={isExpanded} timeout="auto" unmountOnExit> <Collapse in={isExpanded} timeout="auto" unmountOnExit>
<Table size="small"> <Table size="small">
<TableBody> <TableBody>
{fgPerms.map((perm: Permission) => ( {fgPerms.map((perm: Permission) => {
const depTooltip = getDepTooltip(perm.id);
const tooltipText = [perm.description, depTooltip].filter(Boolean).join('\n');
return (
<TableRow key={perm.id} hover> <TableRow key={perm.id} hover>
<TableCell <TableCell
sx={{ sx={{
@@ -251,7 +566,7 @@ function PermissionMatrixTab() {
bgcolor: 'background.paper', bgcolor: 'background.paper',
}} }}
> >
<Tooltip title={perm.description || ''} placement="right"> <Tooltip title={tooltipText || ''} placement="right">
<span>{perm.label}</span> <span>{perm.label}</span>
</Tooltip> </Tooltip>
</TableCell> </TableCell>
@@ -265,7 +580,9 @@ function PermissionMatrixTab() {
<TableCell key={g} align="center" sx={{ minWidth: 120 }}> <TableCell key={g} align="center" sx={{ minWidth: 120 }}>
<Checkbox <Checkbox
checked={isGranted} checked={isGranted}
onChange={() => handlePermissionToggle(g, perm.id, grants)} onChange={() =>
handlePermissionToggle(g, perm.id, grants, groups)
}
disabled={permissionMutation.isPending} disabled={permissionMutation.isPending}
size="small" size="small"
/> />
@@ -273,7 +590,8 @@ function PermissionMatrixTab() {
); );
})} })}
</TableRow> </TableRow>
))} );
})}
</TableBody> </TableBody>
</Table> </Table>
</Collapse> </Collapse>

View File

@@ -40,7 +40,7 @@ function makeDefaults() {
const EventQuickAddWidget: React.FC = () => { const EventQuickAddWidget: React.FC = () => {
const { hasPermission } = usePermissionContext(); const { hasPermission } = usePermissionContext();
const canWrite = hasPermission('kalender:create_events'); const canWrite = hasPermission('kalender:create');
const defaults = makeDefaults(); const defaults = makeDefaults();
const [titel, setTitel] = useState(''); const [titel, setTitel] = useState('');

View File

@@ -1,3 +1,4 @@
import React from 'react';
import { Box, Typography } from '@mui/material'; import { Box, Typography } from '@mui/material';
interface WidgetGroupProps { interface WidgetGroupProps {
@@ -7,6 +8,11 @@ interface WidgetGroupProps {
} }
function WidgetGroup({ title, children, gridColumn }: 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 ( return (
<Box <Box
sx={{ sx={{

View File

@@ -74,7 +74,7 @@ const baseNavigationItems: NavigationItem[] = [
icon: <CalendarMonth />, icon: <CalendarMonth />,
path: '/kalender', path: '/kalender',
subItems: kalenderSubItems, subItems: kalenderSubItems,
permission: 'kalender:access', permission: 'kalender:view',
}, },
{ {
text: 'Fahrzeuge', text: 'Fahrzeuge',
@@ -86,25 +86,25 @@ const baseNavigationItems: NavigationItem[] = [
text: 'Ausrüstung', text: 'Ausrüstung',
icon: <Build />, icon: <Build />,
path: '/ausruestung', path: '/ausruestung',
permission: 'ausruestung:access', permission: 'ausruestung:view',
}, },
{ {
text: 'Mitglieder', text: 'Mitglieder',
icon: <People />, icon: <People />,
path: '/mitglieder', path: '/mitglieder',
permission: 'mitglieder:access', permission: 'mitglieder:view_own',
}, },
{ {
text: 'Atemschutz', text: 'Atemschutz',
icon: <Air />, icon: <Air />,
path: '/atemschutz', path: '/atemschutz',
permission: 'atemschutz:access', permission: 'atemschutz:view',
}, },
{ {
text: 'Wissen', text: 'Wissen',
icon: <MenuBook />, icon: <MenuBook />,
path: '/wissen', path: '/wissen',
permission: 'wissen:access', permission: 'wissen:view',
}, },
]; ];
@@ -130,7 +130,7 @@ function Sidebar({ mobileOpen, onMobileClose }: SidebarProps) {
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation(); const location = useLocation();
const { sidebarCollapsed, toggleSidebar } = useLayout(); const { sidebarCollapsed, toggleSidebar } = useLayout();
const { hasPermission, isAdmin } = usePermissionContext(); const { hasPermission } = usePermissionContext();
// Fetch vehicle list for dynamic dropdown sub-items // Fetch vehicle list for dynamic dropdown sub-items
const { data: vehicleList } = useQuery({ const { data: vehicleList } = useQuery({
@@ -154,13 +154,13 @@ function Sidebar({ mobileOpen, onMobileClose }: SidebarProps) {
icon: <DirectionsCar />, icon: <DirectionsCar />,
path: '/fahrzeuge', path: '/fahrzeuge',
subItems: vehicleSubItems.length > 0 ? vehicleSubItems : undefined, subItems: vehicleSubItems.length > 0 ? vehicleSubItems : undefined,
permission: 'fahrzeuge:access', permission: 'fahrzeuge:view',
}; };
const items = baseNavigationItems const items = baseNavigationItems
.map((item) => item.path === '/fahrzeuge' ? fahrzeugeItem : item) .map((item) => item.path === '/fahrzeuge' ? fahrzeugeItem : item)
.filter((item) => !item.permission || hasPermission(item.permission)); .filter((item) => !item.permission || hasPermission(item.permission));
return isAdmin ? [...items, adminItem, adminSettingsItem] : items; return hasPermission('admin:view') ? [...items, adminItem, adminSettingsItem] : items;
}, [isAdmin, vehicleSubItems, hasPermission]); }, [vehicleSubItems, hasPermission]);
// Expand state for items with sub-items — auto-expand when route matches // Expand state for items with sub-items — auto-expand when route matches
const [expandedItems, setExpandedItems] = useState<Record<string, boolean>>({}); const [expandedItems, setExpandedItems] = useState<Record<string, boolean>>({});

View File

@@ -10,7 +10,7 @@ import BannerManagementTab from '../components/admin/BannerManagementTab';
import ServiceModeTab from '../components/admin/ServiceModeTab'; import ServiceModeTab from '../components/admin/ServiceModeTab';
import FdiskSyncTab from '../components/admin/FdiskSyncTab'; import FdiskSyncTab from '../components/admin/FdiskSyncTab';
import PermissionMatrixTab from '../components/admin/PermissionMatrixTab'; import PermissionMatrixTab from '../components/admin/PermissionMatrixTab';
import { useAuth } from '../contexts/AuthContext'; import { usePermissionContext } from '../contexts/PermissionContext';
interface TabPanelProps { interface TabPanelProps {
children: React.ReactNode; children: React.ReactNode;
@@ -37,11 +37,9 @@ function AdminDashboard() {
const t = Number(searchParams.get('tab')); const t = Number(searchParams.get('tab'));
if (t >= 0 && t < ADMIN_TAB_COUNT) setTab(t); if (t >= 0 && t < ADMIN_TAB_COUNT) setTab(t);
}, [searchParams]); }, [searchParams]);
const { user } = useAuth(); const { hasPermission } = usePermissionContext();
const isAdmin = user?.groups?.includes('dashboard_admin') ?? false; if (!hasPermission('admin:view')) {
if (!isAdmin) {
return <Navigate to="/dashboard" replace />; return <Navigate to="/dashboard" replace />;
} }

View File

@@ -34,7 +34,7 @@ import { WidgetKey } from '../constants/widgets';
function Dashboard() { function Dashboard() {
const { user } = useAuth(); const { user } = useAuth();
const { hasPermission, isAdmin } = usePermissionContext(); const { hasPermission } = usePermissionContext();
const [dataLoading, setDataLoading] = useState(true); const [dataLoading, setDataLoading] = useState(true);
const { data: preferences } = useQuery({ const { data: preferences } = useQuery({
@@ -99,7 +99,7 @@ function Dashboard() {
{/* Status Group */} {/* Status Group */}
<WidgetGroup title="Status" gridColumn="1 / -1"> <WidgetGroup title="Status" gridColumn="1 / -1">
{widgetVisible('vehicles') && ( {hasPermission('fahrzeuge:widget') && widgetVisible('vehicles') && (
<Fade in={!dataLoading} timeout={600} style={{ transitionDelay: '300ms' }}> <Fade in={!dataLoading} timeout={600} style={{ transitionDelay: '300ms' }}>
<Box> <Box>
<VehicleDashboardCard /> <VehicleDashboardCard />
@@ -107,7 +107,7 @@ function Dashboard() {
</Fade> </Fade>
)} )}
{widgetVisible('equipment') && ( {hasPermission('ausruestung:widget') && widgetVisible('equipment') && (
<Fade in={!dataLoading} timeout={600} style={{ transitionDelay: '350ms' }}> <Fade in={!dataLoading} timeout={600} style={{ transitionDelay: '350ms' }}>
<Box> <Box>
<EquipmentDashboardCard /> <EquipmentDashboardCard />
@@ -123,7 +123,7 @@ function Dashboard() {
</Fade> </Fade>
)} )}
{isAdmin && widgetVisible('adminStatus') && ( {hasPermission('admin:view') && widgetVisible('adminStatus') && (
<Fade in={!dataLoading} timeout={600} style={{ transitionDelay: '440ms' }}> <Fade in={!dataLoading} timeout={600} style={{ transitionDelay: '440ms' }}>
<Box> <Box>
<AdminStatusWidget /> <AdminStatusWidget />
@@ -134,7 +134,7 @@ function Dashboard() {
{/* Kalender Group */} {/* Kalender Group */}
<WidgetGroup title="Kalender" gridColumn="1 / -1"> <WidgetGroup title="Kalender" gridColumn="1 / -1">
{widgetVisible('events') && ( {hasPermission('kalender:widget_events') && widgetVisible('events') && (
<Fade in={!dataLoading} timeout={600} style={{ transitionDelay: '480ms' }}> <Fade in={!dataLoading} timeout={600} style={{ transitionDelay: '480ms' }}>
<Box> <Box>
<UpcomingEventsWidget /> <UpcomingEventsWidget />
@@ -142,7 +142,7 @@ function Dashboard() {
</Fade> </Fade>
)} )}
{widgetVisible('vehicleBookingList') && ( {hasPermission('kalender:widget_bookings') && widgetVisible('vehicleBookingList') && (
<Fade in={!dataLoading} timeout={600} style={{ transitionDelay: '520ms' }}> <Fade in={!dataLoading} timeout={600} style={{ transitionDelay: '520ms' }}>
<Box> <Box>
<VehicleBookingListWidget /> <VehicleBookingListWidget />
@@ -150,7 +150,7 @@ function Dashboard() {
</Fade> </Fade>
)} )}
{widgetVisible('vehicleBooking') && ( {hasPermission('kalender:create_bookings') && widgetVisible('vehicleBooking') && (
<Fade in={!dataLoading} timeout={600} style={{ transitionDelay: '560ms' }}> <Fade in={!dataLoading} timeout={600} style={{ transitionDelay: '560ms' }}>
<Box> <Box>
<VehicleBookingQuickAddWidget /> <VehicleBookingQuickAddWidget />
@@ -169,7 +169,7 @@ function Dashboard() {
{/* Dienste Group */} {/* Dienste Group */}
<WidgetGroup title="Dienste" gridColumn="1 / -1"> <WidgetGroup title="Dienste" gridColumn="1 / -1">
{widgetVisible('bookstackRecent') && ( {hasPermission('wissen:widget_recent') && widgetVisible('bookstackRecent') && (
<Fade in={!dataLoading} timeout={600} style={{ transitionDelay: '600ms' }}> <Fade in={!dataLoading} timeout={600} style={{ transitionDelay: '600ms' }}>
<Box> <Box>
<BookStackRecentWidget /> <BookStackRecentWidget />
@@ -177,7 +177,7 @@ function Dashboard() {
</Fade> </Fade>
)} )}
{widgetVisible('bookstackSearch') && ( {hasPermission('wissen:widget_search') && widgetVisible('bookstackSearch') && (
<Fade in={!dataLoading} timeout={600} style={{ transitionDelay: '640ms' }}> <Fade in={!dataLoading} timeout={600} style={{ transitionDelay: '640ms' }}>
<Box> <Box>
<BookStackSearchWidget /> <BookStackSearchWidget />
@@ -185,7 +185,7 @@ function Dashboard() {
</Fade> </Fade>
)} )}
{widgetVisible('vikunjaTasks') && ( {hasPermission('vikunja:widget_tasks') && widgetVisible('vikunjaTasks') && (
<Fade in={!dataLoading} timeout={600} style={{ transitionDelay: '680ms' }}> <Fade in={!dataLoading} timeout={600} style={{ transitionDelay: '680ms' }}>
<Box> <Box>
<VikunjaMyTasksWidget /> <VikunjaMyTasksWidget />
@@ -193,7 +193,7 @@ function Dashboard() {
</Fade> </Fade>
)} )}
{widgetVisible('vikunjaQuickAdd') && ( {hasPermission('vikunja:widget_quick_add') && widgetVisible('vikunjaQuickAdd') && (
<Fade in={!dataLoading} timeout={600} style={{ transitionDelay: '720ms' }}> <Fade in={!dataLoading} timeout={600} style={{ transitionDelay: '720ms' }}>
<Box> <Box>
<VikunjaQuickAddWidget /> <VikunjaQuickAddWidget />
@@ -204,7 +204,7 @@ function Dashboard() {
{/* Information Group */} {/* Information Group */}
<WidgetGroup title="Information" gridColumn="1 / -1"> <WidgetGroup title="Information" gridColumn="1 / -1">
{widgetVisible('links') && linkCollections.map((collection, idx) => ( {hasPermission('dashboard:widget_links') && widgetVisible('links') && linkCollections.map((collection, idx) => (
<Fade key={collection.id} in={!dataLoading} timeout={600} style={{ transitionDelay: `${760 + idx * 40}ms` }}> <Fade key={collection.id} in={!dataLoading} timeout={600} style={{ transitionDelay: `${760 + idx * 40}ms` }}>
<Box> <Box>
<LinksWidget collection={collection} /> <LinksWidget collection={collection} />
@@ -212,11 +212,13 @@ function Dashboard() {
</Fade> </Fade>
))} ))}
{hasPermission('dashboard:widget_banner') && (
<Fade in={!dataLoading} timeout={600} style={{ transitionDelay: `${760 + linkCollections.length * 40}ms` }}> <Fade in={!dataLoading} timeout={600} style={{ transitionDelay: `${760 + linkCollections.length * 40}ms` }}>
<Box> <Box>
<BannerWidget /> <BannerWidget />
</Box> </Box>
</Fade> </Fade>
)}
</WidgetGroup> </WidgetGroup>
</Box> </Box>
</Container> </Container>

View File

@@ -96,6 +96,7 @@ function FahrzeugBuchungen() {
const notification = useNotification(); const notification = useNotification();
const canCreate = hasPermission('kalender:create_bookings'); const canCreate = hasPermission('kalender:create_bookings');
const canWrite = hasPermission('kalender:edit_bookings'); const canWrite = hasPermission('kalender:edit_bookings');
const canCancelOwn = hasPermission('kalender:cancel_own_bookings');
const canChangeBuchungsArt = hasPermission('kalender:manage_categories'); const canChangeBuchungsArt = hasPermission('kalender:manage_categories');
// ── Week navigation ──────────────────────────────────────────────────────── // ── Week navigation ────────────────────────────────────────────────────────
@@ -691,7 +692,7 @@ function FahrzeugBuchungen() {
Von: {detailBooking.gebucht_von_name} Von: {detailBooking.gebucht_von_name}
</Typography> </Typography>
)} )}
{(canWrite || detailBooking.gebucht_von === user?.id) && ( {(canWrite || (canCancelOwn && detailBooking.gebucht_von === user?.id)) && (
<Box sx={{ mt: 1.5, display: 'flex', gap: 1 }}> <Box sx={{ mt: 1.5, display: 'flex', gap: 1 }}>
{canWrite && ( {canWrite && (
<Button <Button

View File

@@ -1707,7 +1707,7 @@ export default function Kalender() {
const theme = useTheme(); const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('sm')); const isMobile = useMediaQuery(theme.breakpoints.down('sm'));
const canWriteEvents = hasPermission('kalender:create_events'); const canWriteEvents = hasPermission('kalender:create');
const canWriteBookings = hasPermission('kalender:edit_bookings'); const canWriteBookings = hasPermission('kalender:edit_bookings');
const canCreateBookings = hasPermission('kalender:create_bookings'); const canCreateBookings = hasPermission('kalender:create_bookings');

View File

@@ -80,7 +80,7 @@ function Mitglieder() {
// --- redirect non-privileged users to their own profile --- // --- redirect non-privileged users to their own profile ---
useEffect(() => { useEffect(() => {
if (!user) return; if (!user) return;
if (!hasPermission('mitglieder:edit')) { if (!hasPermission('mitglieder:view_all')) {
navigate(`/mitglieder/${(user as any).id}`, { replace: true }); navigate(`/mitglieder/${(user as any).id}`, { replace: true });
} }
}, [user, navigate, hasPermission]); }, [user, navigate, hasPermission]);

View File

@@ -1074,7 +1074,7 @@ export default function Veranstaltungen() {
const theme = useTheme(); const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('sm')); const isMobile = useMediaQuery(theme.breakpoints.down('sm'));
const canWrite = hasPermission('kalender:create_events'); const canWrite = hasPermission('kalender:create');
const today = new Date(); const today = new Date();
const [viewMonth, setViewMonth] = useState({ year: today.getFullYear(), month: today.getMonth() }); const [viewMonth, setViewMonth] = useState({ year: today.getFullYear(), month: today.getMonth() });

View File

@@ -24,4 +24,9 @@ export const permissionsApi = {
setMaintenanceFlag: async (featureGroup: string, active: boolean): Promise<void> => { setMaintenanceFlag: async (featureGroup: string, active: boolean): Promise<void> => {
await api.put(`/api/permissions/admin/maintenance/${encodeURIComponent(featureGroup)}`, { active }); await api.put(`/api/permissions/admin/maintenance/${encodeURIComponent(featureGroup)}`, { active });
}, },
getUnknownGroups: async (): Promise<string[]> => {
const r = await api.get('/api/permissions/admin/unknown-groups');
return r.data.data;
},
}; };