From a575b61d262b272d6ed8b5e06e11875a3ae0462a Mon Sep 17 00:00:00 2001 From: Matthias Hochmeister Date: Mon, 23 Mar 2026 12:00:09 +0100 Subject: [PATCH] rights system --- .../src/controllers/permission.controller.ts | 46 ++ backend/src/routes/permission.routes.ts | 2 + backend/src/services/permission.service.ts | 117 +++- .../components/admin/PermissionMatrixTab.tsx | 607 +++++++++--------- frontend/src/services/permissions.ts | 15 + 5 files changed, 476 insertions(+), 311 deletions(-) diff --git a/backend/src/controllers/permission.controller.ts b/backend/src/controllers/permission.controller.ts index a0e6fc0..dc3b0fc 100644 --- a/backend/src/controllers/permission.controller.ts +++ b/backend/src/controllers/permission.controller.ts @@ -154,6 +154,52 @@ class PermissionController { res.status(500).json({ success: false, message: 'Fehler beim Setzen des Wartungsmodus' }); } } + + /** + * GET /api/admin/permissions/config + * Returns the dependency configuration (group hierarchy + permission deps). + */ + async getDependencyConfig(_req: Request, res: Response): Promise { + try { + const config = await permissionService.getDependencyConfig(); + res.json({ success: true, data: config }); + } catch (error) { + logger.error('Failed to get dependency config', { error }); + res.status(500).json({ success: false, message: 'Fehler beim Laden der Konfiguration' }); + } + } + + /** + * PUT /api/admin/permissions/config + * Updates the dependency configuration. + * Body: { groupHierarchy?: Record, permissionDeps?: Record } + */ + async setDependencyConfig(req: Request, res: Response): Promise { + try { + const { groupHierarchy, permissionDeps } = req.body; + + if (groupHierarchy !== undefined) { + if (typeof groupHierarchy !== 'object' || groupHierarchy === null) { + res.status(400).json({ success: false, message: 'groupHierarchy must be an object' }); + return; + } + await permissionService.setGroupHierarchy(groupHierarchy, req.user!.id); + } + + if (permissionDeps !== undefined) { + if (typeof permissionDeps !== 'object' || permissionDeps === null) { + res.status(400).json({ success: false, message: 'permissionDeps must be an object' }); + return; + } + await permissionService.setPermissionDeps(permissionDeps, req.user!.id); + } + + res.json({ success: true, message: 'Konfiguration aktualisiert' }); + } catch (error) { + logger.error('Failed to set dependency config', { error }); + res.status(500).json({ success: false, message: 'Fehler beim Speichern der Konfiguration' }); + } + } } export default new PermissionController(); diff --git a/backend/src/routes/permission.routes.ts b/backend/src/routes/permission.routes.ts index 2c8918f..d24f48c 100644 --- a/backend/src/routes/permission.routes.ts +++ b/backend/src/routes/permission.routes.ts @@ -12,6 +12,8 @@ router.get('/me', authenticate, permissionController.getMyPermissions.bind(permi router.get('/admin/matrix', authenticate, requirePermission('admin:view'), permissionController.getMatrix.bind(permissionController)); router.get('/admin/groups', authenticate, requirePermission('admin:view'), permissionController.getGroups.bind(permissionController)); router.get('/admin/unknown-groups', authenticate, requirePermission('admin:view'), permissionController.getUnknownGroups.bind(permissionController)); +router.get('/admin/config', authenticate, requirePermission('admin:view'), permissionController.getDependencyConfig.bind(permissionController)); +router.put('/admin/config', authenticate, requirePermission('admin:write'), permissionController.setDependencyConfig.bind(permissionController)); router.put('/admin/group/:groupName', authenticate, requirePermission('admin:write'), permissionController.setGroupPermissions.bind(permissionController)); router.put('/admin/bulk', authenticate, requirePermission('admin:write'), permissionController.setBulkPermissions.bind(permissionController)); router.put('/admin/maintenance/:featureGroupId', authenticate, requirePermission('admin:write'), permissionController.setMaintenanceFlag.bind(permissionController)); diff --git a/backend/src/services/permission.service.ts b/backend/src/services/permission.service.ts index d5f5d13..ce68c50 100644 --- a/backend/src/services/permission.service.ts +++ b/backend/src/services/permission.service.ts @@ -1,6 +1,55 @@ import pool from '../config/database'; import logger from '../utils/logger'; +// Default configs — used when no DB config exists yet +const DEFAULT_GROUP_HIERARCHY: Record = { + 'dashboard_mitglied': ['dashboard_chargen', 'dashboard_atemschutz', 'dashboard_moderator', 'dashboard_zeugmeister', 'dashboard_fahrmeister', 'dashboard_gruppenfuehrer', 'dashboard_kommando'], + 'dashboard_chargen': ['dashboard_gruppenfuehrer', 'dashboard_kommando'], + 'dashboard_atemschutz': ['dashboard_kommando'], + 'dashboard_moderator': ['dashboard_kommando'], + 'dashboard_zeugmeister': ['dashboard_kommando'], + 'dashboard_fahrmeister': ['dashboard_kommando'], + 'dashboard_gruppenfuehrer': ['dashboard_kommando'], + 'dashboard_kommando': [], +}; + +const DEFAULT_PERMISSION_DEPS: Record = { + 'kalender:create': ['kalender:view'], + 'kalender:cancel': ['kalender:view'], + 'kalender:mark_attendance': ['kalender:view'], + 'kalender:create_bookings': ['kalender:view'], + 'kalender:edit_bookings': ['kalender:view', 'kalender:create_bookings'], + 'kalender:cancel_own_bookings': ['kalender:view'], + 'kalender:delete_bookings': ['kalender:view', 'kalender:edit_bookings'], + 'kalender:manage_categories': ['kalender:view'], + 'kalender:view_reports': ['kalender:view'], + 'kalender:widget_events': ['kalender:view'], + 'kalender:widget_bookings': ['kalender:view'], + 'kalender:widget_quick_add': ['kalender:view', 'kalender:create'], + 'fahrzeuge:create': ['fahrzeuge:view'], + 'fahrzeuge:change_status': ['fahrzeuge:view'], + 'fahrzeuge:manage_maintenance': ['fahrzeuge:view'], + 'fahrzeuge:delete': ['fahrzeuge:view', 'fahrzeuge:create'], + 'fahrzeuge:widget': ['fahrzeuge:view'], + 'einsaetze:view_reports': ['einsaetze:view'], + 'einsaetze:create': ['einsaetze:view'], + 'einsaetze:delete': ['einsaetze:view', 'einsaetze:create'], + 'einsaetze:manage_personnel': ['einsaetze:view'], + 'ausruestung:create': ['ausruestung:view'], + 'ausruestung:manage_maintenance': ['ausruestung:view'], + 'ausruestung:delete': ['ausruestung:view', 'ausruestung:create'], + 'ausruestung:widget': ['ausruestung:view'], + 'mitglieder:view_all': ['mitglieder:view_own'], + 'mitglieder:edit': ['mitglieder:view_own', 'mitglieder:view_all'], + 'mitglieder:create_profile': ['mitglieder:view_own', 'mitglieder:view_all', 'mitglieder:edit'], + 'atemschutz:create': ['atemschutz:view'], + 'atemschutz:delete': ['atemschutz:view', 'atemschutz:create'], + 'atemschutz:widget': ['atemschutz:view'], + 'wissen:widget_recent': ['wissen:view'], + 'wissen:widget_search': ['wissen:view'], + 'admin:write': ['admin:view'], +}; + export interface FeatureGroupRow { id: string; label: string; @@ -255,10 +304,76 @@ class PermissionService { 'UPDATE feature_groups SET maintenance = $1 WHERE id = $2', [active, featureGroup] ); - // Reload cache await this.loadCache(); logger.info('Maintenance flag updated', { featureGroup, active }); } + + // ── Dependency config (stored in app_settings) ── + + async getGroupHierarchy(): Promise> { + try { + const result = await pool.query( + "SELECT value FROM app_settings WHERE key = 'permission_group_hierarchy'" + ); + if (result.rows.length > 0 && result.rows[0].value) { + const val = typeof result.rows[0].value === 'string' + ? JSON.parse(result.rows[0].value) + : result.rows[0].value; + return val; + } + } catch (error) { + logger.warn('Failed to load group hierarchy from DB, using defaults', { error }); + } + return DEFAULT_GROUP_HIERARCHY; + } + + async setGroupHierarchy(hierarchy: Record, userId: string): Promise { + await pool.query( + `INSERT INTO app_settings (key, value, updated_by, updated_at) + VALUES ('permission_group_hierarchy', $1, $2, NOW()) + ON CONFLICT (key) DO UPDATE SET value = $1, updated_by = $2, updated_at = NOW()`, + [JSON.stringify(hierarchy), userId] + ); + logger.info('Group hierarchy updated', { userId }); + } + + async getPermissionDeps(): Promise> { + try { + const result = await pool.query( + "SELECT value FROM app_settings WHERE key = 'permission_deps'" + ); + if (result.rows.length > 0 && result.rows[0].value) { + const val = typeof result.rows[0].value === 'string' + ? JSON.parse(result.rows[0].value) + : result.rows[0].value; + return val; + } + } catch (error) { + logger.warn('Failed to load permission deps from DB, using defaults', { error }); + } + return DEFAULT_PERMISSION_DEPS; + } + + async setPermissionDeps(deps: Record, userId: string): Promise { + await pool.query( + `INSERT INTO app_settings (key, value, updated_by, updated_at) + VALUES ('permission_deps', $1, $2, NOW()) + ON CONFLICT (key) DO UPDATE SET value = $1, updated_by = $2, updated_at = NOW()`, + [JSON.stringify(deps), userId] + ); + logger.info('Permission deps updated', { userId }); + } + + async getDependencyConfig(): Promise<{ + groupHierarchy: Record; + permissionDeps: Record; + }> { + const [groupHierarchy, permissionDeps] = await Promise.all([ + this.getGroupHierarchy(), + this.getPermissionDeps(), + ]); + return { groupHierarchy, permissionDeps }; + } } export const permissionService = new PermissionService(); diff --git a/frontend/src/components/admin/PermissionMatrixTab.tsx b/frontend/src/components/admin/PermissionMatrixTab.tsx index 4fcb6fa..742c143 100644 --- a/frontend/src/components/admin/PermissionMatrixTab.tsx +++ b/frontend/src/components/admin/PermissionMatrixTab.tsx @@ -1,6 +1,7 @@ import React, { useState, useCallback, useMemo } from 'react'; import { Alert, + Autocomplete, Box, Button, Card, @@ -18,107 +19,29 @@ import { TableContainer, TableHead, TableRow, + TextField, Tooltip, Typography, } from '@mui/material'; -import { ExpandMore, ExpandLess, Add as AddIcon } from '@mui/icons-material'; +import { ExpandMore, ExpandLess, Add as AddIcon, Delete as DeleteIcon } from '@mui/icons-material'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { permissionsApi } from '../../services/permissions'; import { useNotification } from '../../contexts/NotificationContext'; import type { PermissionMatrix, FeatureGroup, Permission } from '../../types/permissions.types'; -// ── Permission dependency map ── -// Each permission lists its prerequisites (must also be granted). -const PERMISSION_DEPS: Record = { - // kalender - 'kalender:create': ['kalender:view'], - 'kalender:cancel': ['kalender:view'], - 'kalender:mark_attendance': ['kalender:view'], - 'kalender:create_bookings': ['kalender:view'], - 'kalender:edit_bookings': ['kalender:view', 'kalender:create_bookings'], - 'kalender:cancel_own_bookings': ['kalender:view'], - 'kalender:delete_bookings': ['kalender:view', 'kalender:edit_bookings'], - 'kalender:manage_categories': ['kalender:view'], - 'kalender:view_reports': ['kalender:view'], - 'kalender:widget_events': ['kalender:view'], - 'kalender:widget_bookings': ['kalender:view'], - 'kalender:widget_quick_add': ['kalender:view', 'kalender:create'], - // fahrzeuge - 'fahrzeuge:create': ['fahrzeuge:view'], - 'fahrzeuge:change_status': ['fahrzeuge:view'], - 'fahrzeuge:manage_maintenance': ['fahrzeuge:view'], - 'fahrzeuge:delete': ['fahrzeuge:view', 'fahrzeuge:create'], - 'fahrzeuge:widget': ['fahrzeuge:view'], - // einsaetze - 'einsaetze:view_reports': ['einsaetze:view'], - 'einsaetze:create': ['einsaetze:view'], - 'einsaetze:delete': ['einsaetze:view', 'einsaetze:create'], - 'einsaetze:manage_personnel': ['einsaetze:view'], - // ausruestung - 'ausruestung:create': ['ausruestung:view'], - 'ausruestung:manage_maintenance': ['ausruestung:view'], - 'ausruestung:delete': ['ausruestung:view', 'ausruestung:create'], - 'ausruestung:widget': ['ausruestung:view'], - // mitglieder - 'mitglieder:view_all': ['mitglieder:view_own'], - 'mitglieder:edit': ['mitglieder:view_own', 'mitglieder:view_all'], - 'mitglieder:create_profile': ['mitglieder:view_own', 'mitglieder:view_all', 'mitglieder:edit'], - // atemschutz - 'atemschutz:create': ['atemschutz:view'], - 'atemschutz:delete': ['atemschutz:view', 'atemschutz:create'], - 'atemschutz:widget': ['atemschutz:view'], - // wissen - 'wissen:widget_recent': ['wissen:view'], - 'wissen:widget_search': ['wissen:view'], - // admin - 'admin:write': ['admin:view'], -}; +// ── Dependency helpers (work with dynamic configs) ── -// ── Group hierarchy ── -// When a permission is granted to a group, it is also auto-granted to all listed groups. -const GROUP_HIERARCHY: Record = { - 'dashboard_mitglied': ['dashboard_chargen', 'dashboard_atemschutz', 'dashboard_moderator', 'dashboard_zeugmeister', 'dashboard_fahrmeister', 'dashboard_gruppenfuehrer', 'dashboard_kommando'], - 'dashboard_chargen': ['dashboard_gruppenfuehrer', 'dashboard_kommando'], - 'dashboard_atemschutz': ['dashboard_kommando'], - 'dashboard_moderator': ['dashboard_kommando'], - 'dashboard_zeugmeister': ['dashboard_kommando'], - 'dashboard_fahrmeister': ['dashboard_kommando'], - 'dashboard_gruppenfuehrer': ['dashboard_kommando'], - 'dashboard_kommando': [], -}; - -// Build reverse hierarchy: for each group, which groups propagate DOWN to it -// i.e. if group X lists Y in its hierarchy, then removing from Y should remove from X -function buildReverseHierarchy(): Record { - const reverse: Record = {}; - for (const [group, inheritors] of Object.entries(GROUP_HIERARCHY)) { - for (const inheritor of inheritors) { - if (!reverse[inheritor]) reverse[inheritor] = []; - reverse[inheritor].push(group); - } - } - return reverse; -} - -const REVERSE_HIERARCHY = buildReverseHierarchy(); - -// ── Dependency helpers ── - -/** Recursively collect all prerequisite permissions for `permId`. */ -function collectAllDeps(permId: string, visited = new Set()): Set { +function collectAllDeps(permId: string, depsMap: Record, visited = new Set()): Set { if (visited.has(permId)) return visited; visited.add(permId); - const deps = PERMISSION_DEPS[permId] || []; - for (const dep of deps) { - collectAllDeps(dep, visited); - } + const deps = depsMap[permId] || []; + for (const dep of deps) collectAllDeps(dep, depsMap, visited); return visited; } -/** Build a reverse dependency map: for each permission, which permissions depend on it. */ -function buildReverseDeps(): Record { +function buildReverseDeps(depsMap: Record): Record { const rev: Record = {}; - for (const [perm, deps] of Object.entries(PERMISSION_DEPS)) { + for (const [perm, deps] of Object.entries(depsMap)) { for (const dep of deps) { if (!rev[dep]) rev[dep] = []; rev[dep].push(perm); @@ -127,33 +50,37 @@ function buildReverseDeps(): Record { return rev; } -const REVERSE_DEPS = buildReverseDeps(); - -/** Recursively collect all permissions that depend on `permId`. */ -function collectAllDependents(permId: string, visited = new Set()): Set { +function collectAllDependents(permId: string, reverseDeps: Record, visited = new Set()): Set { if (visited.has(permId)) return visited; visited.add(permId); - const dependents = REVERSE_DEPS[permId] || []; - for (const dep of dependents) { - collectAllDependents(dep, visited); - } + const dependents = reverseDeps[permId] || []; + for (const dep of dependents) collectAllDependents(dep, reverseDeps, visited); return visited; } -/** Add a permission with all its deps to a set. */ -function addPermWithDeps(current: Set, permId: string): Set { - const allNeeded = collectAllDeps(permId); +function addPermWithDeps(current: Set, permId: string, depsMap: Record): Set { + const allNeeded = collectAllDeps(permId, depsMap); for (const p of allNeeded) current.add(p); return current; } -/** Remove a permission and all its dependents from a set. */ -function removePermWithDependents(current: Set, permId: string): Set { - const allToRemove = collectAllDependents(permId); +function removePermWithDependents(current: Set, permId: string, reverseDeps: Record): Set { + const allToRemove = collectAllDependents(permId, reverseDeps); for (const p of allToRemove) current.delete(p); return current; } +function buildReverseHierarchy(hierarchy: Record): Record { + const reverse: Record = {}; + for (const [group, inheritors] of Object.entries(hierarchy)) { + for (const inheritor of inheritors) { + if (!reverse[inheritor]) reverse[inheritor] = []; + reverse[inheritor].push(group); + } + } + return reverse; +} + // ── Component ── function PermissionMatrixTab() { @@ -170,13 +97,24 @@ function PermissionMatrixTab() { queryFn: permissionsApi.getUnknownGroups, }); + const { data: depConfig, isLoading: depConfigLoading } = useQuery({ + queryKey: ['admin-dep-config'], + queryFn: permissionsApi.getDependencyConfig, + }); + + const groupHierarchy = depConfig?.groupHierarchy ?? {}; + const permissionDeps = depConfig?.permissionDeps ?? {}; + const reverseDeps = useMemo(() => buildReverseDeps(permissionDeps), [permissionDeps]); + const reverseHierarchy = useMemo(() => buildReverseHierarchy(groupHierarchy), [groupHierarchy]); + const [expandedGroups, setExpandedGroups] = useState>({}); + const [showDepEditor, setShowDepEditor] = useState(false); const toggleGroup = (groupId: string) => { setExpandedGroups(prev => ({ ...prev, [groupId]: !prev[groupId] })); }; - // ── Maintenance toggle ── + // ── Mutations ── const maintenanceMutation = useMutation({ mutationFn: ({ featureGroup, active }: { featureGroup: string; active: boolean }) => permissionsApi.setMaintenanceFlag(featureGroup, active), @@ -188,7 +126,6 @@ function PermissionMatrixTab() { onError: () => showError('Fehler beim Aktualisieren des Wartungsmodus'), }); - // ── Permission save (bulk — single request for all affected groups) ── const permissionMutation = useMutation({ mutationFn: (updates: { group: string; permissions: string[] }[]) => permissionsApi.setBulkPermissions(updates), @@ -200,7 +137,6 @@ function PermissionMatrixTab() { onError: () => showError('Fehler beim Speichern der Berechtigungen'), }); - // ── Add unknown group ── const addGroupMutation = useMutation({ mutationFn: (groupName: string) => permissionsApi.setGroupPermissions(groupName, []), onSuccess: () => { @@ -211,139 +147,97 @@ function PermissionMatrixTab() { onError: () => showError('Fehler beim Hinzufügen der Gruppe'), }); - // ── Compute affected groups when toggling a permission ── + const depConfigMutation = useMutation({ + mutationFn: (config: { groupHierarchy?: Record; permissionDeps?: Record }) => + permissionsApi.setDependencyConfig(config), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['admin-dep-config'] }); + showSuccess('Abhängigkeiten gespeichert'); + }, + onError: () => showError('Fehler beim Speichern der Abhängigkeiten'), + }); + + // ── Permission toggle with cascading ── const computeUpdates = useCallback( - ( - group: string, - permId: string, - grants: Record, - allGroups: string[], - ): { group: string; permissions: string[] }[] => { + (group: string, permId: string, grants: Record, allGroups: string[]) => { const currentPerms = new Set(grants[group] || []); const isAdding = !currentPerms.has(permId); const updates: { group: string; permissions: string[] }[] = []; if (isAdding) { - // Add perm + deps to this group const newPerms = new Set(currentPerms); - addPermWithDeps(newPerms, permId); + addPermWithDeps(newPerms, permId, permissionDeps); updates.push({ group, permissions: Array.from(newPerms) }); - // Hierarchy: also add to all groups that should inherit upward - const inheritors = GROUP_HIERARCHY[group] || []; + const inheritors = groupHierarchy[group] || []; for (const inheritor of inheritors) { if (!allGroups.includes(inheritor) || inheritor === 'dashboard_admin') continue; const inhPerms = new Set(grants[inheritor] || []); - const beforeSize = inhPerms.size; - addPermWithDeps(inhPerms, permId); - if (inhPerms.size !== beforeSize || !inhPerms.has(permId)) { - // Only push if something changed - inhPerms.add(permId); // ensure the perm itself is there - // Re-add deps just in case - const allNeeded = collectAllDeps(permId); - for (const p of allNeeded) inhPerms.add(p); - updates.push({ group: inheritor, permissions: Array.from(inhPerms) }); - } + addPermWithDeps(inhPerms, permId, permissionDeps); + updates.push({ group: inheritor, permissions: Array.from(inhPerms) }); } - // Deduplicate: if a group already in updates, skip - const seen = new Set(); - return updates.filter(u => { - if (seen.has(u.group)) return false; - seen.add(u.group); - return true; - }); } else { - // Remove perm + dependents from this group const newPerms = new Set(currentPerms); - removePermWithDependents(newPerms, permId); + removePermWithDependents(newPerms, permId, reverseDeps); updates.push({ group, permissions: Array.from(newPerms) }); - // Hierarchy: also remove from all groups that are LOWER (reverse hierarchy) - // i.e. groups for which this group appears in their hierarchy list - const lowerGroups = REVERSE_HIERARCHY[group] || []; + const lowerGroups = reverseHierarchy[group] || []; for (const lower of lowerGroups) { if (!allGroups.includes(lower) || lower === 'dashboard_admin') continue; const lowerPerms = new Set(grants[lower] || []); - const beforeSize = lowerPerms.size; - const hadPerm = lowerPerms.has(permId); - removePermWithDependents(lowerPerms, permId); - if (lowerPerms.size !== beforeSize || hadPerm) { + if (lowerPerms.has(permId)) { + removePermWithDependents(lowerPerms, permId, reverseDeps); updates.push({ group: lower, permissions: Array.from(lowerPerms) }); } } - const seen = new Set(); - return updates.filter(u => { - if (seen.has(u.group)) return false; - seen.add(u.group); - return true; - }); } + + // Deduplicate + const seen = new Set(); + return updates.filter(u => { + if (seen.has(u.group)) return false; + seen.add(u.group); + return true; + }); }, - [], + [permissionDeps, reverseDeps, groupHierarchy, reverseHierarchy], ); const handlePermissionToggle = useCallback( (group: string, permId: string, grants: Record, allGroups: string[]) => { const updates = computeUpdates(group, permId, grants, allGroups); - if (updates.length > 0) { - permissionMutation.mutate(updates); - } + if (updates.length > 0) permissionMutation.mutate(updates); }, [computeUpdates, permissionMutation], ); const handleSelectAllForGroup = useCallback( - ( - authentikGroup: string, - featureGroupId: string, - allPermissions: Permission[], - grants: Record, - allGroups: string[], - selectAll: boolean, - ) => { - const fgPermIds = allPermissions - .filter(p => p.feature_group_id === featureGroupId) - .map(p => p.id); - - // Build combined updates across all affected groups + (authentikGroup: string, featureGroupId: string, allPermissions: Permission[], grants: Record, allGroups: string[], selectAll: boolean) => { + const fgPermIds = allPermissions.filter(p => p.feature_group_id === featureGroupId).map(p => p.id); const allUpdates = new Map>(); - - // Initialize with current grants for potentially affected groups - const initGroup = (g: string) => { - if (!allUpdates.has(g)) { - allUpdates.set(g, new Set(grants[g] || [])); - } - }; - + const initGroup = (g: string) => { if (!allUpdates.has(g)) allUpdates.set(g, new Set(grants[g] || [])); }; initGroup(authentikGroup); if (selectAll) { - // Add all feature group perms with deps for (const permId of fgPermIds) { - addPermWithDeps(allUpdates.get(authentikGroup)!, permId); - // Hierarchy upward - const inheritors = GROUP_HIERARCHY[authentikGroup] || []; - for (const inheritor of inheritors) { + addPermWithDeps(allUpdates.get(authentikGroup)!, permId, permissionDeps); + for (const inheritor of (groupHierarchy[authentikGroup] || [])) { if (!allGroups.includes(inheritor) || inheritor === 'dashboard_admin') continue; initGroup(inheritor); - addPermWithDeps(allUpdates.get(inheritor)!, permId); + addPermWithDeps(allUpdates.get(inheritor)!, permId, permissionDeps); } } } else { - // Remove all feature group perms with dependents for (const permId of fgPermIds) { - removePermWithDependents(allUpdates.get(authentikGroup)!, permId); - // Hierarchy downward - const lowerGroups = REVERSE_HIERARCHY[authentikGroup] || []; - for (const lower of lowerGroups) { + removePermWithDependents(allUpdates.get(authentikGroup)!, permId, reverseDeps); + for (const lower of (reverseHierarchy[authentikGroup] || [])) { if (!allGroups.includes(lower) || lower === 'dashboard_admin') continue; initGroup(lower); - removePermWithDependents(allUpdates.get(lower)!, permId); + removePermWithDependents(allUpdates.get(lower)!, permId, reverseDeps); } } } - // Build final update list, only groups that actually changed const updates: { group: string; permissions: string[] }[] = []; for (const [g, perms] of allUpdates) { const original = new Set(grants[g] || []); @@ -352,36 +246,25 @@ function PermissionMatrixTab() { updates.push({ group: g, permissions: newArr }); } } - - if (updates.length > 0) { - permissionMutation.mutate(updates); - } + if (updates.length > 0) permissionMutation.mutate(updates); }, - [permissionMutation], - ); - - // ── All known permission IDs for dependency tooltip ── - const allPermissionIds = useMemo( - () => (matrix ? new Set(matrix.permissions.map(p => p.id)) : new Set()), - [matrix], + [permissionDeps, reverseDeps, groupHierarchy, reverseHierarchy, permissionMutation], ); const getDepTooltip = useCallback( (permId: string): string => { - const deps = PERMISSION_DEPS[permId]; + const deps = permissionDeps[permId]; if (!deps || deps.length === 0) return ''; - const labels = deps - .filter(d => allPermissionIds.has(d)) - .map(d => { - const p = matrix?.permissions.find(p => p.id === d); - return p ? p.label : d; - }); + const labels = deps.map(d => { + const p = matrix?.permissions.find(pp => pp.id === d); + return p ? p.label : d; + }); return labels.length > 0 ? `Benötigt: ${labels.join(', ')}` : ''; }, - [allPermissionIds, matrix], + [permissionDeps, matrix], ); - if (isLoading || !matrix) { + if (isLoading || depConfigLoading || !matrix) { return ( @@ -402,14 +285,8 @@ function PermissionMatrixTab() { {unknownGroups.map(g => ( - ))} @@ -420,43 +297,55 @@ function PermissionMatrixTab() { {/* Section 1: Maintenance Toggles */} - - Wartungsmodus - + Wartungsmodus Wenn aktiviert, wird die Funktion für alle Benutzer ausser Administratoren ausgeblendet. {featureGroups.map((fg: FeatureGroup) => ( - maintenanceMutation.mutate({ - featureGroup: fg.id, - active: !(maintenance[fg.id] ?? false), - }) - } - disabled={maintenanceMutation.isPending} - /> - } + control={ maintenanceMutation.mutate({ featureGroup: fg.id, active: !(maintenance[fg.id] ?? false) })} + disabled={maintenanceMutation.isPending} />} label={fg.label} /> - {maintenance[fg.id] && ( - - )} + {maintenance[fg.id] && } ))} - {/* Section 2: Permission Matrix */} + {/* Section 2: Dependency Configuration */} - - Berechtigungsmatrix - + + Abhängigkeiten + + + + g === 'dashboard_admin')]} + allPermissions={permissions} + onSave={(config) => depConfigMutation.mutate(config)} + isSaving={depConfigMutation.isPending} + /> + + {!showDepEditor && ( + + {Object.keys(groupHierarchy).length} Gruppenabhängigkeiten, {Object.keys(permissionDeps).length} Berechtigungsabhängigkeiten konfiguriert. + + )} + + + + {/* Section 3: Permission Matrix */} + + + Berechtigungsmatrix Berechtigungen pro Authentik-Gruppe zuweisen. Die Gruppe "dashboard_admin" hat immer vollen Zugriff. @@ -465,23 +354,11 @@ function PermissionMatrixTab() { - + Berechtigung - {/* dashboard_admin column */} - - admin - + admin {nonAdminGroups.map(g => ( @@ -493,59 +370,36 @@ function PermissionMatrixTab() { {featureGroups.map((fg: FeatureGroup) => { const fgPerms = permissions.filter((p: Permission) => p.feature_group_id === fg.id); - const isExpanded = expandedGroups[fg.id] !== false; // default expanded + const isExpanded = expandedGroups[fg.id] !== false; return ( - {/* Feature group header row */} - toggleGroup(fg.id)} - > + toggleGroup(fg.id)}> {isExpanded ? : } {fg.label} - {maintenance[fg.id] && ( - - )} + {maintenance[fg.id] && } - {/* Admin: all checked */} - - - - {/* Per-group: select all / deselect all */} + {nonAdminGroups.map(g => { const groupGrants = grants[g] || []; const allGranted = fgPerms.length > 0 && fgPerms.every((p: Permission) => groupGrants.includes(p.id)); const someGranted = fgPerms.some((p: Permission) => groupGrants.includes(p.id)); return ( - - handleSelectAllForGroup(g, fg.id, permissions, grants, groups, !allGranted) - } - disabled={permissionMutation.isPending} - size="small" - /> + handleSelectAllForGroup(g, fg.id, permissions, grants, groups, !allGranted)} + disabled={permissionMutation.isPending} size="small" /> ); })} - {/* Individual permission rows */} @@ -556,46 +410,24 @@ function PermissionMatrixTab() { const tooltipText = [perm.description, depTooltip].filter(Boolean).join('\n'); return ( - - - {perm.label} - + + {perm.label} - {/* Admin: always checked */} {nonAdminGroups.map(g => { const isGranted = (grants[g] || []).includes(perm.id); - // Check if this perm is required by another granted perm (dependency lock) - const groupGrants = grants[g] || []; - const dependents = REVERSE_DEPS[perm.id] || []; - const isRequiredByOther = isGranted && dependents.some(d => groupGrants.includes(d)); + const curReverseDeps = reverseDeps[perm.id] || []; + const isRequiredByOther = isGranted && curReverseDeps.some(d => (grants[g] || []).includes(d)); return ( - + - - handlePermissionToggle(g, perm.id, grants, groups) - } - disabled={permissionMutation.isPending} - size="small" - sx={isRequiredByOther ? { color: 'warning.main', '&.Mui-checked': { color: 'warning.main' } } : undefined} - /> + handlePermissionToggle(g, perm.id, grants, groups)} + disabled={permissionMutation.isPending} size="small" + sx={isRequiredByOther ? { color: 'warning.main', '&.Mui-checked': { color: 'warning.main' } } : undefined} /> @@ -621,4 +453,159 @@ function PermissionMatrixTab() { ); } +// ── Dependency Editor Sub-Component ── + +interface DependencyEditorProps { + groupHierarchy: Record; + permissionDeps: Record; + allGroups: string[]; + allPermissions: Permission[]; + onSave: (config: { groupHierarchy?: Record; permissionDeps?: Record }) => void; + isSaving: boolean; +} + +function DependencyEditor({ groupHierarchy, permissionDeps, allGroups, allPermissions, onSave, isSaving }: DependencyEditorProps) { + const [editHierarchy, setEditHierarchy] = useState>(() => ({ ...groupHierarchy })); + const [editDeps, setEditDeps] = useState>(() => ({ ...permissionDeps })); + + const nonAdminGroups = allGroups.filter(g => g !== 'dashboard_admin'); + const permOptions = allPermissions.map(p => p.id); + + const handleHierarchyChange = (group: string, inheritors: string[]) => { + setEditHierarchy(prev => { + const next = { ...prev }; + if (inheritors.length === 0) { + delete next[group]; + } else { + next[group] = inheritors; + } + return next; + }); + }; + + const handleDepChange = (permId: string, deps: string[]) => { + setEditDeps(prev => { + const next = { ...prev }; + if (deps.length === 0) { + delete next[permId]; + } else { + next[permId] = deps; + } + return next; + }); + }; + + const handleRemoveDep = (permId: string) => { + setEditDeps(prev => { + const next = { ...prev }; + delete next[permId]; + return next; + }); + }; + + const [newDepPerm, setNewDepPerm] = useState(null); + + return ( + + {/* Group Hierarchy Editor */} + + + Gruppenabhängigkeiten + + + Wenn eine Gruppe eine Berechtigung erhält, erhalten die hier zugeordneten höheren Gruppen diese ebenfalls. + + {nonAdminGroups.map(group => ( + + + {group.replace('dashboard_', '')} + + + g !== group)} + getOptionLabel={(g) => g.replace('dashboard_', '')} + value={editHierarchy[group] || []} + onChange={(_e, val) => handleHierarchyChange(group, val)} + renderInput={(params) => } + renderTags={(value, getTagProps) => + value.map((g, index) => ( + + )) + } + /> + + ))} + + + {/* Permission Dependency Editor */} + + + Berechtigungsabhängigkeiten + + + Wenn eine Berechtigung gesetzt wird, werden die hier definierten Voraussetzungen automatisch mit aktiviert. + + {Object.entries(editDeps).map(([permId, deps]) => { + const perm = allPermissions.find(p => p.id === permId); + return ( + + + {perm?.label ?? permId} + + benötigt + p !== permId)} + getOptionLabel={(p) => allPermissions.find(pp => pp.id === p)?.label ?? p} + value={deps} + onChange={(_e, val) => handleDepChange(permId, val)} + renderInput={(params) => } + renderTags={(value, getTagProps) => + value.map((p, index) => ( + pp.id === p)?.label ?? p} size="small" /> + )) + } + /> + handleRemoveDep(permId)} color="error"> + + + + ); + })} + + {/* Add new dependency */} + + !editDeps[p])} + getOptionLabel={(p) => allPermissions.find(pp => pp.id === p)?.label ?? p} + value={newDepPerm} + onChange={(_e, val) => setNewDepPerm(val)} + renderInput={(params) => } + /> + + + + + {/* Save button */} + + + + + ); +} + export default PermissionMatrixTab; diff --git a/frontend/src/services/permissions.ts b/frontend/src/services/permissions.ts index 1f9606b..d08a63c 100644 --- a/frontend/src/services/permissions.ts +++ b/frontend/src/services/permissions.ts @@ -33,4 +33,19 @@ export const permissionsApi = { const r = await api.get('/api/permissions/admin/unknown-groups'); return r.data.data; }, + + getDependencyConfig: async (): Promise<{ + groupHierarchy: Record; + permissionDeps: Record; + }> => { + const r = await api.get('/api/permissions/admin/config'); + return r.data.data; + }, + + setDependencyConfig: async (config: { + groupHierarchy?: Record; + permissionDeps?: Record; + }): Promise => { + await api.put('/api/permissions/admin/config', config); + }, };