rights system
This commit is contained in:
@@ -97,6 +97,7 @@ import configRoutes from './routes/config.routes';
|
|||||||
import serviceMonitorRoutes from './routes/serviceMonitor.routes';
|
import serviceMonitorRoutes from './routes/serviceMonitor.routes';
|
||||||
import settingsRoutes from './routes/settings.routes';
|
import settingsRoutes from './routes/settings.routes';
|
||||||
import bannerRoutes from './routes/banner.routes';
|
import bannerRoutes from './routes/banner.routes';
|
||||||
|
import permissionRoutes from './routes/permission.routes';
|
||||||
|
|
||||||
app.use('/api/auth', authRoutes);
|
app.use('/api/auth', authRoutes);
|
||||||
app.use('/api/user', userRoutes);
|
app.use('/api/user', userRoutes);
|
||||||
@@ -118,6 +119,7 @@ app.use('/api/admin', serviceMonitorRoutes);
|
|||||||
app.use('/api/admin/settings', settingsRoutes);
|
app.use('/api/admin/settings', settingsRoutes);
|
||||||
app.use('/api/settings', settingsRoutes);
|
app.use('/api/settings', settingsRoutes);
|
||||||
app.use('/api/banners', bannerRoutes);
|
app.use('/api/banners', bannerRoutes);
|
||||||
|
app.use('/api/permissions', permissionRoutes);
|
||||||
|
|
||||||
// 404 handler
|
// 404 handler
|
||||||
app.use(notFoundHandler);
|
app.use(notFoundHandler);
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ import { Request, Response } from 'express';
|
|||||||
import incidentService from '../services/incident.service';
|
import incidentService from '../services/incident.service';
|
||||||
import logger from '../utils/logger';
|
import logger from '../utils/logger';
|
||||||
import { AppError } from '../middleware/error.middleware';
|
import { AppError } from '../middleware/error.middleware';
|
||||||
import { AppRole, hasPermission, resolveRequestRole } from '../middleware/rbac.middleware';
|
import { AppRole } from '../middleware/rbac.middleware';
|
||||||
|
import { permissionService } from '../services/permission.service';
|
||||||
import {
|
import {
|
||||||
CreateEinsatzSchema,
|
CreateEinsatzSchema,
|
||||||
UpdateEinsatzSchema,
|
UpdateEinsatzSchema,
|
||||||
@@ -88,9 +89,11 @@ class IncidentController {
|
|||||||
throw new AppError('Einsatz nicht gefunden', 404);
|
throw new AppError('Einsatz nicht gefunden', 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Role-based redaction: self-contained role resolution (no middleware dependency)
|
// Role-based redaction: check einsaetze:view_reports permission
|
||||||
const role = resolveRequestRole(req);
|
const groups: string[] = req.user?.groups ?? [];
|
||||||
const canReadBerichtText = hasPermission(role, 'incidents:read_bericht_text');
|
const canReadBerichtText =
|
||||||
|
groups.includes('dashboard_admin') ||
|
||||||
|
permissionService.hasPermission(groups, 'einsaetze:view_reports');
|
||||||
|
|
||||||
const responseData = {
|
const responseData = {
|
||||||
...incident,
|
...incident,
|
||||||
|
|||||||
116
backend/src/controllers/permission.controller.ts
Normal file
116
backend/src/controllers/permission.controller.ts
Normal 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();
|
||||||
563
backend/src/database/migrations/037_create_permission_system.sql
Normal file
563
backend/src/database/migrations/037_create_permission_system.sql
Normal 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;
|
||||||
@@ -1,13 +1,11 @@
|
|||||||
import { Request, Response, NextFunction } from 'express';
|
import { Request, Response, NextFunction } from 'express';
|
||||||
import pool from '../config/database';
|
|
||||||
import logger from '../utils/logger';
|
import logger from '../utils/logger';
|
||||||
import { auditPermissionDenied } from './audit.middleware';
|
import { auditPermissionDenied } from './audit.middleware';
|
||||||
import { AuditResourceType } from '../services/audit.service';
|
import { AuditResourceType } from '../services/audit.service';
|
||||||
|
import { permissionService } from '../services/permission.service';
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// AppRole — mirrors the roles defined in the project spec.
|
// AppRole — kept for backward compatibility (resolveRequestRole, bericht_text)
|
||||||
// Tier 1 (RBAC) is assumed complete and adds a `role` column to users.
|
|
||||||
// This middleware reads that column to enforce permissions.
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
export type AppRole =
|
export type AppRole =
|
||||||
| 'admin'
|
| 'admin'
|
||||||
@@ -16,105 +14,14 @@ export type AppRole =
|
|||||||
| 'mitglied'
|
| 'mitglied'
|
||||||
| 'bewerber';
|
| '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
|
* 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:
|
* Hardwired rules:
|
||||||
* router.post('/api/incidents', authenticate, requirePermission('incidents:write'), handler)
|
* - `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) {
|
export function requirePermission(permission: string) {
|
||||||
return async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
return async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||||
@@ -126,27 +33,65 @@ export function requirePermission(permission: string) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const dbRole = (req.user as any).role
|
const groups: string[] = req.user?.groups ?? [];
|
||||||
? (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;
|
|
||||||
|
|
||||||
// Attach role to request for downstream use (e.g., bericht_text redaction)
|
// Attach resolved role for downstream use (bericht_text redaction, etc.)
|
||||||
(req as Request & { userRole?: AppRole }).userRole = role;
|
(req as any).userRole = resolveRequestRole(req);
|
||||||
|
|
||||||
if (!hasPermission(role, permission)) {
|
// Hardwired: dashboard_admin always has full access
|
||||||
logger.warn('Permission denied', {
|
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,
|
userId: req.user.id,
|
||||||
role,
|
|
||||||
permission,
|
permission,
|
||||||
path: req.path,
|
path: req.path,
|
||||||
});
|
});
|
||||||
|
|
||||||
// GDPR audit trail — fire-and-forget, never throws
|
|
||||||
auditPermissionDenied(req, AuditResourceType.SYSTEM, undefined, {
|
auditPermissionDenied(req, AuditResourceType.SYSTEM, undefined, {
|
||||||
required_permission: permission,
|
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({
|
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.
|
* Resolve the effective AppRole for a request.
|
||||||
* Self-contained — does not depend on requirePermission() middleware having run.
|
* 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 {
|
export function resolveRequestRole(req: Request): AppRole {
|
||||||
const dbRole = (req.user as any)?.role
|
const groups: string[] = req.user?.groups ?? [];
|
||||||
? ((req.user as any).role as AppRole)
|
if (groups.includes('dashboard_admin')) return 'admin';
|
||||||
: 'mitglied';
|
if (groups.includes('dashboard_kommando')) return 'kommandant';
|
||||||
const groupRole = roleFromGroups(req.user?.groups ?? []);
|
if (
|
||||||
return ROLE_HIERARCHY.indexOf(groupRole) > ROLE_HIERARCHY.indexOf(dbRole) ? groupRole : dbRole;
|
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
|
* @deprecated Use requirePermission() instead.
|
||||||
* one of the given Authentik groups (sourced from the JWT `groups` claim).
|
|
||||||
*
|
|
||||||
* Usage:
|
|
||||||
* router.post('/api/vehicles', authenticate, requireGroups(['dashboard_admin']), handler)
|
|
||||||
*/
|
*/
|
||||||
export function requireGroups(requiredGroups: string[]) {
|
export function requireGroups(requiredGroups: string[]) {
|
||||||
return async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
return async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||||
@@ -195,15 +157,15 @@ export function requireGroups(requiredGroups: string[]) {
|
|||||||
|
|
||||||
if (!hasAccess) {
|
if (!hasAccess) {
|
||||||
logger.warn('Group-based access denied', {
|
logger.warn('Group-based access denied', {
|
||||||
userId: req.user.id,
|
userId: req.user.id,
|
||||||
userGroups,
|
userGroups,
|
||||||
requiredGroups,
|
requiredGroups,
|
||||||
path: req.path,
|
path: req.path,
|
||||||
});
|
});
|
||||||
|
|
||||||
auditPermissionDenied(req, AuditResourceType.SYSTEM, undefined, {
|
auditPermissionDenied(req, AuditResourceType.SYSTEM, undefined, {
|
||||||
required_groups: requiredGroups,
|
required_groups: requiredGroups,
|
||||||
user_groups: userGroups,
|
user_groups: userGroups,
|
||||||
});
|
});
|
||||||
|
|
||||||
res.status(403).json({
|
res.status(403).json({
|
||||||
|
|||||||
@@ -16,8 +16,8 @@ router.get('/:id', authenticate, atemschutzController.getOne.bind(atem
|
|||||||
|
|
||||||
// ── Write — gruppenfuehrer+ ─────────────────────────────────────────────────
|
// ── Write — gruppenfuehrer+ ─────────────────────────────────────────────────
|
||||||
|
|
||||||
router.post('/', authenticate, requirePermission('atemschutz:write'), atemschutzController.create.bind(atemschutzController));
|
router.post('/', authenticate, requirePermission('atemschutz:create'), atemschutzController.create.bind(atemschutzController));
|
||||||
router.patch('/:id', authenticate, requirePermission('atemschutz:write'), atemschutzController.update.bind(atemschutzController));
|
router.patch('/:id', authenticate, requirePermission('atemschutz:create'), atemschutzController.update.bind(atemschutzController));
|
||||||
|
|
||||||
// ── Delete — kommandant+ ────────────────────────────────────────────────────
|
// ── Delete — kommandant+ ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|||||||
@@ -19,14 +19,14 @@ router.get('/calendar-token', authenticate, bookingController.getCalendarToken.b
|
|||||||
// ── Write operations ──────────────────────────────────────────────────────────
|
// ── Write operations ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
router.post('/', authenticate, bookingController.create.bind(bookingController));
|
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
|
// Soft-cancel (sets abgesagt=TRUE) — creator or bookings:write
|
||||||
router.delete('/:id', authenticate, bookingController.cancel.bind(bookingController));
|
router.delete('/:id', authenticate, bookingController.cancel.bind(bookingController));
|
||||||
router.patch('/:id/cancel', authenticate, bookingController.cancel.bind(bookingController));
|
router.patch('/:id/cancel', authenticate, bookingController.cancel.bind(bookingController));
|
||||||
|
|
||||||
// Hard-delete (admin only)
|
// 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 ───────
|
// ── Single booking read — after specific routes to avoid path conflicts ───────
|
||||||
|
|
||||||
|
|||||||
@@ -17,13 +17,13 @@ router.get('/:id', authenticate, equipmentController.getEquipmen
|
|||||||
|
|
||||||
// ── Write — gruppenfuehrer+ ────────────────────────────────────────────────
|
// ── Write — gruppenfuehrer+ ────────────────────────────────────────────────
|
||||||
|
|
||||||
router.post('/', authenticate, requirePermission('equipment:write'), equipmentController.createEquipment.bind(equipmentController));
|
router.post('/', authenticate, requirePermission('ausruestung:create'), equipmentController.createEquipment.bind(equipmentController));
|
||||||
router.patch('/:id', authenticate, requirePermission('equipment:write'), equipmentController.updateEquipment.bind(equipmentController));
|
router.patch('/:id', authenticate, requirePermission('ausruestung:create'), equipmentController.updateEquipment.bind(equipmentController));
|
||||||
router.patch('/:id/status', authenticate, requirePermission('equipment:write'), equipmentController.updateStatus.bind(equipmentController));
|
router.patch('/:id/status', authenticate, requirePermission('ausruestung:create'), equipmentController.updateStatus.bind(equipmentController));
|
||||||
router.post('/:id/wartung', authenticate, requirePermission('equipment:write'), equipmentController.addWartung.bind(equipmentController));
|
router.post('/:id/wartung', authenticate, requirePermission('ausruestung:create'), equipmentController.addWartung.bind(equipmentController));
|
||||||
|
|
||||||
// ── Delete — admin only ──────────────────────────────────────────────────────
|
// ── 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;
|
export default router;
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ router.get('/kategorien', authenticate, eventsController.listKategorien.bind(eve
|
|||||||
router.post(
|
router.post(
|
||||||
'/kategorien',
|
'/kategorien',
|
||||||
authenticate,
|
authenticate,
|
||||||
requirePermission('events:categories'),
|
requirePermission('kalender:manage_categories'),
|
||||||
eventsController.createKategorie.bind(eventsController)
|
eventsController.createKategorie.bind(eventsController)
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -33,7 +33,7 @@ router.post(
|
|||||||
router.patch(
|
router.patch(
|
||||||
'/kategorien/:id',
|
'/kategorien/:id',
|
||||||
authenticate,
|
authenticate,
|
||||||
requirePermission('events:categories'),
|
requirePermission('kalender:manage_categories'),
|
||||||
eventsController.updateKategorie.bind(eventsController)
|
eventsController.updateKategorie.bind(eventsController)
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -44,7 +44,7 @@ router.patch(
|
|||||||
router.delete(
|
router.delete(
|
||||||
'/kategorien/:id',
|
'/kategorien/:id',
|
||||||
authenticate,
|
authenticate,
|
||||||
requirePermission('events:categories'),
|
requirePermission('kalender:manage_categories'),
|
||||||
eventsController.deleteKategorie.bind(eventsController)
|
eventsController.deleteKategorie.bind(eventsController)
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -118,7 +118,7 @@ router.get(
|
|||||||
router.post(
|
router.post(
|
||||||
'/import',
|
'/import',
|
||||||
authenticate,
|
authenticate,
|
||||||
requirePermission('events:write'),
|
requirePermission('kalender:create_events'),
|
||||||
eventsController.importEvents.bind(eventsController)
|
eventsController.importEvents.bind(eventsController)
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -129,7 +129,7 @@ router.post(
|
|||||||
router.post(
|
router.post(
|
||||||
'/',
|
'/',
|
||||||
authenticate,
|
authenticate,
|
||||||
requirePermission('events:write'),
|
requirePermission('kalender:create_events'),
|
||||||
eventsController.createEvent.bind(eventsController)
|
eventsController.createEvent.bind(eventsController)
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -146,7 +146,7 @@ router.get('/:id', authenticate, eventsController.getById.bind(eventsController)
|
|||||||
router.patch(
|
router.patch(
|
||||||
'/:id',
|
'/:id',
|
||||||
authenticate,
|
authenticate,
|
||||||
requirePermission('events:write'),
|
requirePermission('kalender:create_events'),
|
||||||
eventsController.updateEvent.bind(eventsController)
|
eventsController.updateEvent.bind(eventsController)
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -157,7 +157,7 @@ router.patch(
|
|||||||
router.delete(
|
router.delete(
|
||||||
'/:id',
|
'/:id',
|
||||||
authenticate,
|
authenticate,
|
||||||
requirePermission('events:write'),
|
requirePermission('kalender:create_events'),
|
||||||
eventsController.cancelEvent.bind(eventsController)
|
eventsController.cancelEvent.bind(eventsController)
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -168,7 +168,7 @@ router.delete(
|
|||||||
router.post(
|
router.post(
|
||||||
'/:id/delete',
|
'/:id/delete',
|
||||||
authenticate,
|
authenticate,
|
||||||
requirePermission('events:write'),
|
requirePermission('kalender:create_events'),
|
||||||
eventsController.deleteEvent.bind(eventsController)
|
eventsController.deleteEvent.bind(eventsController)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ router.use(authenticate);
|
|||||||
*/
|
*/
|
||||||
router.get(
|
router.get(
|
||||||
'/',
|
'/',
|
||||||
requirePermission('incidents:read'),
|
requirePermission('einsaetze:view'),
|
||||||
incidentController.listIncidents.bind(incidentController)
|
incidentController.listIncidents.bind(incidentController)
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -31,7 +31,7 @@ router.get(
|
|||||||
*/
|
*/
|
||||||
router.get(
|
router.get(
|
||||||
'/stats',
|
'/stats',
|
||||||
requirePermission('incidents:read'),
|
requirePermission('einsaetze:view'),
|
||||||
incidentController.getStats.bind(incidentController)
|
incidentController.getStats.bind(incidentController)
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -42,7 +42,7 @@ router.get(
|
|||||||
*/
|
*/
|
||||||
router.post(
|
router.post(
|
||||||
'/refresh-stats',
|
'/refresh-stats',
|
||||||
requirePermission('incidents:delete'), // kommandant+ (repurposing delete permission for admin ops)
|
requirePermission('einsaetze:delete'),
|
||||||
incidentController.refreshStats.bind(incidentController)
|
incidentController.refreshStats.bind(incidentController)
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -53,7 +53,7 @@ router.post(
|
|||||||
*/
|
*/
|
||||||
router.get(
|
router.get(
|
||||||
'/:id',
|
'/:id',
|
||||||
requirePermission('incidents:read'),
|
requirePermission('einsaetze:view'),
|
||||||
incidentController.getIncident.bind(incidentController)
|
incidentController.getIncident.bind(incidentController)
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -64,7 +64,7 @@ router.get(
|
|||||||
*/
|
*/
|
||||||
router.post(
|
router.post(
|
||||||
'/',
|
'/',
|
||||||
requirePermission('incidents:write'),
|
requirePermission('einsaetze:create'),
|
||||||
incidentController.createIncident.bind(incidentController)
|
incidentController.createIncident.bind(incidentController)
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -75,7 +75,7 @@ router.post(
|
|||||||
*/
|
*/
|
||||||
router.patch(
|
router.patch(
|
||||||
'/:id',
|
'/:id',
|
||||||
requirePermission('incidents:write'),
|
requirePermission('einsaetze:create'),
|
||||||
incidentController.updateIncident.bind(incidentController)
|
incidentController.updateIncident.bind(incidentController)
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -86,7 +86,7 @@ router.patch(
|
|||||||
*/
|
*/
|
||||||
router.delete(
|
router.delete(
|
||||||
'/:id',
|
'/:id',
|
||||||
requirePermission('incidents:delete'),
|
requirePermission('einsaetze:delete'),
|
||||||
incidentController.deleteIncident.bind(incidentController)
|
incidentController.deleteIncident.bind(incidentController)
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -98,7 +98,7 @@ router.delete(
|
|||||||
*/
|
*/
|
||||||
router.post(
|
router.post(
|
||||||
'/:id/personnel',
|
'/:id/personnel',
|
||||||
requirePermission('incidents:manage_personnel'),
|
requirePermission('einsaetze:manage_personnel'),
|
||||||
incidentController.assignPersonnel.bind(incidentController)
|
incidentController.assignPersonnel.bind(incidentController)
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -109,7 +109,7 @@ router.post(
|
|||||||
*/
|
*/
|
||||||
router.delete(
|
router.delete(
|
||||||
'/:id/personnel/:userId',
|
'/:id/personnel/:userId',
|
||||||
requirePermission('incidents:manage_personnel'),
|
requirePermission('einsaetze:manage_personnel'),
|
||||||
incidentController.removePersonnel.bind(incidentController)
|
incidentController.removePersonnel.bind(incidentController)
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -121,7 +121,7 @@ router.delete(
|
|||||||
*/
|
*/
|
||||||
router.post(
|
router.post(
|
||||||
'/:id/vehicles',
|
'/:id/vehicles',
|
||||||
requirePermission('incidents:manage_personnel'),
|
requirePermission('einsaetze:manage_personnel'),
|
||||||
incidentController.assignVehicle.bind(incidentController)
|
incidentController.assignVehicle.bind(incidentController)
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -132,7 +132,7 @@ router.post(
|
|||||||
*/
|
*/
|
||||||
router.delete(
|
router.delete(
|
||||||
'/:id/vehicles/:fahrzeugId',
|
'/:id/vehicles/:fahrzeugId',
|
||||||
requirePermission('incidents:manage_personnel'),
|
requirePermission('einsaetze:manage_personnel'),
|
||||||
incidentController.removeVehicle.bind(incidentController)
|
incidentController.removeVehicle.bind(incidentController)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -17,49 +17,49 @@ router.use(authenticate);
|
|||||||
// "stats" as a userId parameter.
|
// "stats" as a userId parameter.
|
||||||
router.get(
|
router.get(
|
||||||
'/stats',
|
'/stats',
|
||||||
requirePermission('members:read'),
|
requirePermission('mitglieder:view'),
|
||||||
memberController.getMemberStats.bind(memberController)
|
memberController.getMemberStats.bind(memberController)
|
||||||
);
|
);
|
||||||
|
|
||||||
router.get(
|
router.get(
|
||||||
'/',
|
'/',
|
||||||
requirePermission('members:read'),
|
requirePermission('mitglieder:view'),
|
||||||
memberController.getMembers.bind(memberController)
|
memberController.getMembers.bind(memberController)
|
||||||
);
|
);
|
||||||
|
|
||||||
router.get(
|
router.get(
|
||||||
'/:userId',
|
'/:userId',
|
||||||
requirePermission('members:read'),
|
requirePermission('mitglieder:view'),
|
||||||
memberController.getMemberById.bind(memberController)
|
memberController.getMemberById.bind(memberController)
|
||||||
);
|
);
|
||||||
|
|
||||||
router.post(
|
router.post(
|
||||||
'/:userId/profile',
|
'/:userId/profile',
|
||||||
requirePermission('members:write'),
|
requirePermission('mitglieder:edit'),
|
||||||
memberController.createMemberProfile.bind(memberController)
|
memberController.createMemberProfile.bind(memberController)
|
||||||
);
|
);
|
||||||
|
|
||||||
router.get(
|
router.get(
|
||||||
'/:userId/befoerderungen',
|
'/:userId/befoerderungen',
|
||||||
requirePermission('members:read'),
|
requirePermission('mitglieder:view'),
|
||||||
memberController.getBefoerderungen.bind(memberController)
|
memberController.getBefoerderungen.bind(memberController)
|
||||||
);
|
);
|
||||||
|
|
||||||
router.get(
|
router.get(
|
||||||
'/:userId/untersuchungen',
|
'/:userId/untersuchungen',
|
||||||
requirePermission('members:read'),
|
requirePermission('mitglieder:view'),
|
||||||
memberController.getUntersuchungen.bind(memberController)
|
memberController.getUntersuchungen.bind(memberController)
|
||||||
);
|
);
|
||||||
|
|
||||||
router.get(
|
router.get(
|
||||||
'/:userId/fahrgenehmigungen',
|
'/:userId/fahrgenehmigungen',
|
||||||
requirePermission('members:read'),
|
requirePermission('mitglieder:view'),
|
||||||
memberController.getFahrgenehmigungen.bind(memberController)
|
memberController.getFahrgenehmigungen.bind(memberController)
|
||||||
);
|
);
|
||||||
|
|
||||||
router.get(
|
router.get(
|
||||||
'/:userId/ausbildungen',
|
'/:userId/ausbildungen',
|
||||||
requirePermission('members:read'),
|
requirePermission('mitglieder:view'),
|
||||||
memberController.getAusbildungen.bind(memberController)
|
memberController.getAusbildungen.bind(memberController)
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -76,7 +76,7 @@ const requireOwnerOrWrite = (req: Request, res: Response, next: NextFunction): v
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Not the owner — must have members:write permission
|
// Not the owner — must have members:write permission
|
||||||
requirePermission('members:write')(req, res, next);
|
requirePermission('mitglieder:edit')(req, res, next);
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
17
backend/src/routes/permission.routes.ts
Normal file
17
backend/src/routes/permission.routes.ts
Normal 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;
|
||||||
@@ -1,14 +1,15 @@
|
|||||||
import { Router, Request, Response, NextFunction } from 'express';
|
import { Router, Request, Response, NextFunction } from 'express';
|
||||||
import trainingController from '../controllers/training.controller';
|
import trainingController from '../controllers/training.controller';
|
||||||
import { authenticate, optionalAuth } from '../middleware/auth.middleware';
|
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();
|
const router = Router();
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// injectTeilnahmenFlag
|
// 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.
|
// Regular Mitglieder see only attendance counts; officers see the full list.
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
@@ -19,12 +20,10 @@ async function injectTeilnahmenFlag(
|
|||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
try {
|
try {
|
||||||
if (req.user) {
|
if (req.user) {
|
||||||
const role = await getUserRole(req.user.id);
|
const groups: string[] = req.user?.groups ?? [];
|
||||||
const ROLE_ORDER: Record<string, number> = {
|
|
||||||
bewerber: -1, mitglied: 0, gruppenfuehrer: 1, kommandant: 2, admin: 3,
|
|
||||||
};
|
|
||||||
(req as any).canSeeTeilnahmen =
|
(req as any).canSeeTeilnahmen =
|
||||||
(ROLE_ORDER[role] ?? 0) >= ROLE_ORDER.gruppenfuehrer;
|
groups.includes('dashboard_admin') ||
|
||||||
|
permissionService.hasPermission(groups, 'kalender:mark_attendance');
|
||||||
}
|
}
|
||||||
} catch (_err) {
|
} catch (_err) {
|
||||||
// Non-fatal — default to restricted view
|
// Non-fatal — default to restricted view
|
||||||
@@ -68,12 +67,12 @@ router.get('/calendar-token', authenticate, trainingController.getCalendarToken)
|
|||||||
/**
|
/**
|
||||||
* GET /api/training/stats?year=<YYYY>
|
* GET /api/training/stats?year=<YYYY>
|
||||||
* Annual participation stats.
|
* Annual participation stats.
|
||||||
* Requires Kommandant or above (requirePermission('reports:read')).
|
* Requires Kommandant or above (requirePermission('kalender:view_reports')).
|
||||||
*/
|
*/
|
||||||
router.get(
|
router.get(
|
||||||
'/stats',
|
'/stats',
|
||||||
authenticate,
|
authenticate,
|
||||||
requirePermission('reports:read'),
|
requirePermission('kalender:view_reports'),
|
||||||
trainingController.getStats
|
trainingController.getStats
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -92,12 +91,12 @@ router.get(
|
|||||||
/**
|
/**
|
||||||
* POST /api/training
|
* POST /api/training
|
||||||
* Create a new training event.
|
* Create a new training event.
|
||||||
* Requires Gruppenführer or above (requirePermission('training:write')).
|
* Requires Gruppenführer or above (requirePermission('kalender:create_training')).
|
||||||
*/
|
*/
|
||||||
router.post(
|
router.post(
|
||||||
'/',
|
'/',
|
||||||
authenticate,
|
authenticate,
|
||||||
requirePermission('training:write'),
|
requirePermission('kalender:create_training'),
|
||||||
trainingController.createEvent
|
trainingController.createEvent
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -109,7 +108,7 @@ router.post(
|
|||||||
router.patch(
|
router.patch(
|
||||||
'/:id',
|
'/:id',
|
||||||
authenticate,
|
authenticate,
|
||||||
requirePermission('training:write'),
|
requirePermission('kalender:create_training'),
|
||||||
trainingController.updateEvent
|
trainingController.updateEvent
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -121,7 +120,7 @@ router.patch(
|
|||||||
router.delete(
|
router.delete(
|
||||||
'/:id',
|
'/:id',
|
||||||
authenticate,
|
authenticate,
|
||||||
requirePermission('training:cancel'),
|
requirePermission('kalender:cancel_training'),
|
||||||
trainingController.cancelEvent
|
trainingController.cancelEvent
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -142,7 +141,7 @@ router.patch(
|
|||||||
router.post(
|
router.post(
|
||||||
'/:id/attendance/mark',
|
'/:id/attendance/mark',
|
||||||
authenticate,
|
authenticate,
|
||||||
requirePermission('training:mark_attendance'),
|
requirePermission('kalender:mark_attendance'),
|
||||||
trainingController.markAttendance
|
trainingController.markAttendance
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -10,19 +10,19 @@ const router = Router();
|
|||||||
router.get('/', authenticate, vehicleController.listVehicles.bind(vehicleController));
|
router.get('/', authenticate, vehicleController.listVehicles.bind(vehicleController));
|
||||||
router.get('/stats', authenticate, vehicleController.getStats.bind(vehicleController));
|
router.get('/stats', authenticate, vehicleController.getStats.bind(vehicleController));
|
||||||
router.get('/alerts', authenticate, vehicleController.getAlerts.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', authenticate, vehicleController.getVehicle.bind(vehicleController));
|
||||||
router.get('/:id/wartung', authenticate, vehicleController.getWartung.bind(vehicleController));
|
router.get('/:id/wartung', authenticate, vehicleController.getWartung.bind(vehicleController));
|
||||||
|
|
||||||
// ── Write — kommandant+ ──────────────────────────────────────────────────────
|
// ── Write — kommandant+ ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
router.post('/', authenticate, requirePermission('vehicles:write'), vehicleController.createVehicle.bind(vehicleController));
|
router.post('/', authenticate, requirePermission('fahrzeuge:create'), vehicleController.createVehicle.bind(vehicleController));
|
||||||
router.patch('/:id', authenticate, requirePermission('vehicles:write'), vehicleController.updateVehicle.bind(vehicleController));
|
router.patch('/:id', authenticate, requirePermission('fahrzeuge:create'), vehicleController.updateVehicle.bind(vehicleController));
|
||||||
router.delete('/:id', authenticate, requirePermission('vehicles:delete'), vehicleController.deleteVehicle.bind(vehicleController));
|
router.delete('/:id', authenticate, requirePermission('fahrzeuge:delete'), vehicleController.deleteVehicle.bind(vehicleController));
|
||||||
|
|
||||||
// ── Status + maintenance log — gruppenfuehrer+ ──────────────────────────────
|
// ── Status + maintenance log — gruppenfuehrer+ ──────────────────────────────
|
||||||
|
|
||||||
router.patch('/:id/status', authenticate, requirePermission('vehicles:status'), vehicleController.updateVehicleStatus.bind(vehicleController));
|
router.patch('/:id/status', authenticate, requirePermission('fahrzeuge:change_status'), vehicleController.updateVehicleStatus.bind(vehicleController));
|
||||||
router.post('/:id/wartung', authenticate, requirePermission('vehicles:status'), vehicleController.addWartung.bind(vehicleController));
|
router.post('/:id/wartung', authenticate, requirePermission('fahrzeuge:change_status'), vehicleController.addWartung.bind(vehicleController));
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import logger from './utils/logger';
|
|||||||
import { testConnection, closePool, runMigrations } from './config/database';
|
import { testConnection, closePool, runMigrations } from './config/database';
|
||||||
import { startAuditCleanupJob, stopAuditCleanupJob } from './jobs/audit-cleanup.job';
|
import { startAuditCleanupJob, stopAuditCleanupJob } from './jobs/audit-cleanup.job';
|
||||||
import { startNotificationJob, stopNotificationJob } from './jobs/notification-generation.job';
|
import { startNotificationJob, stopNotificationJob } from './jobs/notification-generation.job';
|
||||||
|
import { permissionService } from './services/permission.service';
|
||||||
|
|
||||||
const startServer = async (): Promise<void> => {
|
const startServer = async (): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
@@ -16,6 +17,9 @@ const startServer = async (): Promise<void> => {
|
|||||||
} else {
|
} else {
|
||||||
// Run pending migrations automatically on startup
|
// Run pending migrations automatically on startup
|
||||||
await runMigrations();
|
await runMigrations();
|
||||||
|
|
||||||
|
// Load permission cache after migrations
|
||||||
|
await permissionService.loadCache();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start the GDPR IP anonymisation job
|
// Start the GDPR IP anonymisation job
|
||||||
|
|||||||
172
backend/src/services/permission.service.ts
Normal file
172
backend/src/services/permission.service.ts
Normal 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();
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Routes, Route } from 'react-router-dom';
|
import { Routes, Route } from 'react-router-dom';
|
||||||
import { NotificationProvider } from './contexts/NotificationContext';
|
import { NotificationProvider } from './contexts/NotificationContext';
|
||||||
import { AuthProvider } from './contexts/AuthContext';
|
import { AuthProvider } from './contexts/AuthContext';
|
||||||
|
import { PermissionProvider } from './contexts/PermissionContext';
|
||||||
import ErrorBoundary from './components/shared/ErrorBoundary';
|
import ErrorBoundary from './components/shared/ErrorBoundary';
|
||||||
import ProtectedRoute from './components/auth/ProtectedRoute';
|
import ProtectedRoute from './components/auth/ProtectedRoute';
|
||||||
import LoginCallback from './components/auth/LoginCallback';
|
import LoginCallback from './components/auth/LoginCallback';
|
||||||
@@ -34,6 +35,7 @@ function App() {
|
|||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
<NotificationProvider>
|
<NotificationProvider>
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
|
<PermissionProvider>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<Login />} />
|
<Route path="/" element={<Login />} />
|
||||||
<Route path="/login" element={<Login />} />
|
<Route path="/login" element={<Login />} />
|
||||||
@@ -232,6 +234,7 @@ function App() {
|
|||||||
/>
|
/>
|
||||||
<Route path="*" element={<NotFound />} />
|
<Route path="*" element={<NotFound />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
|
</PermissionProvider>
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
</NotificationProvider>
|
</NotificationProvider>
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
|
|||||||
294
frontend/src/components/admin/PermissionMatrixTab.tsx
Normal file
294
frontend/src/components/admin/PermissionMatrixTab.tsx
Normal 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 "dashboard_admin" 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;
|
||||||
@@ -8,16 +8,13 @@ import {
|
|||||||
Button,
|
Button,
|
||||||
Switch,
|
Switch,
|
||||||
FormControlLabel,
|
FormControlLabel,
|
||||||
Skeleton,
|
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
import { CalendarMonth } from '@mui/icons-material';
|
import { CalendarMonth } from '@mui/icons-material';
|
||||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { eventsApi } from '../../services/events';
|
import { eventsApi } from '../../services/events';
|
||||||
import type { CreateVeranstaltungInput } from '../../types/events.types';
|
import type { CreateVeranstaltungInput } from '../../types/events.types';
|
||||||
import { useNotification } from '../../contexts/NotificationContext';
|
import { useNotification } from '../../contexts/NotificationContext';
|
||||||
import { useAuth } from '../../contexts/AuthContext';
|
import { usePermissionContext } from '../../contexts/PermissionContext';
|
||||||
|
|
||||||
const WRITE_GROUPS = ['dashboard_admin', 'dashboard_kommando', 'dashboard_moderator', 'dashboard_gruppenfuehrer'];
|
|
||||||
|
|
||||||
function toDatetimeLocal(date: Date): string {
|
function toDatetimeLocal(date: Date): string {
|
||||||
const pad = (n: number) => String(n).padStart(2, '0');
|
const pad = (n: number) => String(n).padStart(2, '0');
|
||||||
@@ -42,8 +39,8 @@ function makeDefaults() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const EventQuickAddWidget: React.FC = () => {
|
const EventQuickAddWidget: React.FC = () => {
|
||||||
const { user } = useAuth();
|
const { hasPermission } = usePermissionContext();
|
||||||
const canWrite = user?.groups?.some(g => WRITE_GROUPS.includes(g)) ?? false;
|
const canWrite = hasPermission('kalender:create_events');
|
||||||
|
|
||||||
const defaults = makeDefaults();
|
const defaults = makeDefaults();
|
||||||
const [titel, setTitel] = useState('');
|
const [titel, setTitel] = useState('');
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ import {
|
|||||||
import { useNavigate, useLocation } from 'react-router-dom';
|
import { useNavigate, useLocation } from 'react-router-dom';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { useLayout, DRAWER_WIDTH, DRAWER_WIDTH_COLLAPSED } from '../../contexts/LayoutContext';
|
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';
|
import { vehiclesApi } from '../../services/vehicles';
|
||||||
|
|
||||||
export { DRAWER_WIDTH, DRAWER_WIDTH_COLLAPSED };
|
export { DRAWER_WIDTH, DRAWER_WIDTH_COLLAPSED };
|
||||||
@@ -44,6 +44,7 @@ interface NavigationItem {
|
|||||||
icon: JSX.Element;
|
icon: JSX.Element;
|
||||||
path: string;
|
path: string;
|
||||||
subItems?: SubItem[];
|
subItems?: SubItem[];
|
||||||
|
permission?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const kalenderSubItems: SubItem[] = [
|
const kalenderSubItems: SubItem[] = [
|
||||||
@@ -58,6 +59,8 @@ const adminSubItems: SubItem[] = [
|
|||||||
{ text: 'Broadcast', path: '/admin?tab=3' },
|
{ text: 'Broadcast', path: '/admin?tab=3' },
|
||||||
{ text: 'Banner', path: '/admin?tab=4' },
|
{ text: 'Banner', path: '/admin?tab=4' },
|
||||||
{ text: 'Wartung', path: '/admin?tab=5' },
|
{ text: 'Wartung', path: '/admin?tab=5' },
|
||||||
|
{ text: 'FDISK Sync', path: '/admin?tab=6' },
|
||||||
|
{ text: 'Berechtigungen', path: '/admin?tab=7' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const baseNavigationItems: NavigationItem[] = [
|
const baseNavigationItems: NavigationItem[] = [
|
||||||
@@ -71,31 +74,37 @@ const baseNavigationItems: NavigationItem[] = [
|
|||||||
icon: <CalendarMonth />,
|
icon: <CalendarMonth />,
|
||||||
path: '/kalender',
|
path: '/kalender',
|
||||||
subItems: kalenderSubItems,
|
subItems: kalenderSubItems,
|
||||||
|
permission: 'kalender:access',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
text: 'Fahrzeuge',
|
text: 'Fahrzeuge',
|
||||||
icon: <DirectionsCar />,
|
icon: <DirectionsCar />,
|
||||||
path: '/fahrzeuge',
|
path: '/fahrzeuge',
|
||||||
|
permission: 'fahrzeuge:access',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
text: 'Ausrüstung',
|
text: 'Ausrüstung',
|
||||||
icon: <Build />,
|
icon: <Build />,
|
||||||
path: '/ausruestung',
|
path: '/ausruestung',
|
||||||
|
permission: 'ausruestung:access',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
text: 'Mitglieder',
|
text: 'Mitglieder',
|
||||||
icon: <People />,
|
icon: <People />,
|
||||||
path: '/mitglieder',
|
path: '/mitglieder',
|
||||||
|
permission: 'mitglieder:access',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
text: 'Atemschutz',
|
text: 'Atemschutz',
|
||||||
icon: <Air />,
|
icon: <Air />,
|
||||||
path: '/atemschutz',
|
path: '/atemschutz',
|
||||||
|
permission: 'atemschutz:access',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
text: 'Wissen',
|
text: 'Wissen',
|
||||||
icon: <MenuBook />,
|
icon: <MenuBook />,
|
||||||
path: '/wissen',
|
path: '/wissen',
|
||||||
|
permission: 'wissen:access',
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -121,9 +130,7 @@ function Sidebar({ mobileOpen, onMobileClose }: SidebarProps) {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const { sidebarCollapsed, toggleSidebar } = useLayout();
|
const { sidebarCollapsed, toggleSidebar } = useLayout();
|
||||||
const { user } = useAuth();
|
const { hasPermission, isAdmin } = usePermissionContext();
|
||||||
|
|
||||||
const isAdmin = user?.groups?.includes('dashboard_admin') ?? false;
|
|
||||||
|
|
||||||
// Fetch vehicle list for dynamic dropdown sub-items
|
// Fetch vehicle list for dynamic dropdown sub-items
|
||||||
const { data: vehicleList } = useQuery({
|
const { data: vehicleList } = useQuery({
|
||||||
@@ -147,12 +154,13 @@ function Sidebar({ mobileOpen, onMobileClose }: SidebarProps) {
|
|||||||
icon: <DirectionsCar />,
|
icon: <DirectionsCar />,
|
||||||
path: '/fahrzeuge',
|
path: '/fahrzeuge',
|
||||||
subItems: vehicleSubItems.length > 0 ? vehicleSubItems : undefined,
|
subItems: vehicleSubItems.length > 0 ? vehicleSubItems : undefined,
|
||||||
|
permission: 'fahrzeuge:access',
|
||||||
};
|
};
|
||||||
const items = baseNavigationItems.map((item) =>
|
const items = baseNavigationItems
|
||||||
item.path === '/fahrzeuge' ? fahrzeugeItem : item,
|
.map((item) => item.path === '/fahrzeuge' ? fahrzeugeItem : item)
|
||||||
);
|
.filter((item) => !item.permission || hasPermission(item.permission));
|
||||||
return isAdmin ? [...items, adminItem, adminSettingsItem] : items;
|
return isAdmin ? [...items, adminItem, adminSettingsItem] : items;
|
||||||
}, [isAdmin, vehicleSubItems]);
|
}, [isAdmin, vehicleSubItems, hasPermission]);
|
||||||
|
|
||||||
// Expand state for items with sub-items — auto-expand when route matches
|
// Expand state for items with sub-items — auto-expand when route matches
|
||||||
const [expandedItems, setExpandedItems] = useState<Record<string, boolean>>({});
|
const [expandedItems, setExpandedItems] = useState<Record<string, boolean>>({});
|
||||||
|
|||||||
98
frontend/src/contexts/PermissionContext.tsx
Normal file
98
frontend/src/contexts/PermissionContext.tsx
Normal 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;
|
||||||
|
};
|
||||||
@@ -1,27 +1,30 @@
|
|||||||
import { useAuth } from '../contexts/AuthContext';
|
import { usePermissionContext } from '../contexts/PermissionContext';
|
||||||
import { AusruestungKategorie } from '../types/equipment.types';
|
import { AusruestungKategorie } from '../types/equipment.types';
|
||||||
|
|
||||||
export function usePermissions() {
|
export function usePermissions() {
|
||||||
const { user } = useAuth();
|
const { hasPermission, hasAnyPermission, isAdmin, isFeatureEnabled, isLoading, permissions, maintenance, refetch } = usePermissionContext();
|
||||||
const groups = user?.groups ?? [];
|
|
||||||
|
|
||||||
const isAdmin = groups.includes('dashboard_admin');
|
|
||||||
const isFahrmeister = groups.includes('dashboard_fahrmeister');
|
|
||||||
const isZeugmeister = groups.includes('dashboard_zeugmeister');
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
// Core API
|
||||||
|
hasPermission,
|
||||||
|
hasAnyPermission,
|
||||||
|
isFeatureEnabled,
|
||||||
isAdmin,
|
isAdmin,
|
||||||
isFahrmeister,
|
isLoading,
|
||||||
isZeugmeister,
|
permissions,
|
||||||
canChangeStatus: isAdmin || isFahrmeister || isZeugmeister,
|
maintenance,
|
||||||
canManageEquipment: isAdmin || isFahrmeister || isZeugmeister,
|
refetch,
|
||||||
canManageMotorizedEquipment: isAdmin || isFahrmeister,
|
|
||||||
canManageNonMotorizedEquipment: isAdmin || isZeugmeister,
|
// Backward-compatible convenience flags
|
||||||
canManageCategory: (kategorie: AusruestungKategorie | null | undefined): boolean => {
|
isFahrmeister: false, // No longer needed — use hasPermission() instead
|
||||||
if (isAdmin) return true;
|
isZeugmeister: false, // No longer needed — use hasPermission() instead
|
||||||
if (!kategorie) return false;
|
canChangeStatus: hasPermission('fahrzeuge:change_status'),
|
||||||
return kategorie.motorisiert ? isFahrmeister : isZeugmeister;
|
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
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import NotificationBroadcastTab from '../components/admin/NotificationBroadcastT
|
|||||||
import BannerManagementTab from '../components/admin/BannerManagementTab';
|
import BannerManagementTab from '../components/admin/BannerManagementTab';
|
||||||
import ServiceModeTab from '../components/admin/ServiceModeTab';
|
import ServiceModeTab from '../components/admin/ServiceModeTab';
|
||||||
import FdiskSyncTab from '../components/admin/FdiskSyncTab';
|
import FdiskSyncTab from '../components/admin/FdiskSyncTab';
|
||||||
|
import PermissionMatrixTab from '../components/admin/PermissionMatrixTab';
|
||||||
import { useAuth } from '../contexts/AuthContext';
|
import { useAuth } from '../contexts/AuthContext';
|
||||||
|
|
||||||
interface TabPanelProps {
|
interface TabPanelProps {
|
||||||
@@ -22,7 +23,7 @@ function TabPanel({ children, value, index }: TabPanelProps) {
|
|||||||
return <Box sx={{ pt: 3 }}>{children}</Box>;
|
return <Box sx={{ pt: 3 }}>{children}</Box>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ADMIN_TAB_COUNT = 7;
|
const ADMIN_TAB_COUNT = 8;
|
||||||
|
|
||||||
function AdminDashboard() {
|
function AdminDashboard() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -57,6 +58,7 @@ function AdminDashboard() {
|
|||||||
<Tab label="Banner" />
|
<Tab label="Banner" />
|
||||||
<Tab label="Wartung" />
|
<Tab label="Wartung" />
|
||||||
<Tab label="FDISK Sync" />
|
<Tab label="FDISK Sync" />
|
||||||
|
<Tab label="Berechtigungen" />
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
@@ -81,6 +83,9 @@ function AdminDashboard() {
|
|||||||
<TabPanel value={tab} index={6}>
|
<TabPanel value={tab} index={6}>
|
||||||
<FdiskSyncTab />
|
<FdiskSyncTab />
|
||||||
</TabPanel>
|
</TabPanel>
|
||||||
|
<TabPanel value={tab} index={7}>
|
||||||
|
<PermissionMatrixTab />
|
||||||
|
</TabPanel>
|
||||||
</DashboardLayout>
|
</DashboardLayout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ import ChatAwareFab from '../components/shared/ChatAwareFab';
|
|||||||
import { atemschutzApi } from '../services/atemschutz';
|
import { atemschutzApi } from '../services/atemschutz';
|
||||||
import { membersService } from '../services/members';
|
import { membersService } from '../services/members';
|
||||||
import { useNotification } from '../contexts/NotificationContext';
|
import { useNotification } from '../contexts/NotificationContext';
|
||||||
import { useAuth } from '../contexts/AuthContext';
|
import { usePermissionContext } from '../contexts/PermissionContext';
|
||||||
import { toGermanDate, fromGermanDate, isValidGermanDate } from '../utils/dateInput';
|
import { toGermanDate, fromGermanDate, isValidGermanDate } from '../utils/dateInput';
|
||||||
import type {
|
import type {
|
||||||
AtemschutzUebersicht,
|
AtemschutzUebersicht,
|
||||||
@@ -142,10 +142,9 @@ const StatCard: React.FC<StatCardProps> = ({ label, value, color, bgcolor }) =>
|
|||||||
|
|
||||||
function Atemschutz() {
|
function Atemschutz() {
|
||||||
const notification = useNotification();
|
const notification = useNotification();
|
||||||
const { user } = useAuth();
|
const { hasPermission } = usePermissionContext();
|
||||||
const ATEMSCHUTZ_PRIVILEGED = ['dashboard_admin', 'dashboard_kommando', 'dashboard_atemschutz', 'dashboard_moderator'];
|
const canViewAll = hasPermission('atemschutz:view');
|
||||||
const canViewAll = user?.groups?.some(g => ATEMSCHUTZ_PRIVILEGED.includes(g)) ?? false;
|
const canWrite = hasPermission('atemschutz:create');
|
||||||
const canWrite = canViewAll;
|
|
||||||
|
|
||||||
// Data state
|
// Data state
|
||||||
const [traeger, setTraeger] = useState<AtemschutzUebersicht[]>([]);
|
const [traeger, setTraeger] = useState<AtemschutzUebersicht[]>([]);
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { useAuth } from '../contexts/AuthContext';
|
import { useAuth } from '../contexts/AuthContext';
|
||||||
|
import { usePermissionContext } from '../contexts/PermissionContext';
|
||||||
import DashboardLayout from '../components/dashboard/DashboardLayout';
|
import DashboardLayout from '../components/dashboard/DashboardLayout';
|
||||||
import SkeletonCard from '../components/shared/SkeletonCard';
|
import SkeletonCard from '../components/shared/SkeletonCard';
|
||||||
import UserProfile from '../components/dashboard/UserProfile';
|
import UserProfile from '../components/dashboard/UserProfile';
|
||||||
@@ -33,13 +34,7 @@ import { WidgetKey } from '../constants/widgets';
|
|||||||
|
|
||||||
function Dashboard() {
|
function Dashboard() {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const isAdmin = user?.groups?.includes('dashboard_admin') ?? false;
|
const { hasPermission, isAdmin } = usePermissionContext();
|
||||||
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 [dataLoading, setDataLoading] = useState(true);
|
const [dataLoading, setDataLoading] = useState(true);
|
||||||
|
|
||||||
const { data: preferences } = useQuery({
|
const { data: preferences } = useQuery({
|
||||||
@@ -120,7 +115,7 @@ function Dashboard() {
|
|||||||
</Fade>
|
</Fade>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{canViewAtemschutz && widgetVisible('atemschutz') && (
|
{hasPermission('atemschutz:widget') && widgetVisible('atemschutz') && (
|
||||||
<Fade in={!dataLoading} timeout={600} style={{ transitionDelay: '400ms' }}>
|
<Fade in={!dataLoading} timeout={600} style={{ transitionDelay: '400ms' }}>
|
||||||
<Box>
|
<Box>
|
||||||
<AtemschutzDashboardCard />
|
<AtemschutzDashboardCard />
|
||||||
@@ -163,7 +158,7 @@ function Dashboard() {
|
|||||||
</Fade>
|
</Fade>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{canWrite && widgetVisible('eventQuickAdd') && (
|
{hasPermission('kalender:widget_quick_add') && widgetVisible('eventQuickAdd') && (
|
||||||
<Fade in={!dataLoading} timeout={600} style={{ transitionDelay: '600ms' }}>
|
<Fade in={!dataLoading} timeout={600} style={{ transitionDelay: '600ms' }}>
|
||||||
<Box>
|
<Box>
|
||||||
<EventQuickAddWidget />
|
<EventQuickAddWidget />
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ import {
|
|||||||
EINSATZ_STATUS_LABELS,
|
EINSATZ_STATUS_LABELS,
|
||||||
} from '../services/incidents';
|
} from '../services/incidents';
|
||||||
import CreateEinsatzDialog from '../components/incidents/CreateEinsatzDialog';
|
import CreateEinsatzDialog from '../components/incidents/CreateEinsatzDialog';
|
||||||
import { useAuth } from '../contexts/AuthContext';
|
import { usePermissionContext } from '../contexts/PermissionContext';
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// COLOUR MAP for Einsatzart chips
|
// COLOUR MAP for Einsatzart chips
|
||||||
@@ -176,10 +176,8 @@ function StatsSummaryBar({ stats, loading }: StatsSummaryProps) {
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
function Einsaetze() {
|
function Einsaetze() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { user } = useAuth();
|
const { hasPermission } = usePermissionContext();
|
||||||
const canWrite = user?.groups?.some((g: string) =>
|
const canWrite = hasPermission('einsaetze:create');
|
||||||
['dashboard_admin', 'dashboard_kommando', 'dashboard_gruppenfuehrer'].includes(g)
|
|
||||||
) ?? false;
|
|
||||||
|
|
||||||
// List state
|
// List state
|
||||||
const [items, setItems] = useState<EinsatzListItem[]>([]);
|
const [items, setItems] = useState<EinsatzListItem[]>([]);
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ import {
|
|||||||
EinsatzArt,
|
EinsatzArt,
|
||||||
} from '../services/incidents';
|
} from '../services/incidents';
|
||||||
import { useNotification } from '../contexts/NotificationContext';
|
import { useNotification } from '../contexts/NotificationContext';
|
||||||
import { useAuth } from '../contexts/AuthContext';
|
import { usePermissionContext } from '../contexts/PermissionContext';
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// COLOUR MAPS
|
// COLOUR MAPS
|
||||||
@@ -165,10 +165,8 @@ function EinsatzDetail() {
|
|||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const notification = useNotification();
|
const notification = useNotification();
|
||||||
const { user } = useAuth();
|
const { hasPermission } = usePermissionContext();
|
||||||
const canWrite = user?.groups?.some((g: string) =>
|
const canWrite = hasPermission('einsaetze:create');
|
||||||
['dashboard_admin', 'dashboard_kommando', 'dashboard_gruppenfuehrer'].includes(g)
|
|
||||||
) ?? false;
|
|
||||||
|
|
||||||
const [einsatz, setEinsatz] = useState<EinsatzDetailType | null>(null);
|
const [einsatz, setEinsatz] = useState<EinsatzDetailType | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ import {
|
|||||||
import DashboardLayout from '../components/dashboard/DashboardLayout';
|
import DashboardLayout from '../components/dashboard/DashboardLayout';
|
||||||
import ChatAwareFab from '../components/shared/ChatAwareFab';
|
import ChatAwareFab from '../components/shared/ChatAwareFab';
|
||||||
import { useAuth } from '../contexts/AuthContext';
|
import { useAuth } from '../contexts/AuthContext';
|
||||||
|
import { usePermissionContext } from '../contexts/PermissionContext';
|
||||||
import { useNotification } from '../contexts/NotificationContext';
|
import { useNotification } from '../contexts/NotificationContext';
|
||||||
import { bookingApi, fetchVehicles } from '../services/bookings';
|
import { bookingApi, fetchVehicles } from '../services/bookings';
|
||||||
import type {
|
import type {
|
||||||
@@ -85,21 +86,17 @@ const EMPTY_FORM: CreateBuchungInput = {
|
|||||||
kontaktTelefon: '',
|
kontaktTelefon: '',
|
||||||
};
|
};
|
||||||
|
|
||||||
const WRITE_GROUPS = ['dashboard_admin', 'dashboard_fahrmeister', 'dashboard_moderator'];
|
|
||||||
const MANAGE_ART_GROUPS = ['dashboard_admin', 'dashboard_moderator'];
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Main Page
|
// Main Page
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
function FahrzeugBuchungen() {
|
function FahrzeugBuchungen() {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
|
const { hasPermission } = usePermissionContext();
|
||||||
const notification = useNotification();
|
const notification = useNotification();
|
||||||
const canCreate = !!user; // All authenticated users can create bookings
|
const canCreate = hasPermission('kalender:create_bookings');
|
||||||
const canWrite =
|
const canWrite = hasPermission('kalender:edit_bookings');
|
||||||
user?.groups?.some((g) => WRITE_GROUPS.includes(g)) ?? false; // Can edit/cancel
|
const canChangeBuchungsArt = hasPermission('kalender:manage_categories');
|
||||||
const canChangeBuchungsArt =
|
|
||||||
user?.groups?.some((g) => MANAGE_ART_GROUPS.includes(g)) ?? false; // Can change booking type
|
|
||||||
|
|
||||||
// ── Week navigation ────────────────────────────────────────────────────────
|
// ── Week navigation ────────────────────────────────────────────────────────
|
||||||
const [currentWeekStart, setCurrentWeekStart] = useState<Date>(() =>
|
const [currentWeekStart, setCurrentWeekStart] = useState<Date>(() =>
|
||||||
|
|||||||
@@ -72,6 +72,7 @@ import DashboardLayout from '../components/dashboard/DashboardLayout';
|
|||||||
import ChatAwareFab from '../components/shared/ChatAwareFab';
|
import ChatAwareFab from '../components/shared/ChatAwareFab';
|
||||||
import { toGermanDateTime, fromGermanDate, fromGermanDateTime, isValidGermanDateTime } from '../utils/dateInput';
|
import { toGermanDateTime, fromGermanDate, fromGermanDateTime, isValidGermanDateTime } from '../utils/dateInput';
|
||||||
import { useAuth } from '../contexts/AuthContext';
|
import { useAuth } from '../contexts/AuthContext';
|
||||||
|
import { usePermissionContext } from '../contexts/PermissionContext';
|
||||||
import { useNotification } from '../contexts/NotificationContext';
|
import { useNotification } from '../contexts/NotificationContext';
|
||||||
import { trainingApi } from '../services/training';
|
import { trainingApi } from '../services/training';
|
||||||
import { eventsApi } from '../services/events';
|
import { eventsApi } from '../services/events';
|
||||||
@@ -117,9 +118,6 @@ import { de } from 'date-fns/locale';
|
|||||||
// Constants
|
// 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 WEEKDAY_LABELS = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'];
|
||||||
const MONTH_LABELS = [
|
const MONTH_LABELS = [
|
||||||
'Januar', 'Februar', 'März', 'April', 'Mai', 'Juni',
|
'Januar', 'Februar', 'März', 'April', 'Mai', 'Juni',
|
||||||
@@ -1704,15 +1702,14 @@ export default function Kalender() {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
|
const { hasPermission } = usePermissionContext();
|
||||||
const notification = useNotification();
|
const notification = useNotification();
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const isMobile = useMediaQuery(theme.breakpoints.down('sm'));
|
const isMobile = useMediaQuery(theme.breakpoints.down('sm'));
|
||||||
|
|
||||||
const canWriteEvents =
|
const canWriteEvents = hasPermission('kalender:create_events');
|
||||||
user?.groups?.some((g) => WRITE_GROUPS_EVENTS.includes(g)) ?? false;
|
const canWriteBookings = hasPermission('kalender:edit_bookings');
|
||||||
const canWriteBookings =
|
const canCreateBookings = hasPermission('kalender:create_bookings');
|
||||||
user?.groups?.some((g) => WRITE_GROUPS_BOOKINGS.includes(g)) ?? false;
|
|
||||||
const canCreateBookings = !!user;
|
|
||||||
|
|
||||||
// ── Tab ─────────────────────────────────────────────────────────────────────
|
// ── Tab ─────────────────────────────────────────────────────────────────────
|
||||||
const [activeTab, setActiveTab] = useState(() => {
|
const [activeTab, setActiveTab] = useState(() => {
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ import {
|
|||||||
import { useParams, useNavigate } from 'react-router-dom';
|
import { useParams, useNavigate } from 'react-router-dom';
|
||||||
import DashboardLayout from '../components/dashboard/DashboardLayout';
|
import DashboardLayout from '../components/dashboard/DashboardLayout';
|
||||||
import { useAuth } from '../contexts/AuthContext';
|
import { useAuth } from '../contexts/AuthContext';
|
||||||
|
import { usePermissionContext } from '../contexts/PermissionContext';
|
||||||
import { membersService } from '../services/members';
|
import { membersService } from '../services/members';
|
||||||
import { atemschutzApi } from '../services/atemschutz';
|
import { atemschutzApi } from '../services/atemschutz';
|
||||||
import { toGermanDate, fromGermanDate } from '../utils/dateInput';
|
import { toGermanDate, fromGermanDate } from '../utils/dateInput';
|
||||||
@@ -67,9 +68,8 @@ import { UntersuchungErgebnisLabel } from '../types/atemschutz.types';
|
|||||||
// Role helpers
|
// Role helpers
|
||||||
// ----------------------------------------------------------------
|
// ----------------------------------------------------------------
|
||||||
function useCanWrite(): boolean {
|
function useCanWrite(): boolean {
|
||||||
const { user } = useAuth();
|
const { hasPermission } = usePermissionContext();
|
||||||
const groups: string[] = (user as any)?.groups ?? [];
|
return hasPermission('mitglieder:edit');
|
||||||
return groups.includes('dashboard_admin') || groups.includes('dashboard_kommando');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function useCurrentUserId(): string | undefined {
|
function useCurrentUserId(): string | undefined {
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ import { useNavigate } from 'react-router-dom';
|
|||||||
import DashboardLayout from '../components/dashboard/DashboardLayout';
|
import DashboardLayout from '../components/dashboard/DashboardLayout';
|
||||||
import ChatAwareFab from '../components/shared/ChatAwareFab';
|
import ChatAwareFab from '../components/shared/ChatAwareFab';
|
||||||
import { useAuth } from '../contexts/AuthContext';
|
import { useAuth } from '../contexts/AuthContext';
|
||||||
|
import { usePermissionContext } from '../contexts/PermissionContext';
|
||||||
import { membersService } from '../services/members';
|
import { membersService } from '../services/members';
|
||||||
import {
|
import {
|
||||||
MemberListItem,
|
MemberListItem,
|
||||||
@@ -51,9 +52,8 @@ import {
|
|||||||
// Helper: determine whether the current user can write member data
|
// Helper: determine whether the current user can write member data
|
||||||
// ----------------------------------------------------------------
|
// ----------------------------------------------------------------
|
||||||
function useCanWrite(): boolean {
|
function useCanWrite(): boolean {
|
||||||
const { user } = useAuth();
|
const { hasPermission } = usePermissionContext();
|
||||||
const groups: string[] = (user as any)?.groups ?? [];
|
return hasPermission('mitglieder:edit');
|
||||||
return groups.includes('dashboard_admin') || groups.includes('dashboard_kommando');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ----------------------------------------------------------------
|
// ----------------------------------------------------------------
|
||||||
@@ -73,17 +73,17 @@ function useDebounce<T>(value: T, delay: number): T {
|
|||||||
// ----------------------------------------------------------------
|
// ----------------------------------------------------------------
|
||||||
function Mitglieder() {
|
function Mitglieder() {
|
||||||
const navigate = useNavigate();
|
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(() => {
|
useEffect(() => {
|
||||||
if (!user) return;
|
if (!user) return;
|
||||||
const groups: string[] = (user as any)?.groups ?? [];
|
if (!hasPermission('mitglieder:edit')) {
|
||||||
const isAdmin = groups.includes('dashboard_admin') || groups.includes('dashboard_kommando');
|
|
||||||
if (!isAdmin) {
|
|
||||||
navigate(`/mitglieder/${(user as any).id}`, { replace: true });
|
navigate(`/mitglieder/${(user as any).id}`, { replace: true });
|
||||||
}
|
}
|
||||||
}, [user, navigate]);
|
}, [user, navigate, hasPermission]);
|
||||||
|
|
||||||
// --- data state ---
|
// --- data state ---
|
||||||
const [members, setMembers] = useState<MemberListItem[]>([]);
|
const [members, setMembers] = useState<MemberListItem[]>([]);
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ import {
|
|||||||
Category as CategoryIcon,
|
Category as CategoryIcon,
|
||||||
} from '@mui/icons-material';
|
} from '@mui/icons-material';
|
||||||
import DashboardLayout from '../components/dashboard/DashboardLayout';
|
import DashboardLayout from '../components/dashboard/DashboardLayout';
|
||||||
import { useAuth } from '../contexts/AuthContext';
|
import { usePermissionContext } from '../contexts/PermissionContext';
|
||||||
import { useNotification } from '../contexts/NotificationContext';
|
import { useNotification } from '../contexts/NotificationContext';
|
||||||
import { eventsApi } from '../services/events';
|
import { eventsApi } from '../services/events';
|
||||||
import type { VeranstaltungKategorie, GroupInfo } from '../types/events.types';
|
import type { VeranstaltungKategorie, GroupInfo } from '../types/events.types';
|
||||||
@@ -298,10 +298,9 @@ function DeleteDialog({ open, kategorie, onClose, onDeleted }: DeleteDialogProps
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
export default function VeranstaltungKategorien() {
|
export default function VeranstaltungKategorien() {
|
||||||
const { user } = useAuth();
|
const { hasPermission } = usePermissionContext();
|
||||||
|
|
||||||
const canManage =
|
const canManage = hasPermission('kalender:manage_categories');
|
||||||
user?.groups?.some((g) => ['dashboard_admin', 'dashboard_moderator'].includes(g)) ?? false;
|
|
||||||
|
|
||||||
const [kategorien, setKategorien] = useState<VeranstaltungKategorie[]>([]);
|
const [kategorien, setKategorien] = useState<VeranstaltungKategorie[]>([]);
|
||||||
const [groups, setGroups] = useState<GroupInfo[]>([]);
|
const [groups, setGroups] = useState<GroupInfo[]>([]);
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ import {
|
|||||||
import DashboardLayout from '../components/dashboard/DashboardLayout';
|
import DashboardLayout from '../components/dashboard/DashboardLayout';
|
||||||
import ChatAwareFab from '../components/shared/ChatAwareFab';
|
import ChatAwareFab from '../components/shared/ChatAwareFab';
|
||||||
import { toGermanDate, toGermanDateTime, fromGermanDate, fromGermanDateTime } from '../utils/dateInput';
|
import { toGermanDate, toGermanDateTime, fromGermanDate, fromGermanDateTime } from '../utils/dateInput';
|
||||||
import { useAuth } from '../contexts/AuthContext';
|
import { usePermissionContext } from '../contexts/PermissionContext';
|
||||||
import { useNotification } from '../contexts/NotificationContext';
|
import { useNotification } from '../contexts/NotificationContext';
|
||||||
import { eventsApi } from '../services/events';
|
import { eventsApi } from '../services/events';
|
||||||
import type {
|
import type {
|
||||||
@@ -1069,13 +1069,12 @@ function EventListView({ events, canWrite, onEdit, onCancel, onDelete }: ListVie
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
export default function Veranstaltungen() {
|
export default function Veranstaltungen() {
|
||||||
const { user } = useAuth();
|
const { hasPermission } = usePermissionContext();
|
||||||
const notification = useNotification();
|
const notification = useNotification();
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const isMobile = useMediaQuery(theme.breakpoints.down('sm'));
|
const isMobile = useMediaQuery(theme.breakpoints.down('sm'));
|
||||||
|
|
||||||
const canWrite =
|
const canWrite = hasPermission('kalender:create_events');
|
||||||
user?.groups?.some((g) => ['dashboard_admin', 'dashboard_moderator'].includes(g)) ?? false;
|
|
||||||
|
|
||||||
const today = new Date();
|
const today = new Date();
|
||||||
const [viewMonth, setViewMonth] = useState({ year: today.getFullYear(), month: today.getMonth() });
|
const [viewMonth, setViewMonth] = useState({ year: today.getFullYear(), month: today.getMonth() });
|
||||||
|
|||||||
27
frontend/src/services/permissions.ts
Normal file
27
frontend/src/services/permissions.ts
Normal 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 });
|
||||||
|
},
|
||||||
|
};
|
||||||
28
frontend/src/types/permissions.types.ts
Normal file
28
frontend/src/types/permissions.types.ts
Normal 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>;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user