Files
dashboard/frontend/src/components/admin/PermissionMatrixTab.tsx
Matthias Hochmeister 725d4d1729 rights system
2026-03-23 12:18:46 +01:00

634 lines
27 KiB
TypeScript

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<string, string[]>, visited = new Set<string>()): Set<string> {
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<string, string[]>): Record<string, string[]> {
const rev: Record<string, string[]> = {};
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<string, string[]>, visited = new Set<string>()): Set<string> {
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<string>, permId: string, depsMap: Record<string, string[]>): Set<string> {
const allNeeded = collectAllDeps(permId, depsMap);
for (const p of allNeeded) current.add(p);
return current;
}
function removePermWithDependents(current: Set<string>, permId: string, reverseDeps: Record<string, string[]>): Set<string> {
const allToRemove = collectAllDependents(permId, reverseDeps);
for (const p of allToRemove) current.delete(p);
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 ──
function PermissionMatrixTab() {
const queryClient = useQueryClient();
const { showSuccess, showError } = useNotification();
const { data: matrix, isLoading } = useQuery<PermissionMatrix>({
queryKey: ['admin-permission-matrix'],
queryFn: permissionsApi.getMatrix,
});
const { data: unknownGroups } = useQuery<string[]>({
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<Record<string, boolean>>({});
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<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(
(group: string, permId: string, grants: Record<string, string[]>, 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<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(
(group: string, permId: string, grants: Record<string, string[]>, 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<string, string[]>, allGroups: string[], selectAll: boolean) => {
const fgPermIds = allPermissions.filter(p => p.feature_group_id === featureGroupId).map(p => p.id);
const allUpdates = new Map<string, Set<string>>();
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 (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}>
<CircularProgress />
</Box>
);
}
const { featureGroups, permissions, groups, grants, maintenance } = matrix;
const nonAdminGroups = groups.filter(g => g !== 'dashboard_admin');
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
{/* Unknown Groups Alert */}
{unknownGroups && unknownGroups.length > 0 && (
<Alert severity="warning" sx={{ alignItems: 'center' }}>
<Typography variant="body2" sx={{ mb: 1 }}>
Folgende Gruppen wurden in der Benutzertabelle gefunden, sind aber noch nicht in der Berechtigungsmatrix:
</Typography>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
{unknownGroups.map(g => (
<Button key={g} variant="outlined" size="small" startIcon={<AddIcon />}
onClick={() => addGroupMutation.mutate(g)} disabled={addGroupMutation.isPending}>
{g} hinzufügen
</Button>
))}
</Box>
</Alert>
)}
{/* Section 1: Maintenance Toggles */}
<Card>
<CardContent>
<Typography variant="h6" gutterBottom>Wartungsmodus</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
Wenn aktiviert, wird die Funktion für alle Benutzer ausser Administratoren ausgeblendet.
</Typography>
{featureGroups.map((fg: FeatureGroup) => (
<Box key={fg.id} sx={{ display: 'flex', alignItems: 'center', gap: 1, py: 0.5 }}>
<FormControlLabel
control={<Switch checked={maintenance[fg.id] ?? false}
onChange={() => maintenanceMutation.mutate({ featureGroup: fg.id, active: !(maintenance[fg.id] ?? false) })}
disabled={maintenanceMutation.isPending} />}
label={fg.label}
/>
{maintenance[fg.id] && <Chip label="Wartungsmodus" color="warning" size="small" />}
</Box>
))}
</CardContent>
</Card>
{/* Section 2: Dependency Configuration */}
<Card>
<CardContent>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1 }}>
<Typography variant="h6">Abhängigkeiten</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 }}>
Berechtigungen pro Authentik-Gruppe zuweisen. Die Gruppe &quot;dashboard_admin&quot; hat immer vollen Zugriff.
</Typography>
<TableContainer sx={{ maxHeight: '70vh' }}>
<Table size="small" stickyHeader>
<TableHead>
<TableRow>
<TableCell sx={{ minWidth: 250, fontWeight: 'bold', position: 'sticky', left: 0, zIndex: 3, bgcolor: 'background.paper' }}>
Berechtigung
</TableCell>
<Tooltip title="Admin hat immer vollen Zugriff" placement="top">
<TableCell align="center" sx={{ minWidth: 120, fontWeight: 'bold', opacity: 0.5 }}>admin</TableCell>
</Tooltip>
{nonAdminGroups.map(g => (
<TableCell key={g} align="center" sx={{ minWidth: 120, fontWeight: 'bold' }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 0.5 }}>
{g.replace('dashboard_', '')}
<Tooltip title={`Gruppe "${g}" entfernen`} placement="top">
<IconButton size="small" onClick={() => {
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' } }}>
<DeleteIcon sx={{ fontSize: 14 }} />
</IconButton>
</Tooltip>
</Box>
</TableCell>
))}
</TableRow>
</TableHead>
<TableBody>
{featureGroups.map((fg: FeatureGroup) => {
const fgPerms = permissions.filter((p: Permission) => p.feature_group_id === fg.id);
const isExpanded = expandedGroups[fg.id] !== false;
return (
<React.Fragment key={fg.id}>
<TableRow sx={{ bgcolor: 'action.hover' }}>
<TableCell sx={{ fontWeight: 'bold', position: 'sticky', left: 0, zIndex: 2, bgcolor: 'action.hover', cursor: 'pointer' }}
onClick={() => toggleGroup(fg.id)}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<IconButton size="small">
{isExpanded ? <ExpandLess fontSize="small" /> : <ExpandMore fontSize="small" />}
</IconButton>
{fg.label}
{maintenance[fg.id] && <Chip label="Wartung" color="warning" size="small" />}
</Box>
</TableCell>
<TableCell align="center"><Checkbox checked disabled sx={{ opacity: 0.3 }} /></TableCell>
{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 (
<TableCell key={g} align="center">
<Checkbox checked={allGranted} indeterminate={someGranted && !allGranted}
onChange={() => handleSelectAllForGroup(g, fg.id, permissions, grants, groups, !allGranted)}
disabled={permissionMutation.isPending} size="small" />
</TableCell>
);
})}
</TableRow>
<TableRow>
<TableCell colSpan={2 + nonAdminGroups.length} sx={{ p: 0 }}>
<Collapse in={isExpanded} timeout="auto" unmountOnExit>
<Table size="small">
<TableBody>
{fgPerms.map((perm: Permission) => {
const depTooltip = getDepTooltip(perm.id);
const tooltipText = [perm.description, depTooltip].filter(Boolean).join('\n');
return (
<TableRow key={perm.id} hover>
<TableCell sx={{ 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 align="center" sx={{ minWidth: 120 }}>
<Checkbox checked disabled sx={{ opacity: 0.3 }} />
</TableCell>
{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 (
<TableCell key={g} align="center" sx={{ minWidth: 120 }}>
<Tooltip title={isRequiredByOther ? 'Wird von anderen Berechtigungen benötigt' : ''} placement="top">
<span>
<Checkbox checked={isGranted}
onChange={() => handlePermissionToggle(g, perm.id, grants, groups)}
disabled={permissionMutation.isPending} size="small"
sx={isRequiredByOther ? { color: 'warning.main', '&.Mui-checked': { color: 'warning.main' } } : undefined} />
</span>
</Tooltip>
</TableCell>
);
})}
</TableRow>
);
})}
</TableBody>
</Table>
</Collapse>
</TableCell>
</TableRow>
</React.Fragment>
);
})}
</TableBody>
</Table>
</TableContainer>
</CardContent>
</Card>
</Box>
);
}
// ── 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;