rights system

This commit is contained in:
Matthias Hochmeister
2026-03-23 10:07:53 +01:00
parent f976f36cbc
commit 2bb22850f4
35 changed files with 1565 additions and 282 deletions

View File

@@ -97,6 +97,7 @@ import configRoutes from './routes/config.routes';
import serviceMonitorRoutes from './routes/serviceMonitor.routes';
import settingsRoutes from './routes/settings.routes';
import bannerRoutes from './routes/banner.routes';
import permissionRoutes from './routes/permission.routes';
app.use('/api/auth', authRoutes);
app.use('/api/user', userRoutes);
@@ -118,6 +119,7 @@ app.use('/api/admin', serviceMonitorRoutes);
app.use('/api/admin/settings', settingsRoutes);
app.use('/api/settings', settingsRoutes);
app.use('/api/banners', bannerRoutes);
app.use('/api/permissions', permissionRoutes);
// 404 handler
app.use(notFoundHandler);

View File

@@ -2,7 +2,8 @@ import { Request, Response } from 'express';
import incidentService from '../services/incident.service';
import logger from '../utils/logger';
import { AppError } from '../middleware/error.middleware';
import { AppRole, hasPermission, resolveRequestRole } from '../middleware/rbac.middleware';
import { AppRole } from '../middleware/rbac.middleware';
import { permissionService } from '../services/permission.service';
import {
CreateEinsatzSchema,
UpdateEinsatzSchema,
@@ -88,9 +89,11 @@ class IncidentController {
throw new AppError('Einsatz nicht gefunden', 404);
}
// Role-based redaction: self-contained role resolution (no middleware dependency)
const role = resolveRequestRole(req);
const canReadBerichtText = hasPermission(role, 'incidents:read_bericht_text');
// Role-based redaction: check einsaetze:view_reports permission
const groups: string[] = req.user?.groups ?? [];
const canReadBerichtText =
groups.includes('dashboard_admin') ||
permissionService.hasPermission(groups, 'einsaetze:view_reports');
const responseData = {
...incident,

View File

@@ -0,0 +1,116 @@
import { Request, Response } from 'express';
import { permissionService } from '../services/permission.service';
import logger from '../utils/logger';
class PermissionController {
/**
* GET /api/permissions/me
* Returns the current user's effective permissions.
*/
async getMyPermissions(req: Request, res: Response): Promise<void> {
try {
const groups: string[] = req.user?.groups ?? [];
const isAdmin = groups.includes('dashboard_admin');
let permissions: string[];
if (isAdmin) {
// Admin gets all permissions
const matrix = await permissionService.getMatrix();
permissions = matrix.permissions.map(p => p.id);
} else {
permissions = permissionService.getEffectivePermissions(groups);
}
res.json({
success: true,
data: {
permissions,
maintenance: permissionService.getMaintenanceFlags(),
isAdmin,
},
});
} catch (error) {
logger.error('Failed to get user permissions', { error });
res.status(500).json({ success: false, message: 'Fehler beim Laden der Berechtigungen' });
}
}
/**
* GET /api/admin/permissions/matrix
* Returns the full permission matrix for the admin UI.
*/
async getMatrix(_req: Request, res: Response): Promise<void> {
try {
const matrix = await permissionService.getMatrix();
res.json({ success: true, data: matrix });
} catch (error) {
logger.error('Failed to get permission matrix', { error });
res.status(500).json({ success: false, message: 'Fehler beim Laden der Berechtigungsmatrix' });
}
}
/**
* PUT /api/admin/permissions/group/:groupName
* Sets all permissions for a given Authentik group.
*/
async setGroupPermissions(req: Request, res: Response): Promise<void> {
try {
const groupName = req.params.groupName as string;
const { permissions } = req.body;
if (!Array.isArray(permissions)) {
res.status(400).json({ success: false, message: 'permissions must be an array' });
return;
}
await permissionService.setGroupPermissions(
groupName,
permissions,
req.user!.id
);
res.json({ success: true, message: 'Berechtigungen aktualisiert' });
} catch (error) {
logger.error('Failed to set group permissions', { error });
res.status(500).json({ success: false, message: 'Fehler beim Speichern der Berechtigungen' });
}
}
/**
* GET /api/admin/permissions/groups
* Returns all known Authentik groups from the permission table.
*/
async getGroups(_req: Request, res: Response): Promise<void> {
try {
const groups = await permissionService.getKnownGroups();
res.json({ success: true, data: groups });
} catch (error) {
logger.error('Failed to get groups', { error });
res.status(500).json({ success: false, message: 'Fehler beim Laden der Gruppen' });
}
}
/**
* PUT /api/admin/permissions/maintenance/:featureGroupId
* Toggles maintenance mode for a feature group.
*/
async setMaintenanceFlag(req: Request, res: Response): Promise<void> {
try {
const featureGroupId = req.params.featureGroupId as string;
const { active } = req.body;
if (typeof active !== 'boolean') {
res.status(400).json({ success: false, message: 'active must be a boolean' });
return;
}
await permissionService.setMaintenanceFlag(featureGroupId, active);
res.json({ success: true, message: 'Wartungsmodus aktualisiert' });
} catch (error) {
logger.error('Failed to set maintenance flag', { error });
res.status(500).json({ success: false, message: 'Fehler beim Setzen des Wartungsmodus' });
}
}
}
export default new PermissionController();

View File

@@ -0,0 +1,563 @@
-- Migration 037: DB-driven permission system
-- Replaces hardcoded RBAC with per-Authentik-group permission assignments
-- ═══════════════════════════════════════════════════════════════════════════
-- 1. Feature Groups
-- ═══════════════════════════════════════════════════════════════════════════
CREATE TABLE IF NOT EXISTS feature_groups (
id VARCHAR(50) PRIMARY KEY,
label VARCHAR(100) NOT NULL,
sort_order INT NOT NULL DEFAULT 0,
maintenance BOOLEAN NOT NULL DEFAULT FALSE
);
INSERT INTO feature_groups (id, label, sort_order) VALUES
('kalender', 'Kalender', 1),
('fahrzeuge', 'Fahrzeuge', 2),
('einsaetze', 'Einsätze', 3),
('ausruestung', 'Ausrüstung', 4),
('mitglieder', 'Mitglieder', 5),
('atemschutz', 'Atemschutz', 6),
('wissen', 'Wissen', 7),
('vikunja', 'Vikunja', 8),
('nextcloud', 'Nextcloud', 9),
('dashboard', 'Dashboard', 10)
ON CONFLICT (id) DO NOTHING;
-- ═══════════════════════════════════════════════════════════════════════════
-- 2. Permissions
-- ═══════════════════════════════════════════════════════════════════════════
CREATE TABLE IF NOT EXISTS permissions (
id VARCHAR(100) PRIMARY KEY,
feature_group_id VARCHAR(50) NOT NULL REFERENCES feature_groups(id) ON DELETE CASCADE,
label VARCHAR(150) NOT NULL,
description TEXT,
sort_order INT NOT NULL DEFAULT 0
);
-- Kalender permissions
INSERT INTO permissions (id, feature_group_id, label, description, sort_order) VALUES
('kalender:access', 'kalender', 'Zugriff', 'Kalender-Seite anzeigen', 1),
('kalender:view_events', 'kalender', 'Termine ansehen', 'Termine und Übungen einsehen', 2),
('kalender:view_bookings', 'kalender', 'Buchungen ansehen', 'Fahrzeugbuchungen einsehen', 3),
('kalender:create_events', 'kalender', 'Termine erstellen', 'Neue Termine/Veranstaltungen erstellen', 4),
('kalender:create_training', 'kalender', 'Übungen erstellen', 'Neue Übungen anlegen', 5),
('kalender:cancel_training', 'kalender', 'Übungen absagen', 'Übungen absagen/löschen', 6),
('kalender:mark_attendance', 'kalender', 'Anwesenheit eintragen', 'Teilnahme an Übungen bestätigen', 7),
('kalender:create_bookings', 'kalender', 'Buchungen erstellen', 'Neue Fahrzeugbuchungen anlegen', 8),
('kalender:edit_bookings', 'kalender', 'Buchungen bearbeiten', 'Bestehende Buchungen ändern', 9),
('kalender:delete_bookings', 'kalender', 'Buchungen löschen', 'Buchungen endgültig löschen', 10),
('kalender:manage_categories', 'kalender', 'Kategorien verwalten', 'Veranstaltungskategorien verwalten', 11),
('kalender:view_reports', 'kalender', 'Berichte ansehen', 'Übungsstatistiken und Berichte einsehen', 12),
('kalender:widget_events', 'kalender', 'Widget: Termine', 'Dashboard-Widget für Termine', 13),
('kalender:widget_bookings', 'kalender', 'Widget: Buchungen', 'Dashboard-Widget für Buchungen', 14),
('kalender:widget_quick_add', 'kalender', 'Widget: Schnell-Termin', 'Dashboard-Widget zum schnellen Erstellen', 15)
ON CONFLICT (id) DO NOTHING;
-- Fahrzeuge permissions
INSERT INTO permissions (id, feature_group_id, label, description, sort_order) VALUES
('fahrzeuge:access', 'fahrzeuge', 'Zugriff', 'Fahrzeug-Seite anzeigen', 1),
('fahrzeuge:view', 'fahrzeuge', 'Ansehen', 'Fahrzeugdetails einsehen', 2),
('fahrzeuge:create', 'fahrzeuge', 'Erstellen', 'Neue Fahrzeuge anlegen', 3),
('fahrzeuge:change_status', 'fahrzeuge', 'Status ändern', 'Fahrzeugstatus ändern', 4),
('fahrzeuge:manage_maintenance', 'fahrzeuge', 'Wartung verwalten', 'Wartungseinträge erstellen/bearbeiten', 5),
('fahrzeuge:delete', 'fahrzeuge', 'Löschen', 'Fahrzeuge löschen', 6),
('fahrzeuge:widget', 'fahrzeuge', 'Widget', 'Dashboard-Widget für Fahrzeuge', 7)
ON CONFLICT (id) DO NOTHING;
-- Einsätze permissions
INSERT INTO permissions (id, feature_group_id, label, description, sort_order) VALUES
('einsaetze:access', 'einsaetze', 'Zugriff', 'Einsatz-Seite anzeigen', 1),
('einsaetze:view', 'einsaetze', 'Ansehen', 'Einsätze einsehen', 2),
('einsaetze:view_reports', 'einsaetze', 'Berichte ansehen', 'Einsatzberichte/Berichtstext einsehen', 3),
('einsaetze:create', 'einsaetze', 'Erstellen', 'Neue Einsätze anlegen und bearbeiten', 4),
('einsaetze:delete', 'einsaetze', 'Löschen', 'Einsätze archivieren/löschen', 5),
('einsaetze:manage_personnel', 'einsaetze', 'Personal verwalten', 'Einsatzpersonal und Fahrzeuge zuweisen', 6)
ON CONFLICT (id) DO NOTHING;
-- Ausrüstung permissions
INSERT INTO permissions (id, feature_group_id, label, description, sort_order) VALUES
('ausruestung:access', 'ausruestung', 'Zugriff', 'Ausrüstungs-Seite anzeigen', 1),
('ausruestung:view', 'ausruestung', 'Ansehen', 'Ausrüstung einsehen', 2),
('ausruestung:create', 'ausruestung', 'Erstellen', 'Neue Ausrüstung anlegen und bearbeiten', 3),
('ausruestung:manage_maintenance', 'ausruestung', 'Wartung verwalten', 'Wartungseinträge verwalten', 4),
('ausruestung:delete', 'ausruestung', 'Löschen', 'Ausrüstung löschen', 5),
('ausruestung:widget', 'ausruestung', 'Widget', 'Dashboard-Widget für Ausrüstung', 6)
ON CONFLICT (id) DO NOTHING;
-- Mitglieder permissions
INSERT INTO permissions (id, feature_group_id, label, description, sort_order) VALUES
('mitglieder:access', 'mitglieder', 'Zugriff', 'Mitglieder-Seite anzeigen', 1),
('mitglieder:view', 'mitglieder', 'Ansehen', 'Mitglieder-Profile einsehen', 2),
('mitglieder:edit', 'mitglieder', 'Bearbeiten', 'Mitglieder-Profile bearbeiten', 3),
('mitglieder:create_profile', 'mitglieder', 'Profil erstellen', 'Neue Mitglieder-Profile anlegen', 4)
ON CONFLICT (id) DO NOTHING;
-- Atemschutz permissions
INSERT INTO permissions (id, feature_group_id, label, description, sort_order) VALUES
('atemschutz:access', 'atemschutz', 'Zugriff', 'Atemschutz-Seite anzeigen', 1),
('atemschutz:view', 'atemschutz', 'Ansehen', 'Atemschutz-Daten aller Träger sehen', 2),
('atemschutz:create', 'atemschutz', 'Erstellen', 'Atemschutz-Einträge anlegen/ändern', 3),
('atemschutz:delete', 'atemschutz', 'Löschen', 'Atemschutz-Einträge löschen', 4),
('atemschutz:widget', 'atemschutz', 'Widget', 'Dashboard-Widget für Atemschutz', 5)
ON CONFLICT (id) DO NOTHING;
-- Wissen permissions
INSERT INTO permissions (id, feature_group_id, label, description, sort_order) VALUES
('wissen:access', 'wissen', 'Zugriff', 'Wissen-Seite anzeigen', 1),
('wissen:widget_recent', 'wissen', 'Widget: Letzte', 'Dashboard-Widget letzte Seiten', 2),
('wissen:widget_search', 'wissen', 'Widget: Suche', 'Dashboard-Widget für BookStack-Suche', 3)
ON CONFLICT (id) DO NOTHING;
-- Vikunja permissions
INSERT INTO permissions (id, feature_group_id, label, description, sort_order) VALUES
('vikunja:access', 'vikunja', 'Zugriff', 'Vikunja-Integration nutzen', 1),
('vikunja:create_tasks', 'vikunja', 'Aufgaben erstellen', 'Neue Vikunja-Aufgaben erstellen', 2),
('vikunja:widget_tasks', 'vikunja', 'Widget: Aufgaben', 'Dashboard-Widget für Vikunja-Aufgaben', 3),
('vikunja:widget_quick_add', 'vikunja', 'Widget: Schnell-Task', 'Dashboard-Widget zum schnellen Erstellen', 4)
ON CONFLICT (id) DO NOTHING;
-- Nextcloud permissions
INSERT INTO permissions (id, feature_group_id, label, description, sort_order) VALUES
('nextcloud:access', 'nextcloud', 'Zugriff', 'Nextcloud-Integration nutzen', 1),
('nextcloud:widget', 'nextcloud', 'Widget', 'Dashboard-Widget für Nextcloud', 2)
ON CONFLICT (id) DO NOTHING;
-- Dashboard permissions
INSERT INTO permissions (id, feature_group_id, label, description, sort_order) VALUES
('dashboard:widget_links', 'dashboard', 'Widget: Links', 'Dashboard-Widget für externe Links', 1),
('dashboard:widget_banner', 'dashboard', 'Widget: Banner', 'Dashboard-Widget für Banner', 2)
ON CONFLICT (id) DO NOTHING;
-- ═══════════════════════════════════════════════════════════════════════════
-- 3. Group Permissions
-- ═══════════════════════════════════════════════════════════════════════════
CREATE TABLE IF NOT EXISTS group_permissions (
authentik_group VARCHAR(100) NOT NULL,
permission_id VARCHAR(100) NOT NULL REFERENCES permissions(id) ON DELETE CASCADE,
granted_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
granted_by UUID REFERENCES users(id) ON DELETE SET NULL,
PRIMARY KEY (authentik_group, permission_id)
);
CREATE INDEX IF NOT EXISTS idx_group_permissions_group ON group_permissions(authentik_group);
-- ═══════════════════════════════════════════════════════════════════════════
-- 4. Seed data — replicate current RBAC behavior
-- ═══════════════════════════════════════════════════════════════════════════
-- Helper: grant a list of permissions to a group
-- We insert each combination individually with ON CONFLICT DO NOTHING
-- ── All authenticated users (mitglied-level) ──
-- Every non-bewerber group gets access + view + widget permissions
-- dashboard_kommando — gets everything (except admin)
INSERT INTO group_permissions (authentik_group, permission_id) VALUES
-- Kalender
('dashboard_kommando', 'kalender:access'),
('dashboard_kommando', 'kalender:view_events'),
('dashboard_kommando', 'kalender:view_bookings'),
('dashboard_kommando', 'kalender:create_events'),
('dashboard_kommando', 'kalender:create_training'),
('dashboard_kommando', 'kalender:cancel_training'),
('dashboard_kommando', 'kalender:mark_attendance'),
('dashboard_kommando', 'kalender:create_bookings'),
('dashboard_kommando', 'kalender:edit_bookings'),
('dashboard_kommando', 'kalender:delete_bookings'),
('dashboard_kommando', 'kalender:manage_categories'),
('dashboard_kommando', 'kalender:view_reports'),
('dashboard_kommando', 'kalender:widget_events'),
('dashboard_kommando', 'kalender:widget_bookings'),
('dashboard_kommando', 'kalender:widget_quick_add'),
-- Fahrzeuge
('dashboard_kommando', 'fahrzeuge:access'),
('dashboard_kommando', 'fahrzeuge:view'),
('dashboard_kommando', 'fahrzeuge:create'),
('dashboard_kommando', 'fahrzeuge:change_status'),
('dashboard_kommando', 'fahrzeuge:manage_maintenance'),
('dashboard_kommando', 'fahrzeuge:delete'),
('dashboard_kommando', 'fahrzeuge:widget'),
-- Einsätze
('dashboard_kommando', 'einsaetze:access'),
('dashboard_kommando', 'einsaetze:view'),
('dashboard_kommando', 'einsaetze:view_reports'),
('dashboard_kommando', 'einsaetze:create'),
('dashboard_kommando', 'einsaetze:delete'),
('dashboard_kommando', 'einsaetze:manage_personnel'),
-- Ausrüstung
('dashboard_kommando', 'ausruestung:access'),
('dashboard_kommando', 'ausruestung:view'),
('dashboard_kommando', 'ausruestung:create'),
('dashboard_kommando', 'ausruestung:manage_maintenance'),
('dashboard_kommando', 'ausruestung:delete'),
('dashboard_kommando', 'ausruestung:widget'),
-- Mitglieder
('dashboard_kommando', 'mitglieder:access'),
('dashboard_kommando', 'mitglieder:view'),
('dashboard_kommando', 'mitglieder:edit'),
('dashboard_kommando', 'mitglieder:create_profile'),
-- Atemschutz
('dashboard_kommando', 'atemschutz:access'),
('dashboard_kommando', 'atemschutz:view'),
('dashboard_kommando', 'atemschutz:create'),
('dashboard_kommando', 'atemschutz:delete'),
('dashboard_kommando', 'atemschutz:widget'),
-- Wissen
('dashboard_kommando', 'wissen:access'),
('dashboard_kommando', 'wissen:widget_recent'),
('dashboard_kommando', 'wissen:widget_search'),
-- Vikunja
('dashboard_kommando', 'vikunja:access'),
('dashboard_kommando', 'vikunja:create_tasks'),
('dashboard_kommando', 'vikunja:widget_tasks'),
('dashboard_kommando', 'vikunja:widget_quick_add'),
-- Nextcloud
('dashboard_kommando', 'nextcloud:access'),
('dashboard_kommando', 'nextcloud:widget'),
-- Dashboard
('dashboard_kommando', 'dashboard:widget_links'),
('dashboard_kommando', 'dashboard:widget_banner')
ON CONFLICT DO NOTHING;
-- dashboard_gruppenfuehrer — write-level for most features
INSERT INTO group_permissions (authentik_group, permission_id) VALUES
-- Kalender
('dashboard_gruppenfuehrer', 'kalender:access'),
('dashboard_gruppenfuehrer', 'kalender:view_events'),
('dashboard_gruppenfuehrer', 'kalender:view_bookings'),
('dashboard_gruppenfuehrer', 'kalender:create_events'),
('dashboard_gruppenfuehrer', 'kalender:create_training'),
('dashboard_gruppenfuehrer', 'kalender:mark_attendance'),
('dashboard_gruppenfuehrer', 'kalender:create_bookings'),
('dashboard_gruppenfuehrer', 'kalender:edit_bookings'),
('dashboard_gruppenfuehrer', 'kalender:manage_categories'),
('dashboard_gruppenfuehrer', 'kalender:widget_events'),
('dashboard_gruppenfuehrer', 'kalender:widget_bookings'),
('dashboard_gruppenfuehrer', 'kalender:widget_quick_add'),
-- Fahrzeuge
('dashboard_gruppenfuehrer', 'fahrzeuge:access'),
('dashboard_gruppenfuehrer', 'fahrzeuge:view'),
('dashboard_gruppenfuehrer', 'fahrzeuge:change_status'),
('dashboard_gruppenfuehrer', 'fahrzeuge:manage_maintenance'),
('dashboard_gruppenfuehrer', 'fahrzeuge:widget'),
-- Einsätze
('dashboard_gruppenfuehrer', 'einsaetze:access'),
('dashboard_gruppenfuehrer', 'einsaetze:view'),
('dashboard_gruppenfuehrer', 'einsaetze:create'),
('dashboard_gruppenfuehrer', 'einsaetze:manage_personnel'),
-- Ausrüstung
('dashboard_gruppenfuehrer', 'ausruestung:access'),
('dashboard_gruppenfuehrer', 'ausruestung:view'),
('dashboard_gruppenfuehrer', 'ausruestung:create'),
('dashboard_gruppenfuehrer', 'ausruestung:manage_maintenance'),
('dashboard_gruppenfuehrer', 'ausruestung:widget'),
-- Mitglieder
('dashboard_gruppenfuehrer', 'mitglieder:access'),
('dashboard_gruppenfuehrer', 'mitglieder:view'),
-- Atemschutz
('dashboard_gruppenfuehrer', 'atemschutz:access'),
('dashboard_gruppenfuehrer', 'atemschutz:view'),
('dashboard_gruppenfuehrer', 'atemschutz:create'),
('dashboard_gruppenfuehrer', 'atemschutz:widget'),
-- Wissen
('dashboard_gruppenfuehrer', 'wissen:access'),
('dashboard_gruppenfuehrer', 'wissen:widget_recent'),
('dashboard_gruppenfuehrer', 'wissen:widget_search'),
-- Vikunja
('dashboard_gruppenfuehrer', 'vikunja:access'),
('dashboard_gruppenfuehrer', 'vikunja:create_tasks'),
('dashboard_gruppenfuehrer', 'vikunja:widget_tasks'),
('dashboard_gruppenfuehrer', 'vikunja:widget_quick_add'),
-- Nextcloud
('dashboard_gruppenfuehrer', 'nextcloud:access'),
('dashboard_gruppenfuehrer', 'nextcloud:widget'),
-- Dashboard
('dashboard_gruppenfuehrer', 'dashboard:widget_links'),
('dashboard_gruppenfuehrer', 'dashboard:widget_banner')
ON CONFLICT DO NOTHING;
-- dashboard_fahrmeister — vehicle specialist
INSERT INTO group_permissions (authentik_group, permission_id) VALUES
-- Kalender
('dashboard_fahrmeister', 'kalender:access'),
('dashboard_fahrmeister', 'kalender:view_events'),
('dashboard_fahrmeister', 'kalender:view_bookings'),
('dashboard_fahrmeister', 'kalender:create_bookings'),
('dashboard_fahrmeister', 'kalender:edit_bookings'),
('dashboard_fahrmeister', 'kalender:widget_events'),
('dashboard_fahrmeister', 'kalender:widget_bookings'),
-- Fahrzeuge
('dashboard_fahrmeister', 'fahrzeuge:access'),
('dashboard_fahrmeister', 'fahrzeuge:view'),
('dashboard_fahrmeister', 'fahrzeuge:change_status'),
('dashboard_fahrmeister', 'fahrzeuge:manage_maintenance'),
('dashboard_fahrmeister', 'fahrzeuge:widget'),
-- Einsätze
('dashboard_fahrmeister', 'einsaetze:access'),
('dashboard_fahrmeister', 'einsaetze:view'),
-- Ausrüstung
('dashboard_fahrmeister', 'ausruestung:access'),
('dashboard_fahrmeister', 'ausruestung:view'),
('dashboard_fahrmeister', 'ausruestung:create'),
('dashboard_fahrmeister', 'ausruestung:manage_maintenance'),
('dashboard_fahrmeister', 'ausruestung:widget'),
-- Mitglieder
('dashboard_fahrmeister', 'mitglieder:access'),
('dashboard_fahrmeister', 'mitglieder:view'),
-- Atemschutz
('dashboard_fahrmeister', 'atemschutz:access'),
('dashboard_fahrmeister', 'atemschutz:widget'),
-- Wissen
('dashboard_fahrmeister', 'wissen:access'),
('dashboard_fahrmeister', 'wissen:widget_recent'),
('dashboard_fahrmeister', 'wissen:widget_search'),
-- Vikunja
('dashboard_fahrmeister', 'vikunja:access'),
('dashboard_fahrmeister', 'vikunja:create_tasks'),
('dashboard_fahrmeister', 'vikunja:widget_tasks'),
('dashboard_fahrmeister', 'vikunja:widget_quick_add'),
-- Nextcloud
('dashboard_fahrmeister', 'nextcloud:access'),
('dashboard_fahrmeister', 'nextcloud:widget'),
-- Dashboard
('dashboard_fahrmeister', 'dashboard:widget_links'),
('dashboard_fahrmeister', 'dashboard:widget_banner')
ON CONFLICT DO NOTHING;
-- dashboard_zeugmeister — equipment specialist
INSERT INTO group_permissions (authentik_group, permission_id) VALUES
-- Kalender
('dashboard_zeugmeister', 'kalender:access'),
('dashboard_zeugmeister', 'kalender:view_events'),
('dashboard_zeugmeister', 'kalender:view_bookings'),
('dashboard_zeugmeister', 'kalender:create_bookings'),
('dashboard_zeugmeister', 'kalender:widget_events'),
('dashboard_zeugmeister', 'kalender:widget_bookings'),
-- Fahrzeuge
('dashboard_zeugmeister', 'fahrzeuge:access'),
('dashboard_zeugmeister', 'fahrzeuge:view'),
('dashboard_zeugmeister', 'fahrzeuge:change_status'),
('dashboard_zeugmeister', 'fahrzeuge:manage_maintenance'),
('dashboard_zeugmeister', 'fahrzeuge:widget'),
-- Einsätze
('dashboard_zeugmeister', 'einsaetze:access'),
('dashboard_zeugmeister', 'einsaetze:view'),
-- Ausrüstung
('dashboard_zeugmeister', 'ausruestung:access'),
('dashboard_zeugmeister', 'ausruestung:view'),
('dashboard_zeugmeister', 'ausruestung:create'),
('dashboard_zeugmeister', 'ausruestung:manage_maintenance'),
('dashboard_zeugmeister', 'ausruestung:widget'),
-- Mitglieder
('dashboard_zeugmeister', 'mitglieder:access'),
('dashboard_zeugmeister', 'mitglieder:view'),
-- Atemschutz
('dashboard_zeugmeister', 'atemschutz:access'),
('dashboard_zeugmeister', 'atemschutz:widget'),
-- Wissen
('dashboard_zeugmeister', 'wissen:access'),
('dashboard_zeugmeister', 'wissen:widget_recent'),
('dashboard_zeugmeister', 'wissen:widget_search'),
-- Vikunja
('dashboard_zeugmeister', 'vikunja:access'),
('dashboard_zeugmeister', 'vikunja:create_tasks'),
('dashboard_zeugmeister', 'vikunja:widget_tasks'),
('dashboard_zeugmeister', 'vikunja:widget_quick_add'),
-- Nextcloud
('dashboard_zeugmeister', 'nextcloud:access'),
('dashboard_zeugmeister', 'nextcloud:widget'),
-- Dashboard
('dashboard_zeugmeister', 'dashboard:widget_links'),
('dashboard_zeugmeister', 'dashboard:widget_banner')
ON CONFLICT DO NOTHING;
-- dashboard_chargen — similar to gruppenfuehrer
INSERT INTO group_permissions (authentik_group, permission_id) VALUES
-- Kalender
('dashboard_chargen', 'kalender:access'),
('dashboard_chargen', 'kalender:view_events'),
('dashboard_chargen', 'kalender:view_bookings'),
('dashboard_chargen', 'kalender:create_bookings'),
('dashboard_chargen', 'kalender:widget_events'),
('dashboard_chargen', 'kalender:widget_bookings'),
-- Fahrzeuge
('dashboard_chargen', 'fahrzeuge:access'),
('dashboard_chargen', 'fahrzeuge:view'),
('dashboard_chargen', 'fahrzeuge:change_status'),
('dashboard_chargen', 'fahrzeuge:manage_maintenance'),
('dashboard_chargen', 'fahrzeuge:widget'),
-- Einsätze
('dashboard_chargen', 'einsaetze:access'),
('dashboard_chargen', 'einsaetze:view'),
('dashboard_chargen', 'einsaetze:create'),
('dashboard_chargen', 'einsaetze:manage_personnel'),
-- Ausrüstung
('dashboard_chargen', 'ausruestung:access'),
('dashboard_chargen', 'ausruestung:view'),
('dashboard_chargen', 'ausruestung:create'),
('dashboard_chargen', 'ausruestung:manage_maintenance'),
('dashboard_chargen', 'ausruestung:widget'),
-- Mitglieder
('dashboard_chargen', 'mitglieder:access'),
('dashboard_chargen', 'mitglieder:view'),
-- Atemschutz
('dashboard_chargen', 'atemschutz:access'),
('dashboard_chargen', 'atemschutz:view'),
('dashboard_chargen', 'atemschutz:create'),
('dashboard_chargen', 'atemschutz:widget'),
-- Wissen
('dashboard_chargen', 'wissen:access'),
('dashboard_chargen', 'wissen:widget_recent'),
('dashboard_chargen', 'wissen:widget_search'),
-- Vikunja
('dashboard_chargen', 'vikunja:access'),
('dashboard_chargen', 'vikunja:create_tasks'),
('dashboard_chargen', 'vikunja:widget_tasks'),
('dashboard_chargen', 'vikunja:widget_quick_add'),
-- Nextcloud
('dashboard_chargen', 'nextcloud:access'),
('dashboard_chargen', 'nextcloud:widget'),
-- Dashboard
('dashboard_chargen', 'dashboard:widget_links'),
('dashboard_chargen', 'dashboard:widget_banner')
ON CONFLICT DO NOTHING;
-- dashboard_moderator — event/calendar management + atemschutz view
INSERT INTO group_permissions (authentik_group, permission_id) VALUES
-- Kalender
('dashboard_moderator', 'kalender:access'),
('dashboard_moderator', 'kalender:view_events'),
('dashboard_moderator', 'kalender:view_bookings'),
('dashboard_moderator', 'kalender:create_events'),
('dashboard_moderator', 'kalender:create_bookings'),
('dashboard_moderator', 'kalender:edit_bookings'),
('dashboard_moderator', 'kalender:manage_categories'),
('dashboard_moderator', 'kalender:widget_events'),
('dashboard_moderator', 'kalender:widget_bookings'),
('dashboard_moderator', 'kalender:widget_quick_add'),
-- Fahrzeuge
('dashboard_moderator', 'fahrzeuge:access'),
('dashboard_moderator', 'fahrzeuge:view'),
('dashboard_moderator', 'fahrzeuge:widget'),
-- Einsätze
('dashboard_moderator', 'einsaetze:access'),
('dashboard_moderator', 'einsaetze:view'),
-- Ausrüstung
('dashboard_moderator', 'ausruestung:access'),
('dashboard_moderator', 'ausruestung:view'),
('dashboard_moderator', 'ausruestung:widget'),
-- Mitglieder
('dashboard_moderator', 'mitglieder:access'),
('dashboard_moderator', 'mitglieder:view'),
-- Atemschutz
('dashboard_moderator', 'atemschutz:access'),
('dashboard_moderator', 'atemschutz:view'),
('dashboard_moderator', 'atemschutz:widget'),
-- Wissen
('dashboard_moderator', 'wissen:access'),
('dashboard_moderator', 'wissen:widget_recent'),
('dashboard_moderator', 'wissen:widget_search'),
-- Vikunja
('dashboard_moderator', 'vikunja:access'),
('dashboard_moderator', 'vikunja:create_tasks'),
('dashboard_moderator', 'vikunja:widget_tasks'),
('dashboard_moderator', 'vikunja:widget_quick_add'),
-- Nextcloud
('dashboard_moderator', 'nextcloud:access'),
('dashboard_moderator', 'nextcloud:widget'),
-- Dashboard
('dashboard_moderator', 'dashboard:widget_links'),
('dashboard_moderator', 'dashboard:widget_banner')
ON CONFLICT DO NOTHING;
-- dashboard_atemschutz — atemschutz specialist
INSERT INTO group_permissions (authentik_group, permission_id) VALUES
-- Kalender (basic access)
('dashboard_atemschutz', 'kalender:access'),
('dashboard_atemschutz', 'kalender:view_events'),
('dashboard_atemschutz', 'kalender:view_bookings'),
('dashboard_atemschutz', 'kalender:create_bookings'),
('dashboard_atemschutz', 'kalender:widget_events'),
('dashboard_atemschutz', 'kalender:widget_bookings'),
-- Fahrzeuge (read)
('dashboard_atemschutz', 'fahrzeuge:access'),
('dashboard_atemschutz', 'fahrzeuge:view'),
('dashboard_atemschutz', 'fahrzeuge:widget'),
-- Einsätze (read)
('dashboard_atemschutz', 'einsaetze:access'),
('dashboard_atemschutz', 'einsaetze:view'),
-- Ausrüstung (read)
('dashboard_atemschutz', 'ausruestung:access'),
('dashboard_atemschutz', 'ausruestung:view'),
('dashboard_atemschutz', 'ausruestung:widget'),
-- Mitglieder (read)
('dashboard_atemschutz', 'mitglieder:access'),
('dashboard_atemschutz', 'mitglieder:view'),
-- Atemschutz (full)
('dashboard_atemschutz', 'atemschutz:access'),
('dashboard_atemschutz', 'atemschutz:view'),
('dashboard_atemschutz', 'atemschutz:create'),
('dashboard_atemschutz', 'atemschutz:widget'),
-- Wissen
('dashboard_atemschutz', 'wissen:access'),
('dashboard_atemschutz', 'wissen:widget_recent'),
('dashboard_atemschutz', 'wissen:widget_search'),
-- Vikunja
('dashboard_atemschutz', 'vikunja:access'),
('dashboard_atemschutz', 'vikunja:create_tasks'),
('dashboard_atemschutz', 'vikunja:widget_tasks'),
('dashboard_atemschutz', 'vikunja:widget_quick_add'),
-- Nextcloud
('dashboard_atemschutz', 'nextcloud:access'),
('dashboard_atemschutz', 'nextcloud:widget'),
-- Dashboard
('dashboard_atemschutz', 'dashboard:widget_links'),
('dashboard_atemschutz', 'dashboard:widget_banner')
ON CONFLICT DO NOTHING;
-- dashboard_mitglied — basic member access (read + basic booking creation)
INSERT INTO group_permissions (authentik_group, permission_id) VALUES
-- Kalender
('dashboard_mitglied', 'kalender:access'),
('dashboard_mitglied', 'kalender:view_events'),
('dashboard_mitglied', 'kalender:view_bookings'),
('dashboard_mitglied', 'kalender:create_bookings'),
('dashboard_mitglied', 'kalender:widget_events'),
('dashboard_mitglied', 'kalender:widget_bookings'),
-- Fahrzeuge
('dashboard_mitglied', 'fahrzeuge:access'),
('dashboard_mitglied', 'fahrzeuge:view'),
('dashboard_mitglied', 'fahrzeuge:widget'),
-- Einsätze
('dashboard_mitglied', 'einsaetze:access'),
('dashboard_mitglied', 'einsaetze:view'),
-- Ausrüstung
('dashboard_mitglied', 'ausruestung:access'),
('dashboard_mitglied', 'ausruestung:view'),
('dashboard_mitglied', 'ausruestung:widget'),
-- Mitglieder
('dashboard_mitglied', 'mitglieder:access'),
('dashboard_mitglied', 'mitglieder:view'),
-- Atemschutz
('dashboard_mitglied', 'atemschutz:access'),
('dashboard_mitglied', 'atemschutz:widget'),
-- Wissen
('dashboard_mitglied', 'wissen:access'),
('dashboard_mitglied', 'wissen:widget_recent'),
('dashboard_mitglied', 'wissen:widget_search'),
-- Vikunja
('dashboard_mitglied', 'vikunja:access'),
('dashboard_mitglied', 'vikunja:create_tasks'),
('dashboard_mitglied', 'vikunja:widget_tasks'),
('dashboard_mitglied', 'vikunja:widget_quick_add'),
-- Nextcloud
('dashboard_mitglied', 'nextcloud:access'),
('dashboard_mitglied', 'nextcloud:widget'),
-- Dashboard
('dashboard_mitglied', 'dashboard:widget_links'),
('dashboard_mitglied', 'dashboard:widget_banner')
ON CONFLICT DO NOTHING;

View File

@@ -1,13 +1,11 @@
import { Request, Response, NextFunction } from 'express';
import pool from '../config/database';
import logger from '../utils/logger';
import { auditPermissionDenied } from './audit.middleware';
import { AuditResourceType } from '../services/audit.service';
import { permissionService } from '../services/permission.service';
// ---------------------------------------------------------------------------
// AppRole — mirrors the roles defined in the project spec.
// Tier 1 (RBAC) is assumed complete and adds a `role` column to users.
// This middleware reads that column to enforce permissions.
// AppRole — kept for backward compatibility (resolveRequestRole, bericht_text)
// ---------------------------------------------------------------------------
export type AppRole =
| 'admin'
@@ -16,105 +14,14 @@ export type AppRole =
| 'mitglied'
| 'bewerber';
/**
* Role hierarchy: higher index = more permissions.
* Used to implement "at least X role" checks.
*/
const ROLE_HIERARCHY: AppRole[] = [
'bewerber',
'mitglied',
'gruppenfuehrer',
'kommandant',
'admin',
];
/**
* Permission map: defines which roles hold a given permission string.
* All roles at or above the listed minimum also hold the permission.
*/
const PERMISSION_ROLE_MIN: Record<string, AppRole> = {
'incidents:read': 'mitglied',
'incidents:write': 'gruppenfuehrer',
'incidents:delete': 'kommandant',
'incidents:read_bericht_text': 'kommandant',
'incidents:manage_personnel': 'gruppenfuehrer',
// Training / Calendar
'training:read': 'mitglied',
'training:write': 'gruppenfuehrer',
'training:cancel': 'kommandant',
'training:mark_attendance': 'gruppenfuehrer',
'reports:read': 'kommandant',
// Audit log and admin panel — restricted to admin role only
'admin:access': 'admin',
'audit:read': 'admin',
'audit:export': 'admin',
'members:read': 'mitglied',
'members:write': 'kommandant',
'vehicles:write': 'kommandant',
'vehicles:status': 'gruppenfuehrer',
'vehicles:delete': 'admin',
'equipment:write': 'gruppenfuehrer',
'equipment:delete': 'admin',
'events:write': 'gruppenfuehrer',
'events:categories': 'gruppenfuehrer',
'atemschutz:write': 'gruppenfuehrer',
'atemschutz:delete': 'kommandant',
'bookings:write': 'gruppenfuehrer',
'bookings:delete': 'admin',
};
/**
* Derive an AppRole from Authentik JWT groups (highest matching role wins).
*/
function roleFromGroups(groups: string[]): AppRole {
if (groups.includes('dashboard_admin')) return 'admin';
if (groups.includes('dashboard_kommando')) return 'kommandant';
if (groups.includes('dashboard_gruppenfuehrer') || groups.includes('dashboard_fahrmeister') || groups.includes('dashboard_zeugmeister') || groups.includes('dashboard_chargen')) return 'gruppenfuehrer';
return 'mitglied';
}
function hasPermission(role: AppRole, permission: string): boolean {
const minRole = PERMISSION_ROLE_MIN[permission];
if (!minRole) {
logger.warn('Unknown permission checked', { permission });
return false;
}
const userLevel = ROLE_HIERARCHY.indexOf(role);
const minLevel = ROLE_HIERARCHY.indexOf(minRole);
return userLevel >= minLevel;
}
/**
* Retrieves the role for a given user ID from the database.
* Falls back to 'mitglied' if the users table does not yet have a role column
* (graceful degradation while Tier 1 migration is pending).
*/
async function getUserRole(userId: string): Promise<AppRole> {
try {
const result = await pool.query(
`SELECT role FROM users WHERE id = $1`,
[userId]
);
if (result.rows.length === 0) return 'mitglied';
return (result.rows[0].role as AppRole) ?? 'mitglied';
} catch (error) {
// If the column doesn't exist yet (Tier 1 not deployed), degrade gracefully
const errMsg = error instanceof Error ? error.message : String(error);
if (errMsg.includes('column "role" does not exist')) {
logger.warn('users.role column not found — Tier 1 RBAC migration pending. Defaulting to mitglied.');
return 'mitglied';
}
logger.error('Error fetching user role', { error, userId });
return 'mitglied';
}
}
/**
* Middleware factory: requires the authenticated user to hold the given
* permission (or a role with sufficient hierarchy level).
* permission. Permission is checked against the DB-driven permission system.
*
* Usage:
* router.post('/api/incidents', authenticate, requirePermission('incidents:write'), handler)
* Hardwired rules:
* - `dashboard_admin` group always passes (full access).
* - `admin:access` is checked via group membership (not DB).
* - Maintenance mode blocks non-admin access per feature group.
*/
export function requirePermission(permission: string) {
return async (req: Request, res: Response, next: NextFunction): Promise<void> => {
@@ -126,27 +33,65 @@ export function requirePermission(permission: string) {
return;
}
const dbRole = (req.user as any).role
? (req.user as any).role as AppRole
: await getUserRole(req.user.id);
const groupRole = roleFromGroups(req.user?.groups ?? []);
const role = ROLE_HIERARCHY.indexOf(groupRole) > ROLE_HIERARCHY.indexOf(dbRole) ? groupRole : dbRole;
const groups: string[] = req.user?.groups ?? [];
// Attach role to request for downstream use (e.g., bericht_text redaction)
(req as Request & { userRole?: AppRole }).userRole = role;
// Attach resolved role for downstream use (bericht_text redaction, etc.)
(req as any).userRole = resolveRequestRole(req);
if (!hasPermission(role, permission)) {
logger.warn('Permission denied', {
// Hardwired: dashboard_admin always has full access
if (groups.includes('dashboard_admin')) {
next();
return;
}
// Hardwired: admin:access only for dashboard_admin (already returned above)
if (permission === 'admin:access') {
logger.warn('Permission denied — admin:access', {
userId: req.user.id,
role,
permission,
path: req.path,
});
// GDPR audit trail — fire-and-forget, never throws
auditPermissionDenied(req, AuditResourceType.SYSTEM, undefined, {
required_permission: permission,
user_role: role,
});
res.status(403).json({
success: false,
message: 'Keine Berechtigung',
});
return;
}
// Check maintenance mode for the feature group
const featureGroup = permission.split(':')[0];
if (permissionService.isFeatureInMaintenance(featureGroup)) {
logger.info('Feature in maintenance mode', {
userId: req.user.id,
featureGroup,
permission,
path: req.path,
});
res.status(403).json({
success: false,
message: 'Diese Funktion befindet sich im Wartungsmodus',
});
return;
}
// Check DB-driven permission
if (!permissionService.hasPermission(groups, permission)) {
logger.warn('Permission denied', {
userId: req.user.id,
groups,
permission,
path: req.path,
});
auditPermissionDenied(req, AuditResourceType.SYSTEM, undefined, {
required_permission: permission,
user_groups: groups,
});
res.status(403).json({
@@ -161,25 +106,42 @@ export function requirePermission(permission: string) {
}
/**
* Resolve the effective AppRole for a request, combining DB role and group role.
* Self-contained — does not depend on requirePermission() middleware having run.
* Resolve the effective AppRole for a request.
* Simplified: returns 'admin' for dashboard_admin, 'kommandant' for dashboard_kommando,
* 'gruppenfuehrer' for specialist groups, else 'mitglied'.
* Used for backward-compatible features like bericht_text redaction.
*/
export function resolveRequestRole(req: Request): AppRole {
const dbRole = (req.user as any)?.role
? ((req.user as any).role as AppRole)
: 'mitglied';
const groupRole = roleFromGroups(req.user?.groups ?? []);
return ROLE_HIERARCHY.indexOf(groupRole) > ROLE_HIERARCHY.indexOf(dbRole) ? groupRole : dbRole;
const groups: string[] = req.user?.groups ?? [];
if (groups.includes('dashboard_admin')) return 'admin';
if (groups.includes('dashboard_kommando')) return 'kommandant';
if (
groups.includes('dashboard_gruppenfuehrer') ||
groups.includes('dashboard_fahrmeister') ||
groups.includes('dashboard_zeugmeister') ||
groups.includes('dashboard_chargen')
) return 'gruppenfuehrer';
return 'mitglied';
}
export { getUserRole, hasPermission, roleFromGroups };
// Legacy exports for backward compatibility
export function getUserRole(_userId: string): Promise<AppRole> {
return Promise.resolve('mitglied');
}
export function roleFromGroups(groups: string[]): AppRole {
if (groups.includes('dashboard_admin')) return 'admin';
if (groups.includes('dashboard_kommando')) return 'kommandant';
if (groups.includes('dashboard_gruppenfuehrer') || groups.includes('dashboard_fahrmeister') || groups.includes('dashboard_zeugmeister') || groups.includes('dashboard_chargen')) return 'gruppenfuehrer';
return 'mitglied';
}
export function hasPermission(role: AppRole, _permission: string): boolean {
return role === 'admin';
}
/**
* Middleware factory: requires the authenticated user to belong to at least
* one of the given Authentik groups (sourced from the JWT `groups` claim).
*
* Usage:
* router.post('/api/vehicles', authenticate, requireGroups(['dashboard_admin']), handler)
* @deprecated Use requirePermission() instead.
*/
export function requireGroups(requiredGroups: string[]) {
return async (req: Request, res: Response, next: NextFunction): Promise<void> => {
@@ -195,15 +157,15 @@ export function requireGroups(requiredGroups: string[]) {
if (!hasAccess) {
logger.warn('Group-based access denied', {
userId: req.user.id,
userId: req.user.id,
userGroups,
requiredGroups,
path: req.path,
path: req.path,
});
auditPermissionDenied(req, AuditResourceType.SYSTEM, undefined, {
required_groups: requiredGroups,
user_groups: userGroups,
user_groups: userGroups,
});
res.status(403).json({

View File

@@ -16,8 +16,8 @@ router.get('/:id', authenticate, atemschutzController.getOne.bind(atem
// ── Write — gruppenfuehrer+ ─────────────────────────────────────────────────
router.post('/', authenticate, requirePermission('atemschutz:write'), atemschutzController.create.bind(atemschutzController));
router.patch('/:id', authenticate, requirePermission('atemschutz:write'), atemschutzController.update.bind(atemschutzController));
router.post('/', authenticate, requirePermission('atemschutz:create'), atemschutzController.create.bind(atemschutzController));
router.patch('/:id', authenticate, requirePermission('atemschutz:create'), atemschutzController.update.bind(atemschutzController));
// ── Delete — kommandant+ ────────────────────────────────────────────────────

View File

@@ -19,14 +19,14 @@ router.get('/calendar-token', authenticate, bookingController.getCalendarToken.b
// ── Write operations ──────────────────────────────────────────────────────────
router.post('/', authenticate, bookingController.create.bind(bookingController));
router.patch('/:id', authenticate, requirePermission('bookings:write'), bookingController.update.bind(bookingController));
router.patch('/:id', authenticate, requirePermission('kalender:edit_bookings'), bookingController.update.bind(bookingController));
// Soft-cancel (sets abgesagt=TRUE) — creator or bookings:write
router.delete('/:id', authenticate, bookingController.cancel.bind(bookingController));
router.patch('/:id/cancel', authenticate, bookingController.cancel.bind(bookingController));
// Hard-delete (admin only)
router.delete('/:id/force', authenticate, requirePermission('bookings:delete'), bookingController.hardDelete.bind(bookingController));
router.delete('/:id/force', authenticate, requirePermission('kalender:delete_bookings'), bookingController.hardDelete.bind(bookingController));
// ── Single booking read — after specific routes to avoid path conflicts ───────

View File

@@ -17,13 +17,13 @@ router.get('/:id', authenticate, equipmentController.getEquipmen
// ── Write — gruppenfuehrer+ ────────────────────────────────────────────────
router.post('/', authenticate, requirePermission('equipment:write'), equipmentController.createEquipment.bind(equipmentController));
router.patch('/:id', authenticate, requirePermission('equipment:write'), equipmentController.updateEquipment.bind(equipmentController));
router.patch('/:id/status', authenticate, requirePermission('equipment:write'), equipmentController.updateStatus.bind(equipmentController));
router.post('/:id/wartung', authenticate, requirePermission('equipment:write'), equipmentController.addWartung.bind(equipmentController));
router.post('/', authenticate, requirePermission('ausruestung:create'), equipmentController.createEquipment.bind(equipmentController));
router.patch('/:id', authenticate, requirePermission('ausruestung:create'), equipmentController.updateEquipment.bind(equipmentController));
router.patch('/:id/status', authenticate, requirePermission('ausruestung:create'), equipmentController.updateStatus.bind(equipmentController));
router.post('/:id/wartung', authenticate, requirePermission('ausruestung:create'), equipmentController.addWartung.bind(equipmentController));
// ── Delete — admin only ──────────────────────────────────────────────────────
router.delete('/:id', authenticate, requirePermission('equipment:delete'), equipmentController.deleteEquipment.bind(equipmentController));
router.delete('/:id', authenticate, requirePermission('ausruestung:delete'), equipmentController.deleteEquipment.bind(equipmentController));
export default router;

View File

@@ -22,7 +22,7 @@ router.get('/kategorien', authenticate, eventsController.listKategorien.bind(eve
router.post(
'/kategorien',
authenticate,
requirePermission('events:categories'),
requirePermission('kalender:manage_categories'),
eventsController.createKategorie.bind(eventsController)
);
@@ -33,7 +33,7 @@ router.post(
router.patch(
'/kategorien/:id',
authenticate,
requirePermission('events:categories'),
requirePermission('kalender:manage_categories'),
eventsController.updateKategorie.bind(eventsController)
);
@@ -44,7 +44,7 @@ router.patch(
router.delete(
'/kategorien/:id',
authenticate,
requirePermission('events:categories'),
requirePermission('kalender:manage_categories'),
eventsController.deleteKategorie.bind(eventsController)
);
@@ -118,7 +118,7 @@ router.get(
router.post(
'/import',
authenticate,
requirePermission('events:write'),
requirePermission('kalender:create_events'),
eventsController.importEvents.bind(eventsController)
);
@@ -129,7 +129,7 @@ router.post(
router.post(
'/',
authenticate,
requirePermission('events:write'),
requirePermission('kalender:create_events'),
eventsController.createEvent.bind(eventsController)
);
@@ -146,7 +146,7 @@ router.get('/:id', authenticate, eventsController.getById.bind(eventsController)
router.patch(
'/:id',
authenticate,
requirePermission('events:write'),
requirePermission('kalender:create_events'),
eventsController.updateEvent.bind(eventsController)
);
@@ -157,7 +157,7 @@ router.patch(
router.delete(
'/:id',
authenticate,
requirePermission('events:write'),
requirePermission('kalender:create_events'),
eventsController.cancelEvent.bind(eventsController)
);
@@ -168,7 +168,7 @@ router.delete(
router.post(
'/:id/delete',
authenticate,
requirePermission('events:write'),
requirePermission('kalender:create_events'),
eventsController.deleteEvent.bind(eventsController)
);

View File

@@ -16,7 +16,7 @@ router.use(authenticate);
*/
router.get(
'/',
requirePermission('incidents:read'),
requirePermission('einsaetze:view'),
incidentController.listIncidents.bind(incidentController)
);
@@ -31,7 +31,7 @@ router.get(
*/
router.get(
'/stats',
requirePermission('incidents:read'),
requirePermission('einsaetze:view'),
incidentController.getStats.bind(incidentController)
);
@@ -42,7 +42,7 @@ router.get(
*/
router.post(
'/refresh-stats',
requirePermission('incidents:delete'), // kommandant+ (repurposing delete permission for admin ops)
requirePermission('einsaetze:delete'),
incidentController.refreshStats.bind(incidentController)
);
@@ -53,7 +53,7 @@ router.post(
*/
router.get(
'/:id',
requirePermission('incidents:read'),
requirePermission('einsaetze:view'),
incidentController.getIncident.bind(incidentController)
);
@@ -64,7 +64,7 @@ router.get(
*/
router.post(
'/',
requirePermission('incidents:write'),
requirePermission('einsaetze:create'),
incidentController.createIncident.bind(incidentController)
);
@@ -75,7 +75,7 @@ router.post(
*/
router.patch(
'/:id',
requirePermission('incidents:write'),
requirePermission('einsaetze:create'),
incidentController.updateIncident.bind(incidentController)
);
@@ -86,7 +86,7 @@ router.patch(
*/
router.delete(
'/:id',
requirePermission('incidents:delete'),
requirePermission('einsaetze:delete'),
incidentController.deleteIncident.bind(incidentController)
);
@@ -98,7 +98,7 @@ router.delete(
*/
router.post(
'/:id/personnel',
requirePermission('incidents:manage_personnel'),
requirePermission('einsaetze:manage_personnel'),
incidentController.assignPersonnel.bind(incidentController)
);
@@ -109,7 +109,7 @@ router.post(
*/
router.delete(
'/:id/personnel/:userId',
requirePermission('incidents:manage_personnel'),
requirePermission('einsaetze:manage_personnel'),
incidentController.removePersonnel.bind(incidentController)
);
@@ -121,7 +121,7 @@ router.delete(
*/
router.post(
'/:id/vehicles',
requirePermission('incidents:manage_personnel'),
requirePermission('einsaetze:manage_personnel'),
incidentController.assignVehicle.bind(incidentController)
);
@@ -132,7 +132,7 @@ router.post(
*/
router.delete(
'/:id/vehicles/:fahrzeugId',
requirePermission('incidents:manage_personnel'),
requirePermission('einsaetze:manage_personnel'),
incidentController.removeVehicle.bind(incidentController)
);

View File

@@ -17,49 +17,49 @@ router.use(authenticate);
// "stats" as a userId parameter.
router.get(
'/stats',
requirePermission('members:read'),
requirePermission('mitglieder:view'),
memberController.getMemberStats.bind(memberController)
);
router.get(
'/',
requirePermission('members:read'),
requirePermission('mitglieder:view'),
memberController.getMembers.bind(memberController)
);
router.get(
'/:userId',
requirePermission('members:read'),
requirePermission('mitglieder:view'),
memberController.getMemberById.bind(memberController)
);
router.post(
'/:userId/profile',
requirePermission('members:write'),
requirePermission('mitglieder:edit'),
memberController.createMemberProfile.bind(memberController)
);
router.get(
'/:userId/befoerderungen',
requirePermission('members:read'),
requirePermission('mitglieder:view'),
memberController.getBefoerderungen.bind(memberController)
);
router.get(
'/:userId/untersuchungen',
requirePermission('members:read'),
requirePermission('mitglieder:view'),
memberController.getUntersuchungen.bind(memberController)
);
router.get(
'/:userId/fahrgenehmigungen',
requirePermission('members:read'),
requirePermission('mitglieder:view'),
memberController.getFahrgenehmigungen.bind(memberController)
);
router.get(
'/:userId/ausbildungen',
requirePermission('members:read'),
requirePermission('mitglieder:view'),
memberController.getAusbildungen.bind(memberController)
);
@@ -76,7 +76,7 @@ const requireOwnerOrWrite = (req: Request, res: Response, next: NextFunction): v
return;
}
// Not the owner — must have members:write permission
requirePermission('members:write')(req, res, next);
requirePermission('mitglieder:edit')(req, res, next);
};
/**

View File

@@ -0,0 +1,17 @@
import { Router } from 'express';
import permissionController from '../controllers/permission.controller';
import { authenticate } from '../middleware/auth.middleware';
import { requirePermission } from '../middleware/rbac.middleware';
const router = Router();
// ── User-facing (any authenticated user) ──────────────────────────────────
router.get('/me', authenticate, permissionController.getMyPermissions.bind(permissionController));
// ── Admin-only routes ─────────────────────────────────────────────────────
router.get('/admin/matrix', authenticate, requirePermission('admin:access'), permissionController.getMatrix.bind(permissionController));
router.get('/admin/groups', authenticate, requirePermission('admin:access'), permissionController.getGroups.bind(permissionController));
router.put('/admin/group/:groupName', authenticate, requirePermission('admin:access'), permissionController.setGroupPermissions.bind(permissionController));
router.put('/admin/maintenance/:featureGroupId', authenticate, requirePermission('admin:access'), permissionController.setMaintenanceFlag.bind(permissionController));
export default router;

View File

@@ -1,14 +1,15 @@
import { Router, Request, Response, NextFunction } from 'express';
import trainingController from '../controllers/training.controller';
import { authenticate, optionalAuth } from '../middleware/auth.middleware';
import { requirePermission, getUserRole } from '../middleware/rbac.middleware';
import { requirePermission } from '../middleware/rbac.middleware';
import { permissionService } from '../services/permission.service';
const router = Router();
// ---------------------------------------------------------------------------
// injectTeilnahmenFlag
//
// Sets req.canSeeTeilnahmen = true for Gruppenführer and above.
// Sets req.canSeeTeilnahmen = true for users with kalender:mark_attendance.
// Regular Mitglieder see only attendance counts; officers see the full list.
// ---------------------------------------------------------------------------
@@ -19,12 +20,10 @@ async function injectTeilnahmenFlag(
): Promise<void> {
try {
if (req.user) {
const role = await getUserRole(req.user.id);
const ROLE_ORDER: Record<string, number> = {
bewerber: -1, mitglied: 0, gruppenfuehrer: 1, kommandant: 2, admin: 3,
};
const groups: string[] = req.user?.groups ?? [];
(req as any).canSeeTeilnahmen =
(ROLE_ORDER[role] ?? 0) >= ROLE_ORDER.gruppenfuehrer;
groups.includes('dashboard_admin') ||
permissionService.hasPermission(groups, 'kalender:mark_attendance');
}
} catch (_err) {
// Non-fatal — default to restricted view
@@ -68,12 +67,12 @@ router.get('/calendar-token', authenticate, trainingController.getCalendarToken)
/**
* GET /api/training/stats?year=<YYYY>
* Annual participation stats.
* Requires Kommandant or above (requirePermission('reports:read')).
* Requires Kommandant or above (requirePermission('kalender:view_reports')).
*/
router.get(
'/stats',
authenticate,
requirePermission('reports:read'),
requirePermission('kalender:view_reports'),
trainingController.getStats
);
@@ -92,12 +91,12 @@ router.get(
/**
* POST /api/training
* Create a new training event.
* Requires Gruppenführer or above (requirePermission('training:write')).
* Requires Gruppenführer or above (requirePermission('kalender:create_training')).
*/
router.post(
'/',
authenticate,
requirePermission('training:write'),
requirePermission('kalender:create_training'),
trainingController.createEvent
);
@@ -109,7 +108,7 @@ router.post(
router.patch(
'/:id',
authenticate,
requirePermission('training:write'),
requirePermission('kalender:create_training'),
trainingController.updateEvent
);
@@ -121,7 +120,7 @@ router.patch(
router.delete(
'/:id',
authenticate,
requirePermission('training:cancel'),
requirePermission('kalender:cancel_training'),
trainingController.cancelEvent
);
@@ -142,7 +141,7 @@ router.patch(
router.post(
'/:id/attendance/mark',
authenticate,
requirePermission('training:mark_attendance'),
requirePermission('kalender:mark_attendance'),
trainingController.markAttendance
);

View File

@@ -10,19 +10,19 @@ const router = Router();
router.get('/', authenticate, vehicleController.listVehicles.bind(vehicleController));
router.get('/stats', authenticate, vehicleController.getStats.bind(vehicleController));
router.get('/alerts', authenticate, vehicleController.getAlerts.bind(vehicleController));
router.get('/alerts/export', authenticate, requirePermission('vehicles:read'), vehicleController.exportAlerts.bind(vehicleController));
router.get('/alerts/export', authenticate, requirePermission('fahrzeuge:view'), vehicleController.exportAlerts.bind(vehicleController));
router.get('/:id', authenticate, vehicleController.getVehicle.bind(vehicleController));
router.get('/:id/wartung', authenticate, vehicleController.getWartung.bind(vehicleController));
// ── Write — kommandant+ ──────────────────────────────────────────────────────
router.post('/', authenticate, requirePermission('vehicles:write'), vehicleController.createVehicle.bind(vehicleController));
router.patch('/:id', authenticate, requirePermission('vehicles:write'), vehicleController.updateVehicle.bind(vehicleController));
router.delete('/:id', authenticate, requirePermission('vehicles:delete'), vehicleController.deleteVehicle.bind(vehicleController));
router.post('/', authenticate, requirePermission('fahrzeuge:create'), vehicleController.createVehicle.bind(vehicleController));
router.patch('/:id', authenticate, requirePermission('fahrzeuge:create'), vehicleController.updateVehicle.bind(vehicleController));
router.delete('/:id', authenticate, requirePermission('fahrzeuge:delete'), vehicleController.deleteVehicle.bind(vehicleController));
// ── Status + maintenance log — gruppenfuehrer+ ──────────────────────────────
router.patch('/:id/status', authenticate, requirePermission('vehicles:status'), vehicleController.updateVehicleStatus.bind(vehicleController));
router.post('/:id/wartung', authenticate, requirePermission('vehicles:status'), vehicleController.addWartung.bind(vehicleController));
router.patch('/:id/status', authenticate, requirePermission('fahrzeuge:change_status'), vehicleController.updateVehicleStatus.bind(vehicleController));
router.post('/:id/wartung', authenticate, requirePermission('fahrzeuge:change_status'), vehicleController.addWartung.bind(vehicleController));
export default router;

View File

@@ -4,6 +4,7 @@ import logger from './utils/logger';
import { testConnection, closePool, runMigrations } from './config/database';
import { startAuditCleanupJob, stopAuditCleanupJob } from './jobs/audit-cleanup.job';
import { startNotificationJob, stopNotificationJob } from './jobs/notification-generation.job';
import { permissionService } from './services/permission.service';
const startServer = async (): Promise<void> => {
try {
@@ -16,6 +17,9 @@ const startServer = async (): Promise<void> => {
} else {
// Run pending migrations automatically on startup
await runMigrations();
// Load permission cache after migrations
await permissionService.loadCache();
}
// Start the GDPR IP anonymisation job

View File

@@ -0,0 +1,172 @@
import pool from '../config/database';
import logger from '../utils/logger';
export interface FeatureGroupRow {
id: string;
label: string;
sort_order: number;
maintenance: boolean;
}
export interface PermissionRow {
id: string;
feature_group_id: string;
label: string;
description: string | null;
sort_order: number;
}
export interface MatrixData {
featureGroups: FeatureGroupRow[];
permissions: PermissionRow[];
groups: string[];
grants: Record<string, string[]>;
maintenance: Record<string, boolean>;
}
class PermissionService {
private groupPermissions: Map<string, Set<string>> = new Map();
private maintenanceFlags: Map<string, boolean> = new Map();
async loadCache(): Promise<void> {
try {
// Load group permissions
const gpResult = await pool.query('SELECT authentik_group, permission_id FROM group_permissions');
const newMap = new Map<string, Set<string>>();
for (const row of gpResult.rows) {
if (!newMap.has(row.authentik_group)) {
newMap.set(row.authentik_group, new Set());
}
newMap.get(row.authentik_group)!.add(row.permission_id);
}
this.groupPermissions = newMap;
// Load maintenance flags
const mResult = await pool.query('SELECT id, maintenance FROM feature_groups');
const newFlags = new Map<string, boolean>();
for (const row of mResult.rows) {
newFlags.set(row.id, row.maintenance);
}
this.maintenanceFlags = newFlags;
logger.info('Permission cache loaded', {
groups: this.groupPermissions.size,
featureGroups: this.maintenanceFlags.size,
});
} catch (error) {
logger.error('Failed to load permission cache', { error });
// Don't throw — service can still function with empty cache
// dashboard_admin bypass ensures admins always have access
}
}
getEffectivePermissions(groups: string[]): string[] {
const permSet = new Set<string>();
for (const group of groups) {
const perms = this.groupPermissions.get(group);
if (perms) {
for (const p of perms) {
permSet.add(p);
}
}
}
return Array.from(permSet);
}
hasPermission(groups: string[], permission: string): boolean {
for (const group of groups) {
const perms = this.groupPermissions.get(group);
if (perms?.has(permission)) return true;
}
return false;
}
isFeatureInMaintenance(featureGroup: string): boolean {
return this.maintenanceFlags.get(featureGroup) ?? false;
}
getMaintenanceFlags(): Record<string, boolean> {
const result: Record<string, boolean> = {};
for (const [k, v] of this.maintenanceFlags) {
result[k] = v;
}
return result;
}
// ── Admin methods ──
async getMatrix(): Promise<MatrixData> {
const [fgResult, pResult, gpResult] = await Promise.all([
pool.query('SELECT id, label, sort_order, maintenance FROM feature_groups ORDER BY sort_order'),
pool.query('SELECT id, feature_group_id, label, description, sort_order FROM permissions ORDER BY feature_group_id, sort_order'),
pool.query('SELECT authentik_group, permission_id FROM group_permissions'),
]);
const grants: Record<string, string[]> = {};
const groupSet = new Set<string>();
for (const row of gpResult.rows) {
groupSet.add(row.authentik_group);
if (!grants[row.authentik_group]) grants[row.authentik_group] = [];
grants[row.authentik_group].push(row.permission_id);
}
const maintenance: Record<string, boolean> = {};
for (const row of fgResult.rows) {
maintenance[row.id] = row.maintenance;
}
return {
featureGroups: fgResult.rows,
permissions: pResult.rows,
groups: Array.from(groupSet).sort(),
grants,
maintenance,
};
}
async getKnownGroups(): Promise<string[]> {
const result = await pool.query(
'SELECT DISTINCT authentik_group FROM group_permissions ORDER BY authentik_group'
);
return result.rows.map((r: any) => r.authentik_group);
}
async setGroupPermissions(group: string, permIds: string[], grantedBy: string): Promise<void> {
const client = await pool.connect();
try {
await client.query('BEGIN');
// Remove all existing permissions for this group
await client.query('DELETE FROM group_permissions WHERE authentik_group = $1', [group]);
// Insert new permissions
for (const permId of permIds) {
await client.query(
'INSERT INTO group_permissions (authentik_group, permission_id, granted_by) VALUES ($1, $2, $3) ON CONFLICT DO NOTHING',
[group, permId, grantedBy]
);
}
await client.query('COMMIT');
// Reload cache
await this.loadCache();
logger.info('Group permissions updated', { group, permissionCount: permIds.length, grantedBy });
} catch (error) {
await client.query('ROLLBACK');
throw error;
} finally {
client.release();
}
}
async setMaintenanceFlag(featureGroup: string, active: boolean): Promise<void> {
await pool.query(
'UPDATE feature_groups SET maintenance = $1 WHERE id = $2',
[active, featureGroup]
);
// Reload cache
await this.loadCache();
logger.info('Maintenance flag updated', { featureGroup, active });
}
}
export const permissionService = new PermissionService();

View File

@@ -1,6 +1,7 @@
import { Routes, Route } from 'react-router-dom';
import { NotificationProvider } from './contexts/NotificationContext';
import { AuthProvider } from './contexts/AuthContext';
import { PermissionProvider } from './contexts/PermissionContext';
import ErrorBoundary from './components/shared/ErrorBoundary';
import ProtectedRoute from './components/auth/ProtectedRoute';
import LoginCallback from './components/auth/LoginCallback';
@@ -34,6 +35,7 @@ function App() {
<ErrorBoundary>
<NotificationProvider>
<AuthProvider>
<PermissionProvider>
<Routes>
<Route path="/" element={<Login />} />
<Route path="/login" element={<Login />} />
@@ -232,6 +234,7 @@ function App() {
/>
<Route path="*" element={<NotFound />} />
</Routes>
</PermissionProvider>
</AuthProvider>
</NotificationProvider>
</ErrorBoundary>

View File

@@ -0,0 +1,294 @@
import React, { useState, useCallback } from 'react';
import {
Box,
Card,
CardContent,
Checkbox,
Chip,
CircularProgress,
Collapse,
FormControlLabel,
IconButton,
Switch,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Tooltip,
Typography,
} from '@mui/material';
import { ExpandMore, ExpandLess } from '@mui/icons-material';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { permissionsApi } from '../../services/permissions';
import { useNotification } from '../../contexts/NotificationContext';
import type { PermissionMatrix, FeatureGroup, Permission } from '../../types/permissions.types';
function PermissionMatrixTab() {
const queryClient = useQueryClient();
const { showSuccess, showError } = useNotification();
const { data: matrix, isLoading } = useQuery<PermissionMatrix>({
queryKey: ['admin-permission-matrix'],
queryFn: permissionsApi.getMatrix,
});
// Track which feature groups are expanded
const [expandedGroups, setExpandedGroups] = useState<Record<string, boolean>>({});
const toggleGroup = (groupId: string) => {
setExpandedGroups(prev => ({ ...prev, [groupId]: !prev[groupId] }));
};
// ── Maintenance toggle mutation ──
const maintenanceMutation = useMutation({
mutationFn: ({ featureGroup, active }: { featureGroup: string; active: boolean }) =>
permissionsApi.setMaintenanceFlag(featureGroup, active),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin-permission-matrix'] });
queryClient.invalidateQueries({ queryKey: ['my-permissions'] });
showSuccess('Wartungsmodus aktualisiert');
},
onError: () => showError('Fehler beim Aktualisieren des Wartungsmodus'),
});
// ── Permission toggle mutation (saves full group permissions) ──
const permissionMutation = useMutation({
mutationFn: ({ group, permissions }: { group: string; permissions: string[] }) =>
permissionsApi.setGroupPermissions(group, permissions),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin-permission-matrix'] });
queryClient.invalidateQueries({ queryKey: ['my-permissions'] });
showSuccess('Berechtigungen gespeichert');
},
onError: () => showError('Fehler beim Speichern der Berechtigungen'),
});
const handlePermissionToggle = useCallback(
(group: string, permId: string, currentGrants: Record<string, string[]>) => {
const current = currentGrants[group] || [];
const newPerms = current.includes(permId)
? current.filter(p => p !== permId)
: [...current, permId];
permissionMutation.mutate({ group, permissions: newPerms });
},
[permissionMutation]
);
const handleSelectAllForGroup = useCallback(
(
authentikGroup: string,
featureGroupId: string,
permissions: Permission[],
currentGrants: Record<string, string[]>,
selectAll: boolean
) => {
const fgPermIds = permissions
.filter(p => p.feature_group_id === featureGroupId)
.map(p => p.id);
const current = currentGrants[authentikGroup] || [];
let newPerms: string[];
if (selectAll) {
const permSet = new Set([...current, ...fgPermIds]);
newPerms = Array.from(permSet);
} else {
const removeSet = new Set(fgPermIds);
newPerms = current.filter(p => !removeSet.has(p));
}
permissionMutation.mutate({ group: authentikGroup, permissions: newPerms });
},
[permissionMutation]
);
if (isLoading || !matrix) {
return (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}>
<CircularProgress />
</Box>
);
}
const { featureGroups, permissions, groups, grants, maintenance } = matrix;
const nonAdminGroups = groups.filter(g => g !== 'dashboard_admin');
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
{/* Section 1: Maintenance Toggles */}
<Card>
<CardContent>
<Typography variant="h6" gutterBottom>
Wartungsmodus
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
Wenn aktiviert, wird die Funktion für alle Benutzer ausser Administratoren ausgeblendet.
</Typography>
{featureGroups.map((fg: FeatureGroup) => (
<Box key={fg.id} sx={{ display: 'flex', alignItems: 'center', gap: 1, py: 0.5 }}>
<FormControlLabel
control={
<Switch
checked={maintenance[fg.id] ?? false}
onChange={() =>
maintenanceMutation.mutate({
featureGroup: fg.id,
active: !(maintenance[fg.id] ?? false),
})
}
disabled={maintenanceMutation.isPending}
/>
}
label={fg.label}
/>
{maintenance[fg.id] && (
<Chip label="Wartungsmodus" color="warning" size="small" />
)}
</Box>
))}
</CardContent>
</Card>
{/* Section 2: Permission Matrix */}
<Card>
<CardContent>
<Typography variant="h6" gutterBottom>
Berechtigungsmatrix
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
Berechtigungen pro Authentik-Gruppe zuweisen. Die Gruppe &quot;dashboard_admin&quot; hat immer vollen Zugriff.
</Typography>
<TableContainer sx={{ maxHeight: '70vh' }}>
<Table size="small" stickyHeader>
<TableHead>
<TableRow>
<TableCell sx={{ minWidth: 250, fontWeight: 'bold', position: 'sticky', left: 0, zIndex: 3, bgcolor: 'background.paper' }}>
Berechtigung
</TableCell>
{/* dashboard_admin column */}
<Tooltip title="Admin hat immer vollen Zugriff" placement="top">
<TableCell align="center" sx={{ minWidth: 120, fontWeight: 'bold', opacity: 0.5 }}>
dashboard_admin
</TableCell>
</Tooltip>
{nonAdminGroups.map(g => (
<TableCell key={g} align="center" sx={{ minWidth: 120, fontWeight: 'bold' }}>
{g.replace('dashboard_', '')}
</TableCell>
))}
</TableRow>
</TableHead>
<TableBody>
{featureGroups.map((fg: FeatureGroup) => {
const fgPerms = permissions.filter((p: Permission) => p.feature_group_id === fg.id);
const isExpanded = expandedGroups[fg.id] !== false; // default expanded
return (
<React.Fragment key={fg.id}>
{/* Feature group header row */}
<TableRow sx={{ bgcolor: 'action.hover' }}>
<TableCell
sx={{
fontWeight: 'bold',
position: 'sticky',
left: 0,
zIndex: 2,
bgcolor: 'action.hover',
cursor: 'pointer',
}}
onClick={() => toggleGroup(fg.id)}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<IconButton size="small">
{isExpanded ? <ExpandLess fontSize="small" /> : <ExpandMore fontSize="small" />}
</IconButton>
{fg.label}
{maintenance[fg.id] && (
<Chip label="Wartung" color="warning" size="small" />
)}
</Box>
</TableCell>
{/* Admin: all checked */}
<TableCell align="center">
<Checkbox checked disabled sx={{ opacity: 0.3 }} />
</TableCell>
{/* Per-group: select all / deselect all */}
{nonAdminGroups.map(g => {
const groupGrants = grants[g] || [];
const allGranted = fgPerms.every((p: Permission) => groupGrants.includes(p.id));
const someGranted = fgPerms.some((p: Permission) => groupGrants.includes(p.id));
return (
<TableCell key={g} align="center">
<Checkbox
checked={allGranted}
indeterminate={someGranted && !allGranted}
onChange={() =>
handleSelectAllForGroup(g, fg.id, permissions, grants, !allGranted)
}
disabled={permissionMutation.isPending}
size="small"
/>
</TableCell>
);
})}
</TableRow>
{/* Individual permission rows */}
<TableRow>
<TableCell colSpan={2 + nonAdminGroups.length} sx={{ p: 0 }}>
<Collapse in={isExpanded} timeout="auto" unmountOnExit>
<Table size="small">
<TableBody>
{fgPerms.map((perm: Permission) => (
<TableRow key={perm.id} hover>
<TableCell
sx={{
pl: 6,
minWidth: 250,
position: 'sticky',
left: 0,
zIndex: 1,
bgcolor: 'background.paper',
}}
>
<Tooltip title={perm.description || ''} placement="right">
<span>{perm.label}</span>
</Tooltip>
</TableCell>
{/* Admin: always checked */}
<TableCell align="center" sx={{ minWidth: 120 }}>
<Checkbox checked disabled sx={{ opacity: 0.3 }} />
</TableCell>
{nonAdminGroups.map(g => {
const isGranted = (grants[g] || []).includes(perm.id);
return (
<TableCell key={g} align="center" sx={{ minWidth: 120 }}>
<Checkbox
checked={isGranted}
onChange={() => handlePermissionToggle(g, perm.id, grants)}
disabled={permissionMutation.isPending}
size="small"
/>
</TableCell>
);
})}
</TableRow>
))}
</TableBody>
</Table>
</Collapse>
</TableCell>
</TableRow>
</React.Fragment>
);
})}
</TableBody>
</Table>
</TableContainer>
</CardContent>
</Card>
</Box>
);
}
export default PermissionMatrixTab;

View File

@@ -8,16 +8,13 @@ import {
Button,
Switch,
FormControlLabel,
Skeleton,
} from '@mui/material';
import { CalendarMonth } from '@mui/icons-material';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { eventsApi } from '../../services/events';
import type { CreateVeranstaltungInput } from '../../types/events.types';
import { useNotification } from '../../contexts/NotificationContext';
import { useAuth } from '../../contexts/AuthContext';
const WRITE_GROUPS = ['dashboard_admin', 'dashboard_kommando', 'dashboard_moderator', 'dashboard_gruppenfuehrer'];
import { usePermissionContext } from '../../contexts/PermissionContext';
function toDatetimeLocal(date: Date): string {
const pad = (n: number) => String(n).padStart(2, '0');
@@ -42,8 +39,8 @@ function makeDefaults() {
}
const EventQuickAddWidget: React.FC = () => {
const { user } = useAuth();
const canWrite = user?.groups?.some(g => WRITE_GROUPS.includes(g)) ?? false;
const { hasPermission } = usePermissionContext();
const canWrite = hasPermission('kalender:create_events');
const defaults = makeDefaults();
const [titel, setTitel] = useState('');

View File

@@ -29,7 +29,7 @@ import {
import { useNavigate, useLocation } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import { useLayout, DRAWER_WIDTH, DRAWER_WIDTH_COLLAPSED } from '../../contexts/LayoutContext';
import { useAuth } from '../../contexts/AuthContext';
import { usePermissionContext } from '../../contexts/PermissionContext';
import { vehiclesApi } from '../../services/vehicles';
export { DRAWER_WIDTH, DRAWER_WIDTH_COLLAPSED };
@@ -44,6 +44,7 @@ interface NavigationItem {
icon: JSX.Element;
path: string;
subItems?: SubItem[];
permission?: string;
}
const kalenderSubItems: SubItem[] = [
@@ -58,6 +59,8 @@ const adminSubItems: SubItem[] = [
{ text: 'Broadcast', path: '/admin?tab=3' },
{ text: 'Banner', path: '/admin?tab=4' },
{ text: 'Wartung', path: '/admin?tab=5' },
{ text: 'FDISK Sync', path: '/admin?tab=6' },
{ text: 'Berechtigungen', path: '/admin?tab=7' },
];
const baseNavigationItems: NavigationItem[] = [
@@ -71,31 +74,37 @@ const baseNavigationItems: NavigationItem[] = [
icon: <CalendarMonth />,
path: '/kalender',
subItems: kalenderSubItems,
permission: 'kalender:access',
},
{
text: 'Fahrzeuge',
icon: <DirectionsCar />,
path: '/fahrzeuge',
permission: 'fahrzeuge:access',
},
{
text: 'Ausrüstung',
icon: <Build />,
path: '/ausruestung',
permission: 'ausruestung:access',
},
{
text: 'Mitglieder',
icon: <People />,
path: '/mitglieder',
permission: 'mitglieder:access',
},
{
text: 'Atemschutz',
icon: <Air />,
path: '/atemschutz',
permission: 'atemschutz:access',
},
{
text: 'Wissen',
icon: <MenuBook />,
path: '/wissen',
permission: 'wissen:access',
},
];
@@ -121,9 +130,7 @@ function Sidebar({ mobileOpen, onMobileClose }: SidebarProps) {
const navigate = useNavigate();
const location = useLocation();
const { sidebarCollapsed, toggleSidebar } = useLayout();
const { user } = useAuth();
const isAdmin = user?.groups?.includes('dashboard_admin') ?? false;
const { hasPermission, isAdmin } = usePermissionContext();
// Fetch vehicle list for dynamic dropdown sub-items
const { data: vehicleList } = useQuery({
@@ -147,12 +154,13 @@ function Sidebar({ mobileOpen, onMobileClose }: SidebarProps) {
icon: <DirectionsCar />,
path: '/fahrzeuge',
subItems: vehicleSubItems.length > 0 ? vehicleSubItems : undefined,
permission: 'fahrzeuge:access',
};
const items = baseNavigationItems.map((item) =>
item.path === '/fahrzeuge' ? fahrzeugeItem : item,
);
const items = baseNavigationItems
.map((item) => item.path === '/fahrzeuge' ? fahrzeugeItem : item)
.filter((item) => !item.permission || hasPermission(item.permission));
return isAdmin ? [...items, adminItem, adminSettingsItem] : items;
}, [isAdmin, vehicleSubItems]);
}, [isAdmin, vehicleSubItems, hasPermission]);
// Expand state for items with sub-items — auto-expand when route matches
const [expandedItems, setExpandedItems] = useState<Record<string, boolean>>({});

View File

@@ -0,0 +1,98 @@
import React, { createContext, useContext, useMemo, useCallback, ReactNode } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useAuth } from './AuthContext';
import { permissionsApi } from '../services/permissions';
interface PermissionContextType {
permissions: Set<string>;
maintenance: Record<string, boolean>;
isAdmin: boolean;
isLoading: boolean;
hasPermission: (perm: string) => boolean;
hasAnyPermission: (...perms: string[]) => boolean;
isFeatureEnabled: (featureGroup: string) => boolean;
refetch: () => void;
}
const PermissionContext = createContext<PermissionContextType | undefined>(undefined);
interface PermissionProviderProps {
children: ReactNode;
}
export const PermissionProvider: React.FC<PermissionProviderProps> = ({ children }) => {
const { isAuthenticated } = useAuth();
const queryClient = useQueryClient();
const { data, isLoading } = useQuery({
queryKey: ['my-permissions'],
queryFn: permissionsApi.getMyPermissions,
enabled: isAuthenticated,
staleTime: 5 * 60 * 1000,
});
const permissions = useMemo(
() => new Set(data?.permissions ?? []),
[data?.permissions]
);
const maintenance = data?.maintenance ?? {};
const isAdmin = data?.isAdmin ?? false;
const isFeatureEnabled = useCallback(
(featureGroup: string): boolean => {
if (isAdmin) return true;
return !maintenance[featureGroup];
},
[isAdmin, maintenance]
);
const hasPermission = useCallback(
(perm: string): boolean => {
if (isAdmin) return true;
const featureGroup = perm.split(':')[0];
if (!isFeatureEnabled(featureGroup)) return false;
return permissions.has(perm);
},
[isAdmin, permissions, isFeatureEnabled]
);
const hasAnyPermission = useCallback(
(...perms: string[]): boolean => {
return perms.some(p => hasPermission(p));
},
[hasPermission]
);
const refetch = useCallback(() => {
queryClient.invalidateQueries({ queryKey: ['my-permissions'] });
}, [queryClient]);
const value = useMemo(
(): PermissionContextType => ({
permissions,
maintenance,
isAdmin,
isLoading: isAuthenticated ? isLoading : false,
hasPermission,
hasAnyPermission,
isFeatureEnabled,
refetch,
}),
[permissions, maintenance, isAdmin, isAuthenticated, isLoading, hasPermission, hasAnyPermission, isFeatureEnabled, refetch]
);
return (
<PermissionContext.Provider value={value}>
{children}
</PermissionContext.Provider>
);
};
export const usePermissionContext = (): PermissionContextType => {
const context = useContext(PermissionContext);
if (context === undefined) {
throw new Error('usePermissionContext must be used within a PermissionProvider');
}
return context;
};

View File

@@ -1,27 +1,30 @@
import { useAuth } from '../contexts/AuthContext';
import { usePermissionContext } from '../contexts/PermissionContext';
import { AusruestungKategorie } from '../types/equipment.types';
export function usePermissions() {
const { user } = useAuth();
const groups = user?.groups ?? [];
const isAdmin = groups.includes('dashboard_admin');
const isFahrmeister = groups.includes('dashboard_fahrmeister');
const isZeugmeister = groups.includes('dashboard_zeugmeister');
const { hasPermission, hasAnyPermission, isAdmin, isFeatureEnabled, isLoading, permissions, maintenance, refetch } = usePermissionContext();
return {
// Core API
hasPermission,
hasAnyPermission,
isFeatureEnabled,
isAdmin,
isFahrmeister,
isZeugmeister,
canChangeStatus: isAdmin || isFahrmeister || isZeugmeister,
canManageEquipment: isAdmin || isFahrmeister || isZeugmeister,
canManageMotorizedEquipment: isAdmin || isFahrmeister,
canManageNonMotorizedEquipment: isAdmin || isZeugmeister,
canManageCategory: (kategorie: AusruestungKategorie | null | undefined): boolean => {
if (isAdmin) return true;
if (!kategorie) return false;
return kategorie.motorisiert ? isFahrmeister : isZeugmeister;
isLoading,
permissions,
maintenance,
refetch,
// Backward-compatible convenience flags
isFahrmeister: false, // No longer needed — use hasPermission() instead
isZeugmeister: false, // No longer needed — use hasPermission() instead
canChangeStatus: hasPermission('fahrzeuge:change_status'),
canManageEquipment: hasPermission('ausruestung:create'),
canManageMotorizedEquipment: hasPermission('ausruestung:create'),
canManageNonMotorizedEquipment: hasPermission('ausruestung:create'),
canManageCategory: (_kategorie: AusruestungKategorie | null | undefined): boolean => {
return hasPermission('ausruestung:create');
},
groups,
groups: [] as string[], // Deprecated — use hasPermission() instead
};
}

View File

@@ -9,6 +9,7 @@ import NotificationBroadcastTab from '../components/admin/NotificationBroadcastT
import BannerManagementTab from '../components/admin/BannerManagementTab';
import ServiceModeTab from '../components/admin/ServiceModeTab';
import FdiskSyncTab from '../components/admin/FdiskSyncTab';
import PermissionMatrixTab from '../components/admin/PermissionMatrixTab';
import { useAuth } from '../contexts/AuthContext';
interface TabPanelProps {
@@ -22,7 +23,7 @@ function TabPanel({ children, value, index }: TabPanelProps) {
return <Box sx={{ pt: 3 }}>{children}</Box>;
}
const ADMIN_TAB_COUNT = 7;
const ADMIN_TAB_COUNT = 8;
function AdminDashboard() {
const navigate = useNavigate();
@@ -57,6 +58,7 @@ function AdminDashboard() {
<Tab label="Banner" />
<Tab label="Wartung" />
<Tab label="FDISK Sync" />
<Tab label="Berechtigungen" />
</Tabs>
</Box>
@@ -81,6 +83,9 @@ function AdminDashboard() {
<TabPanel value={tab} index={6}>
<FdiskSyncTab />
</TabPanel>
<TabPanel value={tab} index={7}>
<PermissionMatrixTab />
</TabPanel>
</DashboardLayout>
);
}

View File

@@ -45,7 +45,7 @@ import ChatAwareFab from '../components/shared/ChatAwareFab';
import { atemschutzApi } from '../services/atemschutz';
import { membersService } from '../services/members';
import { useNotification } from '../contexts/NotificationContext';
import { useAuth } from '../contexts/AuthContext';
import { usePermissionContext } from '../contexts/PermissionContext';
import { toGermanDate, fromGermanDate, isValidGermanDate } from '../utils/dateInput';
import type {
AtemschutzUebersicht,
@@ -142,10 +142,9 @@ const StatCard: React.FC<StatCardProps> = ({ label, value, color, bgcolor }) =>
function Atemschutz() {
const notification = useNotification();
const { user } = useAuth();
const ATEMSCHUTZ_PRIVILEGED = ['dashboard_admin', 'dashboard_kommando', 'dashboard_atemschutz', 'dashboard_moderator'];
const canViewAll = user?.groups?.some(g => ATEMSCHUTZ_PRIVILEGED.includes(g)) ?? false;
const canWrite = canViewAll;
const { hasPermission } = usePermissionContext();
const canViewAll = hasPermission('atemschutz:view');
const canWrite = hasPermission('atemschutz:create');
// Data state
const [traeger, setTraeger] = useState<AtemschutzUebersicht[]>([]);

View File

@@ -6,6 +6,7 @@ import {
} from '@mui/material';
import { useQuery } from '@tanstack/react-query';
import { useAuth } from '../contexts/AuthContext';
import { usePermissionContext } from '../contexts/PermissionContext';
import DashboardLayout from '../components/dashboard/DashboardLayout';
import SkeletonCard from '../components/shared/SkeletonCard';
import UserProfile from '../components/dashboard/UserProfile';
@@ -33,13 +34,7 @@ import { WidgetKey } from '../constants/widgets';
function Dashboard() {
const { user } = useAuth();
const isAdmin = user?.groups?.includes('dashboard_admin') ?? false;
const canViewAtemschutz = user?.groups?.some(g =>
['dashboard_admin', 'dashboard_kommando', 'dashboard_atemschutz', 'dashboard_moderator'].includes(g)
) ?? false;
const canWrite = user?.groups?.some(g =>
['dashboard_admin', 'dashboard_kommando', 'dashboard_moderator', 'dashboard_gruppenfuehrer'].includes(g)
) ?? false;
const { hasPermission, isAdmin } = usePermissionContext();
const [dataLoading, setDataLoading] = useState(true);
const { data: preferences } = useQuery({
@@ -120,7 +115,7 @@ function Dashboard() {
</Fade>
)}
{canViewAtemschutz && widgetVisible('atemschutz') && (
{hasPermission('atemschutz:widget') && widgetVisible('atemschutz') && (
<Fade in={!dataLoading} timeout={600} style={{ transitionDelay: '400ms' }}>
<Box>
<AtemschutzDashboardCard />
@@ -163,7 +158,7 @@ function Dashboard() {
</Fade>
)}
{canWrite && widgetVisible('eventQuickAdd') && (
{hasPermission('kalender:widget_quick_add') && widgetVisible('eventQuickAdd') && (
<Fade in={!dataLoading} timeout={600} style={{ transitionDelay: '600ms' }}>
<Box>
<EventQuickAddWidget />

View File

@@ -49,7 +49,7 @@ import {
EINSATZ_STATUS_LABELS,
} from '../services/incidents';
import CreateEinsatzDialog from '../components/incidents/CreateEinsatzDialog';
import { useAuth } from '../contexts/AuthContext';
import { usePermissionContext } from '../contexts/PermissionContext';
// ---------------------------------------------------------------------------
// COLOUR MAP for Einsatzart chips
@@ -176,10 +176,8 @@ function StatsSummaryBar({ stats, loading }: StatsSummaryProps) {
// ---------------------------------------------------------------------------
function Einsaetze() {
const navigate = useNavigate();
const { user } = useAuth();
const canWrite = user?.groups?.some((g: string) =>
['dashboard_admin', 'dashboard_kommando', 'dashboard_gruppenfuehrer'].includes(g)
) ?? false;
const { hasPermission } = usePermissionContext();
const canWrite = hasPermission('einsaetze:create');
// List state
const [items, setItems] = useState<EinsatzListItem[]>([]);

View File

@@ -43,7 +43,7 @@ import {
EinsatzArt,
} from '../services/incidents';
import { useNotification } from '../contexts/NotificationContext';
import { useAuth } from '../contexts/AuthContext';
import { usePermissionContext } from '../contexts/PermissionContext';
// ---------------------------------------------------------------------------
// COLOUR MAPS
@@ -165,10 +165,8 @@ function EinsatzDetail() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const notification = useNotification();
const { user } = useAuth();
const canWrite = user?.groups?.some((g: string) =>
['dashboard_admin', 'dashboard_kommando', 'dashboard_gruppenfuehrer'].includes(g)
) ?? false;
const { hasPermission } = usePermissionContext();
const canWrite = hasPermission('einsaetze:create');
const [einsatz, setEinsatz] = useState<EinsatzDetailType | null>(null);
const [loading, setLoading] = useState(true);

View File

@@ -46,6 +46,7 @@ import {
import DashboardLayout from '../components/dashboard/DashboardLayout';
import ChatAwareFab from '../components/shared/ChatAwareFab';
import { useAuth } from '../contexts/AuthContext';
import { usePermissionContext } from '../contexts/PermissionContext';
import { useNotification } from '../contexts/NotificationContext';
import { bookingApi, fetchVehicles } from '../services/bookings';
import type {
@@ -85,21 +86,17 @@ const EMPTY_FORM: CreateBuchungInput = {
kontaktTelefon: '',
};
const WRITE_GROUPS = ['dashboard_admin', 'dashboard_fahrmeister', 'dashboard_moderator'];
const MANAGE_ART_GROUPS = ['dashboard_admin', 'dashboard_moderator'];
// ---------------------------------------------------------------------------
// Main Page
// ---------------------------------------------------------------------------
function FahrzeugBuchungen() {
const { user } = useAuth();
const { hasPermission } = usePermissionContext();
const notification = useNotification();
const canCreate = !!user; // All authenticated users can create bookings
const canWrite =
user?.groups?.some((g) => WRITE_GROUPS.includes(g)) ?? false; // Can edit/cancel
const canChangeBuchungsArt =
user?.groups?.some((g) => MANAGE_ART_GROUPS.includes(g)) ?? false; // Can change booking type
const canCreate = hasPermission('kalender:create_bookings');
const canWrite = hasPermission('kalender:edit_bookings');
const canChangeBuchungsArt = hasPermission('kalender:manage_categories');
// ── Week navigation ────────────────────────────────────────────────────────
const [currentWeekStart, setCurrentWeekStart] = useState<Date>(() =>

View File

@@ -72,6 +72,7 @@ import DashboardLayout from '../components/dashboard/DashboardLayout';
import ChatAwareFab from '../components/shared/ChatAwareFab';
import { toGermanDateTime, fromGermanDate, fromGermanDateTime, isValidGermanDateTime } from '../utils/dateInput';
import { useAuth } from '../contexts/AuthContext';
import { usePermissionContext } from '../contexts/PermissionContext';
import { useNotification } from '../contexts/NotificationContext';
import { trainingApi } from '../services/training';
import { eventsApi } from '../services/events';
@@ -117,9 +118,6 @@ import { de } from 'date-fns/locale';
// Constants
// ──────────────────────────────────────────────────────────────────────────────
const WRITE_GROUPS_EVENTS = ['dashboard_admin', 'dashboard_moderator'];
const WRITE_GROUPS_BOOKINGS = ['dashboard_admin', 'dashboard_fahrmeister', 'dashboard_moderator'];
const WEEKDAY_LABELS = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'];
const MONTH_LABELS = [
'Januar', 'Februar', 'März', 'April', 'Mai', 'Juni',
@@ -1704,15 +1702,14 @@ export default function Kalender() {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const { user } = useAuth();
const { hasPermission } = usePermissionContext();
const notification = useNotification();
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('sm'));
const canWriteEvents =
user?.groups?.some((g) => WRITE_GROUPS_EVENTS.includes(g)) ?? false;
const canWriteBookings =
user?.groups?.some((g) => WRITE_GROUPS_BOOKINGS.includes(g)) ?? false;
const canCreateBookings = !!user;
const canWriteEvents = hasPermission('kalender:create_events');
const canWriteBookings = hasPermission('kalender:edit_bookings');
const canCreateBookings = hasPermission('kalender:create_bookings');
// ── Tab ─────────────────────────────────────────────────────────────────────
const [activeTab, setActiveTab] = useState(() => {

View File

@@ -40,6 +40,7 @@ import {
import { useParams, useNavigate } from 'react-router-dom';
import DashboardLayout from '../components/dashboard/DashboardLayout';
import { useAuth } from '../contexts/AuthContext';
import { usePermissionContext } from '../contexts/PermissionContext';
import { membersService } from '../services/members';
import { atemschutzApi } from '../services/atemschutz';
import { toGermanDate, fromGermanDate } from '../utils/dateInput';
@@ -67,9 +68,8 @@ import { UntersuchungErgebnisLabel } from '../types/atemschutz.types';
// Role helpers
// ----------------------------------------------------------------
function useCanWrite(): boolean {
const { user } = useAuth();
const groups: string[] = (user as any)?.groups ?? [];
return groups.includes('dashboard_admin') || groups.includes('dashboard_kommando');
const { hasPermission } = usePermissionContext();
return hasPermission('mitglieder:edit');
}
function useCurrentUserId(): string | undefined {

View File

@@ -34,6 +34,7 @@ import { useNavigate } from 'react-router-dom';
import DashboardLayout from '../components/dashboard/DashboardLayout';
import ChatAwareFab from '../components/shared/ChatAwareFab';
import { useAuth } from '../contexts/AuthContext';
import { usePermissionContext } from '../contexts/PermissionContext';
import { membersService } from '../services/members';
import {
MemberListItem,
@@ -51,9 +52,8 @@ import {
// Helper: determine whether the current user can write member data
// ----------------------------------------------------------------
function useCanWrite(): boolean {
const { user } = useAuth();
const groups: string[] = (user as any)?.groups ?? [];
return groups.includes('dashboard_admin') || groups.includes('dashboard_kommando');
const { hasPermission } = usePermissionContext();
return hasPermission('mitglieder:edit');
}
// ----------------------------------------------------------------
@@ -73,17 +73,17 @@ function useDebounce<T>(value: T, delay: number): T {
// ----------------------------------------------------------------
function Mitglieder() {
const navigate = useNavigate();
const { user } = useAuth(); const canWrite = useCanWrite();
const { user } = useAuth();
const canWrite = useCanWrite();
const { hasPermission } = usePermissionContext();
// --- redirect non-admin/non-kommando users to their own profile ---
// --- redirect non-privileged users to their own profile ---
useEffect(() => {
if (!user) return;
const groups: string[] = (user as any)?.groups ?? [];
const isAdmin = groups.includes('dashboard_admin') || groups.includes('dashboard_kommando');
if (!isAdmin) {
if (!hasPermission('mitglieder:edit')) {
navigate(`/mitglieder/${(user as any).id}`, { replace: true });
}
}, [user, navigate]);
}, [user, navigate, hasPermission]);
// --- data state ---
const [members, setMembers] = useState<MemberListItem[]>([]);

View File

@@ -34,7 +34,7 @@ import {
Category as CategoryIcon,
} from '@mui/icons-material';
import DashboardLayout from '../components/dashboard/DashboardLayout';
import { useAuth } from '../contexts/AuthContext';
import { usePermissionContext } from '../contexts/PermissionContext';
import { useNotification } from '../contexts/NotificationContext';
import { eventsApi } from '../services/events';
import type { VeranstaltungKategorie, GroupInfo } from '../types/events.types';
@@ -298,10 +298,9 @@ function DeleteDialog({ open, kategorie, onClose, onDeleted }: DeleteDialogProps
// ---------------------------------------------------------------------------
export default function VeranstaltungKategorien() {
const { user } = useAuth();
const { hasPermission } = usePermissionContext();
const canManage =
user?.groups?.some((g) => ['dashboard_admin', 'dashboard_moderator'].includes(g)) ?? false;
const canManage = hasPermission('kalender:manage_categories');
const [kategorien, setKategorien] = useState<VeranstaltungKategorie[]>([]);
const [groups, setGroups] = useState<GroupInfo[]>([]);

View File

@@ -52,7 +52,7 @@ import {
import DashboardLayout from '../components/dashboard/DashboardLayout';
import ChatAwareFab from '../components/shared/ChatAwareFab';
import { toGermanDate, toGermanDateTime, fromGermanDate, fromGermanDateTime } from '../utils/dateInput';
import { useAuth } from '../contexts/AuthContext';
import { usePermissionContext } from '../contexts/PermissionContext';
import { useNotification } from '../contexts/NotificationContext';
import { eventsApi } from '../services/events';
import type {
@@ -1069,13 +1069,12 @@ function EventListView({ events, canWrite, onEdit, onCancel, onDelete }: ListVie
// ---------------------------------------------------------------------------
export default function Veranstaltungen() {
const { user } = useAuth();
const { hasPermission } = usePermissionContext();
const notification = useNotification();
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('sm'));
const canWrite =
user?.groups?.some((g) => ['dashboard_admin', 'dashboard_moderator'].includes(g)) ?? false;
const canWrite = hasPermission('kalender:create_events');
const today = new Date();
const [viewMonth, setViewMonth] = useState({ year: today.getFullYear(), month: today.getMonth() });

View File

@@ -0,0 +1,27 @@
import { api } from './api';
import type { MyPermissions, PermissionMatrix } from '../types/permissions.types';
export const permissionsApi = {
getMyPermissions: async (): Promise<MyPermissions> => {
const r = await api.get('/api/permissions/me');
return r.data.data;
},
getMatrix: async (): Promise<PermissionMatrix> => {
const r = await api.get('/api/permissions/admin/matrix');
return r.data.data;
},
getGroups: async (): Promise<string[]> => {
const r = await api.get('/api/permissions/admin/groups');
return r.data.data;
},
setGroupPermissions: async (group: string, permissions: string[]): Promise<void> => {
await api.put(`/api/permissions/admin/group/${encodeURIComponent(group)}`, { permissions });
},
setMaintenanceFlag: async (featureGroup: string, active: boolean): Promise<void> => {
await api.put(`/api/permissions/admin/maintenance/${encodeURIComponent(featureGroup)}`, { active });
},
};

View File

@@ -0,0 +1,28 @@
export interface FeatureGroup {
id: string;
label: string;
sort_order: number;
maintenance: boolean;
}
export interface Permission {
id: string;
feature_group_id: string;
label: string;
description?: string;
sort_order: number;
}
export interface MyPermissions {
permissions: string[];
maintenance: Record<string, boolean>;
isAdmin: boolean;
}
export interface PermissionMatrix {
featureGroups: FeatureGroup[];
permissions: Permission[];
groups: string[];
grants: Record<string, string[]>;
maintenance: Record<string, boolean>;
}