rights system

This commit is contained in:
Matthias Hochmeister
2026-03-23 12:00:09 +01:00
parent d173c8235e
commit a575b61d26
5 changed files with 476 additions and 311 deletions

View File

@@ -154,6 +154,52 @@ class PermissionController {
res.status(500).json({ success: false, message: 'Fehler beim Setzen des Wartungsmodus' }); 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<void> {
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<string, string[]>, permissionDeps?: Record<string, string[]> }
*/
async setDependencyConfig(req: Request, res: Response): Promise<void> {
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(); export default new PermissionController();

View File

@@ -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/matrix', authenticate, requirePermission('admin:view'), permissionController.getMatrix.bind(permissionController));
router.get('/admin/groups', authenticate, requirePermission('admin:view'), permissionController.getGroups.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/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/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/bulk', authenticate, requirePermission('admin:write'), permissionController.setBulkPermissions.bind(permissionController));
router.put('/admin/maintenance/:featureGroupId', authenticate, requirePermission('admin:write'), permissionController.setMaintenanceFlag.bind(permissionController)); router.put('/admin/maintenance/:featureGroupId', authenticate, requirePermission('admin:write'), permissionController.setMaintenanceFlag.bind(permissionController));

View File

@@ -1,6 +1,55 @@
import pool from '../config/database'; import pool from '../config/database';
import logger from '../utils/logger'; import logger from '../utils/logger';
// Default configs — used when no DB config exists yet
const DEFAULT_GROUP_HIERARCHY: Record<string, string[]> = {
'dashboard_mitglied': ['dashboard_chargen', 'dashboard_atemschutz', 'dashboard_moderator', 'dashboard_zeugmeister', 'dashboard_fahrmeister', 'dashboard_gruppenfuehrer', 'dashboard_kommando'],
'dashboard_chargen': ['dashboard_gruppenfuehrer', 'dashboard_kommando'],
'dashboard_atemschutz': ['dashboard_kommando'],
'dashboard_moderator': ['dashboard_kommando'],
'dashboard_zeugmeister': ['dashboard_kommando'],
'dashboard_fahrmeister': ['dashboard_kommando'],
'dashboard_gruppenfuehrer': ['dashboard_kommando'],
'dashboard_kommando': [],
};
const DEFAULT_PERMISSION_DEPS: Record<string, string[]> = {
'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 { export interface FeatureGroupRow {
id: string; id: string;
label: string; label: string;
@@ -255,10 +304,76 @@ class PermissionService {
'UPDATE feature_groups SET maintenance = $1 WHERE id = $2', 'UPDATE feature_groups SET maintenance = $1 WHERE id = $2',
[active, featureGroup] [active, featureGroup]
); );
// Reload cache
await this.loadCache(); await this.loadCache();
logger.info('Maintenance flag updated', { featureGroup, active }); logger.info('Maintenance flag updated', { featureGroup, active });
} }
// ── Dependency config (stored in app_settings) ──
async getGroupHierarchy(): Promise<Record<string, string[]>> {
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<string, string[]>, userId: string): Promise<void> {
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<Record<string, string[]>> {
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<string, string[]>, userId: string): Promise<void> {
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<string, string[]>;
permissionDeps: Record<string, string[]>;
}> {
const [groupHierarchy, permissionDeps] = await Promise.all([
this.getGroupHierarchy(),
this.getPermissionDeps(),
]);
return { groupHierarchy, permissionDeps };
}
} }
export const permissionService = new PermissionService(); export const permissionService = new PermissionService();

View File

