rights system
This commit is contained in:
@@ -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<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'],
|
||||
};
|
||||
// ── 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<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> {
|
||||
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 = 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<string, string[]> {
|
||||
function buildReverseDeps(depsMap: Record<string, string[]>): 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) {
|
||||
if (!rev[dep]) rev[dep] = [];
|
||||
rev[dep].push(perm);
|
||||
@@ -127,33 +50,37 @@ function buildReverseDeps(): Record<string, string[]> {
|
||||
return rev;
|
||||
}
|
||||
|
||||
const REVERSE_DEPS = buildReverseDeps();
|
||||
|
||||
/** Recursively collect all permissions that depend on `permId`. */
|
||||
function collectAllDependents(permId: string, visited = new Set<string>()): Set<string> {
|
||||
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 = 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<string>, permId: string): Set<string> {
|
||||
const allNeeded = collectAllDeps(permId);
|
||||
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;
|
||||
}
|
||||
|
||||
/** Remove a permission and all its dependents from a set. */
|
||||
function removePermWithDependents(current: Set<string>, permId: string): Set<string> {
|
||||
const allToRemove = collectAllDependents(permId);
|
||||
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() {
|
||||
@@ -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<Record<string, boolean>>({});
|
||||
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<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[],
|
||||
): { group: string; permissions: string[] }[] => {
|
||||
(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) {
|
||||
// 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<string>();
|
||||
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<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(
|
||||
(group: string, permId: string, grants: Record<string, string[]>, 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<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
|
||||
(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>>();
|
||||
|
||||
// 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<string>()),
|
||||
[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 (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}>
|
||||
<CircularProgress />
|
||||
@@ -402,14 +285,8 @@ function PermissionMatrixTab() {
|
||||
</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}
|
||||
>
|
||||
<Button key={g} variant="outlined" size="small" startIcon={<AddIcon />}
|
||||
onClick={() => addGroupMutation.mutate(g)} disabled={addGroupMutation.isPending}>
|
||||
{g} hinzufügen
|
||||
</Button>
|
||||
))}
|
||||
@@ -420,43 +297,55 @@ function PermissionMatrixTab() {
|
||||
{/* Section 1: Maintenance Toggles */}
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Wartungsmodus
|
||||
</Typography>
|
||||
<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}
|
||||
/>
|
||||
}
|
||||
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" />
|
||||
)}
|
||||
{maintenance[fg.id] && <Chip label="Wartungsmodus" color="warning" size="small" />}
|
||||
</Box>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Section 2: Permission Matrix */}
|
||||
{/* Section 2: Dependency Configuration */}
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Berechtigungsmatrix
|
||||
</Typography>
|
||||
<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 "dashboard_admin" hat immer vollen Zugriff.
|
||||
</Typography>
|
||||
@@ -465,23 +354,11 @@ function PermissionMatrixTab() {
|
||||
<Table size="small" stickyHeader>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell
|
||||
sx={{
|
||||
minWidth: 250,
|
||||
fontWeight: 'bold',
|
||||
position: 'sticky',
|
||||
left: 0,
|
||||
zIndex: 3,
|
||||
bgcolor: 'background.paper',
|
||||
}}
|
||||
>
|
||||
<TableCell sx={{ minWidth: 250, fontWeight: 'bold', position: 'sticky', left: 0, zIndex: 3, bgcolor: 'background.paper' }}>
|
||||
Berechtigung
|
||||
</TableCell>
|
||||
{/* dashboard_admin column */}
|
||||
<Tooltip title="Admin hat immer vollen Zugriff" placement="top">
|
||||
<TableCell align="center" sx={{ minWidth: 120, fontWeight: 'bold', opacity: 0.5 }}>
|
||||
admin
|
||||
</TableCell>
|
||||
<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' }}>
|
||||
@@ -493,59 +370,36 @@ function PermissionMatrixTab() {
|
||||
<TableBody>
|
||||
{featureGroups.map((fg: FeatureGroup) => {
|
||||
const fgPerms = permissions.filter((p: Permission) => p.feature_group_id === fg.id);
|
||||
const isExpanded = expandedGroups[fg.id] !== false; // default expanded
|
||||
const isExpanded = expandedGroups[fg.id] !== false;
|
||||
|
||||
return (
|
||||
<React.Fragment key={fg.id}>
|
||||
{/* Feature group header row */}
|
||||
<TableRow sx={{ bgcolor: 'action.hover' }}>
|
||||
<TableCell
|
||||
sx={{
|
||||
fontWeight: 'bold',
|
||||
position: 'sticky',
|
||||
left: 0,
|
||||
zIndex: 2,
|
||||
bgcolor: 'action.hover',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
onClick={() => toggleGroup(fg.id)}
|
||||
>
|
||||
<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" />
|
||||
)}
|
||||
{maintenance[fg.id] && <Chip label="Wartung" color="warning" size="small" />}
|
||||
</Box>
|
||||
</TableCell>
|
||||
{/* Admin: all checked */}
|
||||
<TableCell align="center">
|
||||
<Checkbox checked disabled sx={{ opacity: 0.3 }} />
|
||||
</TableCell>
|
||||
{/* Per-group: select all / deselect all */}
|
||||
<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"
|
||||
/>
|
||||
<Checkbox checked={allGranted} indeterminate={someGranted && !allGranted}
|
||||
onChange={() => handleSelectAllForGroup(g, fg.id, permissions, grants, groups, !allGranted)}
|
||||
disabled={permissionMutation.isPending} size="small" />
|
||||
</TableCell>
|
||||
);
|
||||
})}
|
||||
</TableRow>
|
||||
|
||||
{/* Individual permission rows */}
|
||||
<TableRow>
|
||||
<TableCell colSpan={2 + nonAdminGroups.length} sx={{ p: 0 }}>
|
||||
<Collapse in={isExpanded} timeout="auto" unmountOnExit>
|
||||
@@ -556,46 +410,24 @@ function PermissionMatrixTab() {
|
||||
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 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>
|
||||
{/* Admin: always checked */}
|
||||
<TableCell align="center" sx={{ minWidth: 120 }}>
|
||||
<Checkbox checked disabled sx={{ opacity: 0.3 }} />
|
||||
</TableCell>
|
||||
{nonAdminGroups.map(g => {
|
||||
const isGranted = (grants[g] || []).includes(perm.id);
|
||||
// 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 (
|
||||
<TableCell key={g} align="center" sx={{ minWidth: 120 }}>
|
||||
<Tooltip
|
||||
title={isRequiredByOther ? 'Wird von anderen Berechtigungen benötigt' : ''}
|
||||
placement="top"
|
||||
>
|
||||
<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}
|
||||
/>
|
||||
<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>
|
||||
@@ -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;
|
||||
|
||||
@@ -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<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);
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user