import React, { useState, useCallback, useMemo } from 'react'; import { Alert, Autocomplete, Box, Button, Card, CardContent, Checkbox, Chip, CircularProgress, Collapse, FormControlLabel, IconButton, Switch, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, TextField, Tooltip, Typography, } from '@mui/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'; // ── Dependency helpers (work with dynamic configs) ── function collectAllDeps(permId: string, depsMap: Record, visited = new Set()): Set { if (visited.has(permId)) return visited; visited.add(permId); const deps = depsMap[permId] || []; for (const dep of deps) collectAllDeps(dep, depsMap, visited); return visited; } function buildReverseDeps(depsMap: Record): Record { const rev: Record = {}; for (const [perm, deps] of Object.entries(depsMap)) { for (const dep of deps) { if (!rev[dep]) rev[dep] = []; rev[dep].push(perm); } } return rev; } function collectAllDependents(permId: string, reverseDeps: Record, visited = new Set()): Set { if (visited.has(permId)) return visited; visited.add(permId); const dependents = reverseDeps[permId] || []; for (const dep of dependents) collectAllDependents(dep, reverseDeps, visited); return visited; } function addPermWithDeps(current: Set, permId: string, depsMap: Record): Set { const allNeeded = collectAllDeps(permId, depsMap); for (const p of allNeeded) current.add(p); return current; } 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() { const queryClient = useQueryClient(); const { showSuccess, showError } = useNotification(); const { data: matrix, isLoading } = useQuery({ queryKey: ['admin-permission-matrix'], queryFn: permissionsApi.getMatrix, }); const { data: unknownGroups } = useQuery({ queryKey: ['admin-unknown-groups'], 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] })); }; // ── Mutations ── 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'), }); const permissionMutation = useMutation({ mutationFn: (updates: { group: string; permissions: string[] }[]) => permissionsApi.setBulkPermissions(updates), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['admin-permission-matrix'] }); queryClient.invalidateQueries({ queryKey: ['my-permissions'] }); showSuccess('Berechtigungen gespeichert'); }, onError: () => showError('Fehler beim Speichern der Berechtigungen'), }); const addGroupMutation = useMutation({ mutationFn: (groupName: string) => permissionsApi.setGroupPermissions(groupName, []), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['admin-permission-matrix'] }); queryClient.invalidateQueries({ queryKey: ['admin-unknown-groups'] }); showSuccess('Gruppe hinzugefügt'); }, onError: () => showError('Fehler beim Hinzufügen der Gruppe'), }); const deleteGroupMutation = useMutation({ mutationFn: (groupName: string) => permissionsApi.deleteGroup(groupName), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['admin-permission-matrix'] }); queryClient.invalidateQueries({ queryKey: ['admin-unknown-groups'] }); queryClient.invalidateQueries({ queryKey: ['my-permissions'] }); showSuccess('Gruppe entfernt'); }, onError: () => showError('Fehler beim Entfernen der Gruppe'), }); 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[]) => { const currentPerms = new Set(grants[group] || []); const isAdding = !currentPerms.has(permId); const updates: { group: string; permissions: string[] }[] = []; if (isAdding) { const newPerms = new Set(currentPerms); addPermWithDeps(newPerms, permId, permissionDeps); updates.push({ group, permissions: Array.from(newPerms) }); const inheritors = groupHierarchy[group] || []; for (const inheritor of inheritors) { if (!allGroups.includes(inheritor) || inheritor === 'dashboard_admin') continue; const inhPerms = new Set(grants[inheritor] || []); addPermWithDeps(inhPerms, permId, permissionDeps); updates.push({ group: inheritor, permissions: Array.from(inhPerms) }); } } else { const newPerms = new Set(currentPerms); removePermWithDependents(newPerms, permId, reverseDeps); updates.push({ group, permissions: Array.from(newPerms) }); const lowerGroups = reverseHierarchy[group] || []; for (const lower of lowerGroups) { if (!allGroups.includes(lower) || lower === 'dashboard_admin') continue; const lowerPerms = new Set(grants[lower] || []); if (lowerPerms.has(permId)) { removePermWithDependents(lowerPerms, permId, reverseDeps); updates.push({ group: lower, permissions: Array.from(lowerPerms) }); } } } // 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); }, [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); const allUpdates = new Map>(); const initGroup = (g: string) => { if (!allUpdates.has(g)) allUpdates.set(g, new Set(grants[g] || [])); }; initGroup(authentikGroup); if (selectAll) { for (const permId of fgPermIds) { 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, permissionDeps); } } } else { for (const permId of fgPermIds) { 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, reverseDeps); } } } const updates: { group: string; permissions: string[] }[] = []; for (const [g, perms] of allUpdates) { const original = new Set(grants[g] || []); const newArr = Array.from(perms); if (newArr.length !== original.size || newArr.some(p => !original.has(p))) { updates.push({ group: g, permissions: newArr }); } } if (updates.length > 0) permissionMutation.mutate(updates); }, [permissionDeps, reverseDeps, groupHierarchy, reverseHierarchy, permissionMutation], ); const getDepTooltip = useCallback( (permId: string): string => { const deps = permissionDeps[permId]; if (!deps || deps.length === 0) return ''; 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(', ')}` : ''; }, [permissionDeps, matrix], ); if (isLoading || depConfigLoading || !matrix) { return ( ); } const { featureGroups, permissions, groups, grants, maintenance } = matrix; const nonAdminGroups = groups.filter(g => g !== 'dashboard_admin'); return ( {/* Unknown Groups Alert */} {unknownGroups && unknownGroups.length > 0 && ( Folgende Gruppen wurden in der Benutzertabelle gefunden, sind aber noch nicht in der Berechtigungsmatrix: {unknownGroups.map(g => ( ))} )} {/* Section 1: Maintenance Toggles */} 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} />} label={fg.label} /> {maintenance[fg.id] && } ))} {/* Section 2: Dependency Configuration */} 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. Berechtigung admin {nonAdminGroups.map(g => ( {g.replace('dashboard_', '')} { if (window.confirm(`Gruppe "${g}" und alle zugehörigen Berechtigungen wirklich entfernen?`)) { deleteGroupMutation.mutate(g); } }} sx={{ opacity: 0.4, '&:hover': { opacity: 1, color: 'error.main' } }}> ))} {featureGroups.map((fg: FeatureGroup) => { const fgPerms = permissions.filter((p: Permission) => p.feature_group_id === fg.id); const isExpanded = expandedGroups[fg.id] !== false; return ( toggleGroup(fg.id)}> {isExpanded ? : } {fg.label} {maintenance[fg.id] && } {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" /> ); })}
{fgPerms.map((perm: Permission) => { const depTooltip = getDepTooltip(perm.id); const tooltipText = [perm.description, depTooltip].filter(Boolean).join('\n'); return ( {perm.label} {nonAdminGroups.map(g => { const isGranted = (grants[g] || []).includes(perm.id); 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} /> ); })} ); })}
); })}
); } // ── 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;