@@ -1,6 +1,7 @@
import React, { useState, useCallback, useMemo } from 'react'; import React, { useState, useCallback, useMemo } from 'react';
import { import {
Alert, Alert,
Autocomplete,
Box, Box,
Button, Button,
Card, Card,
@@ -18,107 +19,29 @@ import {
TableContainer, TableContainer,
TableHead, TableHead,
TableRow, TableRow,
TextField,
Tooltip, Tooltip,
Typography, Typography,
} from '@mui/material'; } 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 { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { permissionsApi } from '../../services/permissions'; import { permissionsApi } from '../../services/permissions';
import { useNotification } from '../../contexts/NotificationContext'; import { useNotification } from '../../contexts/NotificationContext';
import type { PermissionMatrix, FeatureGroup, Permission } from '../../types/permissions.types'; import type { PermissionMatrix, FeatureGroup, Permission } from '../../types/permissions.types';
// ── Permission dependency map ── // ── Dependency helpers (work with dynamic configs) ──
// Each permission lists its prerequisites (must also be granted).
const PERMISSION_DEPS: Record<string, string[]> = {
// kalender
'kalender:create': ['kalender:view'],
'kalender:cancel': ['kalender:view'],
'kalender:mark_attendance': ['kalender:view'],
'kalender:create_bookings': ['kalender:view'],
'kalender:edit_bookings': ['kalender:view', 'kalender:create_bookings'],
'kalender:cancel_own_bookings': ['kalender:view'],
'kalender:delete_bookings': ['kalender:view', 'kalender:edit_bookings'],
'kalender:manage_categories': ['kalender:view'],
'kalender:view_reports': ['kalender:view'],
'kalender:widget_events': ['kalender:view'],
'kalender:widget_bookings': ['kalender:view'],
'kalender:widget_quick_add': ['kalender:view', 'kalender:create'],
// fahrzeuge
'fahrzeuge:create': ['fahrzeuge:view'],
'fahrzeuge:change_status': ['fahrzeuge:view'],
'fahrzeuge:manage_maintenance': ['fahrzeuge:view'],
'fahrzeuge:delete': ['fahrzeuge:view', 'fahrzeuge:create'],
'fahrzeuge:widget': ['fahrzeuge:view'],
// einsaetze
'einsaetze:view_reports': ['einsaetze:view'],
'einsaetze:create': ['einsaetze:view'],
'einsaetze:delete': ['einsaetze:view', 'einsaetze:create'],
'einsaetze:manage_personnel': ['einsaetze:view'],
// ausruestung
'ausruestung:create': ['ausruestung:view'],
'ausruestung:manage_maintenance': ['ausruestung:view'],
'ausruestung:delete': ['ausruestung:view', 'ausruestung:create'],
'ausruestung:widget': ['ausruestung:view'],
// mitglieder
'mitglieder:view_all': ['mitglieder:view_own'],
'mitglieder:edit': ['mitglieder:view_own', 'mitglieder:view_all'],
'mitglieder:create_profile': ['mitglieder:view_own', 'mitglieder:view_all', 'mitglieder:edit'],
// atemschutz
'atemschutz:create': ['atemschutz:view'],
'atemschutz:delete': ['atemschutz:view', 'atemschutz:create'],
'atemschutz:widget': ['atemschutz:view'],
// wissen
'wissen:widget_recent': ['wissen:view'],
'wissen:widget_search': ['wissen:view'],
// admin
'admin:write': ['admin:view'],
};
// ── Group hierarchy ── function collectAllDeps(permId: string, depsMap: Record<string, string[]>, visited = new Set<string>()): Set<string> {
// When a permission is granted to a group, it is also auto-granted to all listed groups.
const GROUP_HIERARCHY: Record<string, string[]> = {
'dashboard_mitglied': ['dashboard_chargen', 'dashboard_atemschutz', 'dashboard_moderator', 'dashboard_zeugmeister', 'dashboard_fahrmeister', 'dashboard_gruppenfuehrer', 'dashboard_kommando'],
'dashboard_chargen': ['dashboard_gruppenfuehrer', 'dashboard_kommando'],
'dashboard_atemschutz': ['dashboard_kommando'],
'dashboard_moderator': ['dashboard_kommando'],
'dashboard_zeugmeister': ['dashboard_kommando'],
'dashboard_fahrmeister': ['dashboard_kommando'],
'dashboard_gruppenfuehrer': ['dashboard_kommando'],
'dashboard_kommando': [],
};
// Build reverse hierarchy: for each group, which groups propagate DOWN to it
// i.e. if group X lists Y in its hierarchy, then removing from Y should remove from X
function buildReverseHierarchy(): Record<string, string[]> {
const reverse: Record<string, string[]> = {};
for (const [group, inheritors] of Object.entries(GROUP_HIERARCHY)) {
for (const inheritor of inheritors) {
if (!reverse[inheritor]) reverse[inheritor] = [];
reverse[inheritor].push(group);
}
}
return reverse;
}
const REVERSE_HIERARCHY = buildReverseHierarchy();
// ── Dependency helpers ──
/** Recursively collect all prerequisite permissions for `permId`. */
function collectAllDeps(permId: string, visited = new Set<string>()): Set<string> {
if (visited.has(permId)) return visited; if (visited.has(permId)) return visited;
visited.add(permId); visited.add(permId);
const deps = PERMISSION_DEPS[permId] || []; const deps = depsMap[permId] || [];
for (const dep of deps) { for (const dep of deps) collectAllDeps(dep, depsMap, visited);
collectAllDeps(dep, visited);
}
return visited; return visited;
} }
/** Build a reverse dependency map: for each permission, which permissions depend on it. */ function buildReverseDeps(depsMap: Record<string, string[]>): Record<string, string[]> {
function buildReverseDeps(): Record<string, string[]> {
const rev: Record<string, string[]> = {}; const rev: Record<string, string[]> = {};
for (const [perm, deps] of Object.entries(PERMISSION_DEPS)) { for (const [perm, deps] of Object.entries(depsMap)) {
for (const dep of deps) { for (const dep of deps) {
if (!rev[dep]) rev[dep] = []; if (!rev[dep]) rev[dep] = [];
rev[dep].push(perm); rev[dep].push(perm);
@@ -127,33 +50,37 @@ function buildReverseDeps(): Record<string, string[]> {
return rev; return rev;
} }
const REVERSE_DEPS = buildReverseDeps(); function collectAllDependents(permId: string, reverseDeps: Record<string, string[]>, visited = new Set<string>()): Set<string> {
/** Recursively collect all permissions that depend on `permId`. */
function collectAllDependents(permId: string, visited = new Set<string>()): Set<string> {
if (visited.has(permId)) return visited; if (visited.has(permId)) return visited;
visited.add(permId); visited.add(permId);
const dependents = REVERSE_DEPS[permId] || []; const dependents = reverseDeps[permId] || [];
for (const dep of dependents) { for (const dep of dependents) collectAllDependents(dep, reverseDeps, visited);
collectAllDependents(dep, visited);
}
return visited; return visited;
} }
/** Add a permission with all its deps to a set. */ function addPermWithDeps(current: Set<string>, permId: string, depsMap: Record<string, string[]>): Set<string> {
function addPermWithDeps(current: Set<string>, permId: string): Set<string> { const allNeeded = collectAllDeps(permId, depsMap);
const allNeeded = collectAllDeps(permId);
for (const p of allNeeded) current.add(p); for (const p of allNeeded) current.add(p);
return current; return current;
} }
/** Remove a permission and all its dependents from a set. */ function removePermWithDependents(current: Set<string>, permId: string, reverseDeps: Record<string, string[]>): Set<string> {
function removePermWithDependents(current: Set<string>, permId: string): Set<string> { const allToRemove = collectAllDependents(permId, reverseDeps);
const allToRemove = collectAllDependents(permId);
for (const p of allToRemove) current.delete(p); for (const p of allToRemove) current.delete(p);
return current; return current;
} }
function buildReverseHierarchy(hierarchy: Record<string, string[]>): Record<string, string[]> {
const reverse: Record<string, string[]> = {};
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 ── // ── Component ──
function PermissionMatrixTab() { function PermissionMatrixTab() {
@@ -170,13 +97,24 @@ function PermissionMatrixTab() {
queryFn: permissionsApi.getUnknownGroups, 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<Record<string, boolean>>({}); const [expandedGroups, setExpandedGroups] = useState<Record<string, boolean>>({});
const [showDepEditor, setShowDepEditor] = useState(false);
const toggleGroup = (groupId: string) => { const toggleGroup = (groupId: string) => {
setExpandedGroups(prev => ({ ...prev, [groupId]: !prev[groupId] })); setExpandedGroups(prev => ({ ...prev, [groupId]: !prev[groupId] }));
}; };
// ── Maintenance toggle ── // ── Mutations ──
const maintenanceMutation = useMutation({ const maintenanceMutation = useMutation({
mutationFn: ({ featureGroup, active }: { featureGroup: string; active: boolean }) => mutationFn: ({ featureGroup, active }: { featureGroup: string; active: boolean }) =>
permissionsApi.setMaintenanceFlag(featureGroup, active), permissionsApi.setMaintenanceFlag(featureGroup, active),
@@ -188,7 +126,6 @@ function PermissionMatrixTab() {
onError: () => showError('Fehler beim Aktualisieren des Wartungsmodus'), onError: () => showError('Fehler beim Aktualisieren des Wartungsmodus'),
}); });
// ── Permission save (bulk — single request for all affected groups) ──
const permissionMutation = useMutation({ const permissionMutation = useMutation({
mutationFn: (updates: { group: string; permissions: string[] }[]) => mutationFn: (updates: { group: string; permissions: string[] }[]) =>
permissionsApi.setBulkPermissions(updates), permissionsApi.setBulkPermissions(updates),
@@ -200,7 +137,6 @@ function PermissionMatrixTab() {
onError: () => showError('Fehler beim Speichern der Berechtigungen'), onError: () => showError('Fehler beim Speichern der Berechtigungen'),
}); });
// ── Add unknown group ──
const addGroupMutation = useMutation({ const addGroupMutation = useMutation({
mutationFn: (groupName: string) => permissionsApi.setGroupPermissions(groupName, []), mutationFn: (groupName: string) => permissionsApi.setGroupPermissions(groupName, []),
onSuccess: () => { onSuccess: () => {
@@ -211,139 +147,97 @@ function PermissionMatrixTab() {
onError: () => showError('Fehler beim Hinzufügen der Gruppe'), onError: () => showError('Fehler beim Hinzufügen der Gruppe'),
}); });
// ── Compute affected groups when toggling a permission ── const depConfigMutation = useMutation({
mutationFn: (config: { groupHierarchy?: Record<string, string[]>; permissionDeps?: Record<string, string[]> }) =>
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( const computeUpdates = useCallback(
( (group: string, permId: string, grants: Record<string, string[]>, allGroups: string[]) => {
group: string,
permId: string,
grants: Record<string, string[]>,
allGroups: string[],
): { group: string; permissions: string[] }[] => {
const currentPerms = new Set(grants[group] || []); const currentPerms = new Set(grants[group] || []);
const isAdding = !currentPerms.has(permId); const isAdding = !currentPerms.has(permId);
const updates: { group: string; permissions: string[] }[] = []; const updates: { group: string; permissions: string[] }[] = [];
if (isAdding) { if (isAdding) {
// Add perm + deps to this group
const newPerms = new Set(currentPerms); const newPerms = new Set(currentPerms);
addPermWithDeps(newPerms, permId); addPermWithDeps(newPerms, permId, permissionDeps);
updates.push({ group, permissions: Array.from(newPerms) }); updates.push({ group, permissions: Array.from(newPerms) });
// Hierarchy: also add to all groups that should inherit upward const inheritors = groupHierarchy[group] || [];
const inheritors = GROUP_HIERARCHY[group] || [];
for (const inheritor of inheritors) { for (const inheritor of inheritors) {
if (!allGroups.includes(inheritor) || inheritor === 'dashboard_admin') continue; if (!allGroups.includes(inheritor) || inheritor === 'dashboard_admin') continue;
const inhPerms = new Set(grants[inheritor] || []); const inhPerms = new Set(grants[inheritor] || []);
const beforeSize = inhPerms.size; addPermWithDeps(inhPerms, permId, permissionDeps);
addPermWithDeps(inhPerms, permId); updates.push({ group: inheritor, permissions: Array.from(inhPerms) });
if (inhPerms.size !== beforeSize || !inhPerms.has(permId)) {
// Only push if something changed
inhPerms.add(permId); // ensure the perm itself is there
// Re-add deps just in case
const allNeeded = collectAllDeps(permId);
for (const p of allNeeded) inhPerms.add(p);
updates.push({ group: inheritor, permissions: Array.from(inhPerms) });
}
} }
// Deduplicate: if a group already in updates, skip
const seen = new Set<string>();
return updates.filter(u => {
if (seen.has(u.group)) return false;
seen.add(u.group);
return true;
});
} else { } else {
// Remove perm + dependents from this group
const newPerms = new Set(currentPerms); const newPerms = new Set(currentPerms);
removePermWithDependents(newPerms, permId); removePermWithDependents(newPerms, permId, reverseDeps);
updates.push({ group, permissions: Array.from(newPerms) }); updates.push({ group, permissions: Array.from(newPerms) });
// Hierarchy: also remove from all groups that are LOWER (reverse hierarchy) const lowerGroups = reverseHierarchy[group] || [];
// i.e. groups for which this group appears in their hierarchy list
const lowerGroups = REVERSE_HIERARCHY[group] || [];
for (const lower of lowerGroups) { for (const lower of lowerGroups) {
if (!allGroups.includes(lower) || lower === 'dashboard_admin') continue; if (!allGroups.includes(lower) || lower === 'dashboard_admin') continue;
const lowerPerms = new Set(grants[lower] || []); const lowerPerms = new Set(grants[lower] || []);
const beforeSize = lowerPerms.size; if (lowerPerms.has(permId)) {
const hadPerm = lowerPerms.has(permId); removePermWithDependents(lowerPerms, permId, reverseDeps);
removePermWithDependents(lowerPerms, permId);
if (lowerPerms.size !== beforeSize || hadPerm) {
updates.push({ group: lower, permissions: Array.from(lowerPerms) }); updates.push({ group: lower, permissions: Array.from(lowerPerms) });
} }
} }
const seen = new Set<string>();
return updates.filter(u => {
if (seen.has(u.group)) return false;
seen.add(u.group);
return true;
});
} }
// Deduplicate
const seen = new Set<string>();
return updates.filter(u => {
if (seen.has(u.group)) return false;
seen.add(u.group);
return true;
});
}, },
[], [permissionDeps, reverseDeps, groupHierarchy, reverseHierarchy],
); );
const handlePermissionToggle = useCallback( const handlePermissionToggle = useCallback(
(group: string, permId: string, grants: Record<string, string[]>, allGroups: string[]) => { (group: string, permId: string, grants: Record<string, string[]>, allGroups: string[]) => {
const updates = computeUpdates(group, permId, grants, allGroups); const updates = computeUpdates(group, permId, grants, allGroups);
if (updates.length > 0) { if (updates.length > 0) permissionMutation.mutate(updates);
permissionMutation.mutate(updates);
}
}, },
[computeUpdates, permissionMutation], [computeUpdates, permissionMutation],
); );
const handleSelectAllForGroup = useCallback( const handleSelectAllForGroup = useCallback(
( (authentikGroup: string, featureGroupId: string, allPermissions: Permission[], grants: Record<string, string[]>, allGroups: string[], selectAll: boolean) => {
authentikGroup: string, const fgPermIds = allPermissions.filter(p => p.feature_group_id === featureGroupId).map(p => p.id);
featureGroupId: string,
allPermissions: Permission[],
grants: Record<string, string[]>,
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
const allUpdates = new Map<string, Set<string>>(); const allUpdates = new Map<string, Set<string>>();
const initGroup = (g: string) => { if (!allUpdates.has(g)) allUpdates.set(g, new Set(grants[g] || [])); };
// Initialize with current grants for potentially affected groups
const initGroup = (g: string) => {
if (!allUpdates.has(g)) {
allUpdates.set(g, new Set(grants[g] || []));
}
};
initGroup(authentikGroup); initGroup(authentikGroup);
if (selectAll) { if (selectAll) {
// Add all feature group perms with deps
for (const permId of fgPermIds) { for (const permId of fgPermIds) {
addPermWithDeps(allUpdates.get(authentikGroup)!, permId); addPermWithDeps(allUpdates.get(authentikGroup)!, permId, permissionDeps);
// Hierarchy upward for (const inheritor of (groupHierarchy[authentikGroup] || [])) {
const inheritors = GROUP_HIERARCHY[authentikGroup] || [];
for (const inheritor of inheritors) {
if (!allGroups.includes(inheritor) || inheritor === 'dashboard_admin') continue; if (!allGroups.includes(inheritor) || inheritor === 'dashboard_admin') continue;
initGroup(inheritor); initGroup(inheritor);
addPermWithDeps(allUpdates.get(inheritor)!, permId); addPermWithDeps(allUpdates.get(inheritor)!, permId, permissionDeps);
} }
} }
} else { } else {
// Remove all feature group perms with dependents
for (const permId of fgPermIds) { for (const permId of fgPermIds) {
removePermWithDependents(allUpdates.get(authentikGroup)!, permId); removePermWithDependents(allUpdates.get(authentikGroup)!, permId, reverseDeps);
// Hierarchy downward for (const lower of (reverseHierarchy[authentikGroup] || [])) {
const lowerGroups = REVERSE_HIERARCHY[authentikGroup] || [];
for (const lower of lowerGroups) {
if (!allGroups.includes(lower) || lower === 'dashboard_admin') continue; if (!allGroups.includes(lower) || lower === 'dashboard_admin') continue;
initGroup(lower); 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[] }[] = []; const updates: { group: string; permissions: string[] }[] = [];
for (const [g, perms] of allUpdates) { for (const [g, perms] of allUpdates) {
const original = new Set(grants[g] || []); const original = new Set(grants[g] || []);
@@ -352,36 +246,25 @@ function PermissionMatrixTab() {
updates.push({ group: g, permissions: newArr }); updates.push({ group: g, permissions: newArr });
} }
} }
if (updates.length > 0) permissionMutation.mutate(updates);
if (updates.length > 0) {
permissionMutation.mutate(updates);
}
}, },
[permissionMutation], [permissionDeps, reverseDeps, groupHierarchy, reverseHierarchy, permissionMutation],
);
// ── All known permission IDs for dependency tooltip ──
const allPermissionIds = useMemo(
() => (matrix ? new Set(matrix.permissions.map(p => p.id)) : new Set<string>()),
[matrix],
); );
const getDepTooltip = useCallback( const getDepTooltip = useCallback(
(permId: string): string => { (permId: string): string => {
const deps = PERMISSION_DEPS[permId]; const deps = permissionDeps[permId];
if (!deps || deps.length === 0) return ''; if (!deps || deps.length === 0) return '';
const labels = deps const labels = deps.map(d => {
.filter(d => allPermissionIds.has(d)) const p = matrix?.permissions.find(pp => pp.id === d);
.map(d => { return p ? p.label : d;
const p = matrix?.permissions.find(p => p.id === d); });
return p ? p.label : d;
});
return labels.length > 0 ? `Benötigt: ${labels.join(', ')}` : ''; return labels.length > 0 ? `Benötigt: ${labels.join(', ')}` : '';
}, },
[allPermissionIds, matrix], [permissionDeps, matrix],
); );
if (isLoading || !matrix) { if (isLoading || depConfigLoading || !matrix) {
return ( return (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}> <Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}>
<CircularProgress /> <CircularProgress />
@@ -402,14 +285,8 @@ function PermissionMatrixTab() {
</Typography> </Typography>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}> <Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
{unknownGroups.map(g => ( {unknownGroups.map(g => (
<Button <Button key={g} variant="outlined" size="small" startIcon={<AddIcon />}
key={g} onClick={() => addGroupMutation.mutate(g)} disabled={addGroupMutation.isPending}>
variant="outlined"
size="small"
startIcon={<AddIcon />}
onClick={() => addGroupMutation.mutate(g)}
disabled={addGroupMutation.isPending}
>
{g} hinzufügen {g} hinzufügen
</Button> </Button>
))} ))}
@@ -420,43 +297,55 @@ function PermissionMatrixTab() {
{/* Section 1: Maintenance Toggles */} {/* Section 1: Maintenance Toggles */}
<Card> <Card>
<CardContent> <CardContent>
<Typography variant="h6" gutterBottom> <Typography variant="h6" gutterBottom>Wartungsmodus</Typography>
Wartungsmodus
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}> <Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
Wenn aktiviert, wird die Funktion für alle Benutzer ausser Administratoren ausgeblendet. Wenn aktiviert, wird die Funktion für alle Benutzer ausser Administratoren ausgeblendet.
</Typography> </Typography>
{featureGroups.map((fg: FeatureGroup) => ( {featureGroups.map((fg: FeatureGroup) => (
<Box key={fg.id} sx={{ display: 'flex', alignItems: 'center', gap: 1, py: 0.5 }}> <Box key={fg.id} sx={{ display: 'flex', alignItems: 'center', gap: 1, py: 0.5 }}>
<FormControlLabel <FormControlLabel
control={ control={<Switch checked={maintenance[fg.id] ?? false}
<Switch onChange={() => maintenanceMutation.mutate({ featureGroup: fg.id, active: !(maintenance[fg.id] ?? false) })}
checked={maintenance[fg.id] ?? false} disabled={maintenanceMutation.isPending} />}
onChange={() =>
maintenanceMutation.mutate({
featureGroup: fg.id,
active: !(maintenance[fg.id] ?? false),
})
}
disabled={maintenanceMutation.isPending}
/>
}
label={fg.label} label={fg.label}
/> />
{maintenance[fg.id] && ( {maintenance[fg.id] && <Chip label="Wartungsmodus" color="warning" size="small" />}
<Chip label="Wartungsmodus" color="warning" size="small" />
)}
</Box> </Box>
))} ))}
</CardContent> </CardContent>
</Card> </Card>
{/* Section 2: Permission Matrix */} {/* Section 2: Dependency Configuration */}
<Card> <Card>
<CardContent> <CardContent>
<Typography variant="h6" gutterBottom> <Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1 }}>
Berechtigungsmatrix <Typography variant="h6">Abhängigkeiten</Typography>
</Typography> <Button size="small" onClick={() => setShowDepEditor(!showDepEditor)}>
{showDepEditor ? 'Ausblenden' : 'Bearbeiten'}
</Button>
</Box>
<Collapse in={showDepEditor}>
<DependencyEditor
groupHierarchy={groupHierarchy}
permissionDeps={permissionDeps}
allGroups={[...nonAdminGroups, ...groups.filter(g => g === 'dashboard_admin')]}
allPermissions={permissions}
onSave={(config) => depConfigMutation.mutate(config)}
isSaving={depConfigMutation.isPending}
/>
</Collapse>
{!showDepEditor && (
<Typography variant="body2" color="text.secondary">
{Object.keys(groupHierarchy).length} Gruppenabhängigkeiten, {Object.keys(permissionDeps).length} Berechtigungsabhängigkeiten konfiguriert.
</Typography>
)}
</CardContent>
</Card>
{/* Section 3: Permission Matrix */}
<Card>
<CardContent>
<Typography variant="h6" gutterBottom>Berechtigungsmatrix</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}> <Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
Berechtigungen pro Authentik-Gruppe zuweisen. Die Gruppe &quot;dashboard_admin&quot; hat immer vollen Zugriff. Berechtigungen pro Authentik-Gruppe zuweisen. Die Gruppe &quot;dashboard_admin&quot; hat immer vollen Zugriff.
</Typography> </Typography>
@@ -465,23 +354,11 @@ function PermissionMatrixTab() {
<Table size="small" stickyHeader> <Table size="small" stickyHeader>
<TableHead> <TableHead>
<TableRow> <TableRow>
<TableCell <TableCell sx={{ minWidth: 250, fontWeight: 'bold', position: 'sticky', left: 0, zIndex: 3, bgcolor: 'background.paper' }}>
sx={{
minWidth: 250,
fontWeight: 'bold',
position: 'sticky',
left: 0,
zIndex: 3,
bgcolor: 'background.paper',
}}
>
Berechtigung Berechtigung
</TableCell> </TableCell>
{/* dashboard_admin column */}
<Tooltip title="Admin hat immer vollen Zugriff" placement="top"> <Tooltip title="Admin hat immer vollen Zugriff" placement="top">
<TableCell align="center" sx={{ minWidth: 120, fontWeight: 'bold', opacity: 0.5 }}> <TableCell align="center" sx={{ minWidth: 120, fontWeight: 'bold', opacity: 0.5 }}>admin</TableCell>
admin
</TableCell>
</Tooltip> </Tooltip>
{nonAdminGroups.map(g => ( {nonAdminGroups.map(g => (
<TableCell key={g} align="center" sx={{ minWidth: 120, fontWeight: 'bold' }}> <TableCell key={g} align="center" sx={{ minWidth: 120, fontWeight: 'bold' }}>
@@ -493,59 +370,36 @@ function PermissionMatrixTab() {
<TableBody> <TableBody>
{featureGroups.map((fg: FeatureGroup) => { {featureGroups.map((fg: FeatureGroup) => {
const fgPerms = permissions.filter((p: Permission) => p.feature_group_id === fg.id); 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 ( return (
<React.Fragment key={fg.id}> <React.Fragment key={fg.id}>
{/* Feature group header row */}
<TableRow sx={{ bgcolor: 'action.hover' }}> <TableRow sx={{ bgcolor: 'action.hover' }}>
<TableCell <TableCell sx={{ fontWeight: 'bold', position: 'sticky', left: 0, zIndex: 2, bgcolor: 'action.hover', cursor: 'pointer' }}
sx={{ onClick={() => toggleGroup(fg.id)}>
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 }}> <Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<IconButton size="small"> <IconButton size="small">
{isExpanded ? <ExpandLess fontSize="small" /> : <ExpandMore fontSize="small" />} {isExpanded ? <ExpandLess fontSize="small" /> : <ExpandMore fontSize="small" />}
</IconButton> </IconButton>
{fg.label} {fg.label}
{maintenance[fg.id] && ( {maintenance[fg.id] && <Chip label="Wartung" color="warning" size="small" />}
<Chip label="Wartung" color="warning" size="small" />
)}
</Box> </Box>
</TableCell> </TableCell>
{/* Admin: all checked */} <TableCell align="center"><Checkbox checked disabled sx={{ opacity: 0.3 }} /></TableCell>
<TableCell align="center">
<Checkbox checked disabled sx={{ opacity: 0.3 }} />
</TableCell>
{/* Per-group: select all / deselect all */}
{nonAdminGroups.map(g => { {nonAdminGroups.map(g => {
const groupGrants = grants[g] || []; const groupGrants = grants[g] || [];
const allGranted = fgPerms.length > 0 && fgPerms.every((p: Permission) => groupGrants.includes(p.id)); const allGranted = fgPerms.length > 0 && fgPerms.every((p: Permission) => groupGrants.includes(p.id));
const someGranted = fgPerms.some((p: Permission) => groupGrants.includes(p.id)); const someGranted = fgPerms.some((p: Permission) => groupGrants.includes(p.id));
return ( return (
<TableCell key={g} align="center"> <TableCell key={g} align="center">
<Checkbox <Checkbox checked={allGranted} indeterminate={someGranted && !allGranted}
checked={allGranted} onChange={() => handleSelectAllForGroup(g, fg.id, permissions, grants, groups, !allGranted)}
indeterminate={someGranted && !allGranted} disabled={permissionMutation.isPending} size="small" />
onChange={() =>
handleSelectAllForGroup(g, fg.id, permissions, grants, groups, !allGranted)
}
disabled={permissionMutation.isPending}
size="small"
/>
</TableCell> </TableCell>
); );
})} })}
</TableRow> </TableRow>
{/* Individual permission rows */}
<TableRow> <TableRow>
<TableCell colSpan={2 + nonAdminGroups.length} sx={{ p: 0 }}> <TableCell colSpan={2 + nonAdminGroups.length} sx={{ p: 0 }}>
<Collapse in={isExpanded} timeout="auto" unmountOnExit> <Collapse in={isExpanded} timeout="auto" unmountOnExit>
@@ -556,46 +410,24 @@ function PermissionMatrixTab() {
const tooltipText = [perm.description, depTooltip].filter(Boolean).join('\n'); const tooltipText = [perm.description, depTooltip].filter(Boolean).join('\n');
return ( return (
<TableRow key={perm.id} hover> <TableRow key={perm.id} hover>
<TableCell <TableCell sx={{ pl: 6, minWidth: 250, position: 'sticky', left: 0, zIndex: 1, bgcolor: 'background.paper' }}>
sx={{ <Tooltip title={tooltipText || ''} placement="right"><span>{perm.label}</span></Tooltip>
pl: 6,
minWidth: 250,
position: 'sticky',
left: 0,
zIndex: 1,
bgcolor: 'background.paper',
}}
>
<Tooltip title={tooltipText || ''} placement="right">
<span>{perm.label}</span>
</Tooltip>
</TableCell> </TableCell>
{/* Admin: always checked */}
<TableCell align="center" sx={{ minWidth: 120 }}> <TableCell align="center" sx={{ minWidth: 120 }}>
<Checkbox checked disabled sx={{ opacity: 0.3 }} /> <Checkbox checked disabled sx={{ opacity: 0.3 }} />
</TableCell> </TableCell>
{nonAdminGroups.map(g => { {nonAdminGroups.map(g => {
const isGranted = (grants[g] || []).includes(perm.id); const isGranted = (grants[g] || []).includes(perm.id);
// Check if this perm is required by another granted perm (dependency lock) const curReverseDeps = reverseDeps[perm.id] || [];
const groupGrants = grants[g] || []; const isRequiredByOther = isGranted && curReverseDeps.some(d => (grants[g] || []).includes(d));
const dependents = REVERSE_DEPS[perm.id] || [];
const isRequiredByOther = isGranted && dependents.some(d => groupGrants.includes(d));
return ( return (
<TableCell key={g} align="center" sx={{ minWidth: 120 }}> <TableCell key={g} align="center" sx={{ minWidth: 120 }}>
<Tooltip <Tooltip title={isRequiredByOther ? 'Wird von anderen Berechtigungen benötigt' : ''} placement="top">
title={isRequiredByOther ? 'Wird von anderen Berechtigungen benötigt' : ''}
placement="top"
>
<span> <span>
<Checkbox <Checkbox checked={isGranted}
checked={isGranted} onChange={() => handlePermissionToggle(g, perm.id, grants, groups)}
onChange={() => disabled={permissionMutation.isPending} size="small"
handlePermissionToggle(g, perm.id, grants, groups) sx={isRequiredByOther ? { color: 'warning.main', '&.Mui-checked': { color: 'warning.main' } } : undefined} />
}
disabled={permissionMutation.isPending}
size="small"
sx={isRequiredByOther ? { color: 'warning.main', '&.Mui-checked': { color: 'warning.main' } } : undefined}
/>
</span> </span>
</Tooltip> </Tooltip>
</TableCell> </TableCell>
@@ -621,4 +453,159 @@ function PermissionMatrixTab() {
); );
} }
// ── Dependency Editor Sub-Component ──
interface DependencyEditorProps {
groupHierarchy: Record<string, string[]>;
permissionDeps: Record<string, string[]>;
allGroups: string[];
allPermissions: Permission[];
onSave: (config: { groupHierarchy?: Record<string, string[]>; permissionDeps?: Record<string, string[]> }) => void;
isSaving: boolean;
}
function DependencyEditor({ groupHierarchy, permissionDeps, allGroups, allPermissions, onSave, isSaving }: DependencyEditorProps) {
const [editHierarchy, setEditHierarchy] = useState<Record<string, string[]>>(() => ({ ...groupHierarchy }));
const [editDeps, setEditDeps] = useState<Record<string, string[]>>(() => ({ ...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<string | null>(null);
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3, mt: 2 }}>
{/* Group Hierarchy Editor */}
<Box>
<Typography variant="subtitle1" gutterBottom sx={{ fontWeight: 'bold' }}>
Gruppenabhängigkeiten
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
Wenn eine Gruppe eine Berechtigung erhält, erhalten die hier zugeordneten höheren Gruppen diese ebenfalls.
</Typography>
{nonAdminGroups.map(group => (
<Box key={group} sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
<Typography variant="body2" sx={{ minWidth: 180, fontWeight: 500 }}>
{group.replace('dashboard_', '')}
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mx: 1 }}></Typography>
<Autocomplete
multiple size="small" sx={{ flex: 1 }}
options={nonAdminGroups.filter(g => g !== group)}
getOptionLabel={(g) => g.replace('dashboard_', '')}
value={editHierarchy[group] || []}
onChange={(_e, val) => handleHierarchyChange(group, val)}
renderInput={(params) => <TextField {...params} placeholder="Höhere Gruppen..." size="small" />}
renderTags={(value, getTagProps) =>
value.map((g, index) => (
<Chip {...getTagProps({ index })} key={g} label={g.replace('dashboard_', '')} size="small" />
))
}
/>
</Box>
))}
</Box>
{/* Permission Dependency Editor */}
<Box>
<Typography variant="subtitle1" gutterBottom sx={{ fontWeight: 'bold' }}>
Berechtigungsabhängigkeiten
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
Wenn eine Berechtigung gesetzt wird, werden die hier definierten Voraussetzungen automatisch mit aktiviert.
</Typography>
{Object.entries(editDeps).map(([permId, deps]) => {
const perm = allPermissions.find(p => p.id === permId);
return (
<Box key={permId} sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
<Typography variant="body2" sx={{ minWidth: 220, fontWeight: 500 }}>
{perm?.label ?? permId}
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mx: 1 }}>benötigt</Typography>
<Autocomplete
multiple size="small" sx={{ flex: 1 }}
options={permOptions.filter(p => p !== permId)}
getOptionLabel={(p) => allPermissions.find(pp => pp.id === p)?.label ?? p}
value={deps}
onChange={(_e, val) => handleDepChange(permId, val)}
renderInput={(params) => <TextField {...params} placeholder="Voraussetzungen..." size="small" />}
renderTags={(value, getTagProps) =>
value.map((p, index) => (
<Chip {...getTagProps({ index })} key={p}
label={allPermissions.find(pp => pp.id === p)?.label ?? p} size="small" />
))
}
/>
<IconButton size="small" onClick={() => handleRemoveDep(permId)} color="error">
<DeleteIcon fontSize="small" />
</IconButton>
</Box>
);
})}
{/* Add new dependency */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mt: 1 }}>
<Autocomplete
size="small" sx={{ width: 300 }}
options={permOptions.filter(p => !editDeps[p])}
getOptionLabel={(p) => allPermissions.find(pp => pp.id === p)?.label ?? p}
value={newDepPerm}
onChange={(_e, val) => setNewDepPerm(val)}
renderInput={(params) => <TextField {...params} placeholder="Neue Abhängigkeit hinzufügen..." size="small" />}
/>
<Button size="small" variant="outlined" startIcon={<AddIcon />} disabled={!newDepPerm}
onClick={() => {
if (newDepPerm) {
handleDepChange(newDepPerm, []);
setNewDepPerm(null);
}
}}>
Hinzufügen
</Button>
</Box>
</Box>
{/* Save button */}
<Box sx={{ display: 'flex', justifyContent: 'flex-end' }}>
<Button variant="contained" onClick={() => onSave({ groupHierarchy: editHierarchy, permissionDeps: editDeps })}
disabled={isSaving}>
{isSaving ? 'Speichern...' : 'Abhängigkeiten speichern'}
</Button>
</Box>
</Box>
);
}
export default PermissionMatrixTab; export default PermissionMatrixTab;

View File

@@ -33,4 +33,19 @@ export const permissionsApi = {
const r = await api.get('/api/permissions/admin/unknown-groups'); const r = await api.get('/api/permissions/admin/unknown-groups');
return r.data.data; return r.data.data;
}, },
getDependencyConfig: async (): Promise<{
groupHierarchy: Record<string, string[]>;
permissionDeps: Record<string, string[]>;
}> => {
const r = await api.get('/api/permissions/admin/config');
return r.data.data;
},
setDependencyConfig: async (config: {
groupHierarchy?: Record<string, string[]>;
permissionDeps?: Record<string, string[]>;
}): Promise<void> => {
await api.put('/api/permissions/admin/config', config);
},
}; };