rights system

This commit is contained in:
Matthias Hochmeister
2026-03-23 10:50:52 +01:00
parent 2bb22850f4
commit 515f14956e
24 changed files with 629 additions and 363 deletions

View File

@@ -1,6 +1,8 @@
import React, { useState, useCallback } from 'react';
import React, { useState, useCallback, useMemo } from 'react';
import {
Alert,
Box,
Button,
Card,
CardContent,
Checkbox,
@@ -19,12 +21,141 @@ import {
Tooltip,
Typography,
} from '@mui/material';
import { ExpandMore, ExpandLess } from '@mui/icons-material';
import { ExpandMore, ExpandLess, Add as AddIcon } 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'],
};
// ── 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> {
if (visited.has(permId)) return visited;
visited.add(permId);
const deps = PERMISSION_DEPS[permId] || [];
for (const dep of deps) {
collectAllDeps(dep, visited);
}
return visited;
}
/** Build a reverse dependency map: for each permission, which permissions depend on it. */
function buildReverseDeps(): Record<string, string[]> {
const rev: Record<string, string[]> = {};
for (const [perm, deps] of Object.entries(PERMISSION_DEPS)) {
for (const dep of deps) {
if (!rev[dep]) rev[dep] = [];
rev[dep].push(perm);
}
}
return rev;
}
const REVERSE_DEPS = buildReverseDeps();
/** Recursively collect all permissions that depend on `permId`. */
function collectAllDependents(permId: 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);
}
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);
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);
for (const p of allToRemove) current.delete(p);
return current;
}
// ── Component ──
function PermissionMatrixTab() {
const queryClient = useQueryClient();
const { showSuccess, showError } = useNotification();
@@ -34,14 +165,18 @@ function PermissionMatrixTab() {
queryFn: permissionsApi.getMatrix,
});
// Track which feature groups are expanded
const { data: unknownGroups } = useQuery<string[]>({
queryKey: ['admin-unknown-groups'],
queryFn: permissionsApi.getUnknownGroups,
});
const [expandedGroups, setExpandedGroups] = useState<Record<string, boolean>>({});
const toggleGroup = (groupId: string) => {
setExpandedGroups(prev => ({ ...prev, [groupId]: !prev[groupId] }));
};
// ── Maintenance toggle mutation ──
// ── Maintenance toggle ──
const maintenanceMutation = useMutation({
mutationFn: ({ featureGroup, active }: { featureGroup: string; active: boolean }) =>
permissionsApi.setMaintenanceFlag(featureGroup, active),
@@ -53,10 +188,10 @@ function PermissionMatrixTab() {
onError: () => showError('Fehler beim Aktualisieren des Wartungsmodus'),
});
// ── Permission toggle mutation (saves full group permissions) ──
// ── Permission save (saves full group permissions) ──
const permissionMutation = useMutation({
mutationFn: ({ group, permissions }: { group: string; permissions: string[] }) =>
permissionsApi.setGroupPermissions(group, permissions),
mutationFn: (updates: { group: string; permissions: string[] }[]) =>
Promise.all(updates.map(u => permissionsApi.setGroupPermissions(u.group, u.permissions))),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin-permission-matrix'] });
queryClient.invalidateQueries({ queryKey: ['my-permissions'] });
@@ -65,40 +200,185 @@ function PermissionMatrixTab() {
onError: () => showError('Fehler beim Speichern der Berechtigungen'),
});
const handlePermissionToggle = useCallback(
(group: string, permId: string, currentGrants: Record<string, string[]>) => {
const current = currentGrants[group] || [];
const newPerms = current.includes(permId)
? current.filter(p => p !== permId)
: [...current, permId];
permissionMutation.mutate({ group, permissions: newPerms });
// ── Add unknown group ──
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');
},
[permissionMutation]
onError: () => showError('Fehler beim Hinzufügen der Gruppe'),
});
// ── Compute affected groups when toggling a permission ──
const computeUpdates = useCallback(
(
group: string,
permId: string,
grants: Record<string, string[]>,
allGroups: string[],
): { group: string; permissions: 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);
updates.push({ group, permissions: Array.from(newPerms) });
// Hierarchy: also add to all groups that should inherit upward
const inheritors = GROUP_HIERARCHY[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) });
}
}
// 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);
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] || [];
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) {
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;
});
}
},
[],
);
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,
permissions: Permission[],
currentGrants: Record<string, string[]>,
selectAll: boolean
allPermissions: Permission[],
grants: Record<string, string[]>,
allGroups: string[],
selectAll: boolean,
) => {
const fgPermIds = permissions
const fgPermIds = allPermissions
.filter(p => p.feature_group_id === featureGroupId)
.map(p => p.id);
const current = currentGrants[authentikGroup] || [];
let newPerms: string[];
// Build combined updates across all affected groups
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] || []));
}
};
initGroup(authentikGroup);
if (selectAll) {
const permSet = new Set([...current, ...fgPermIds]);
newPerms = Array.from(permSet);
// 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) {
if (!allGroups.includes(inheritor) || inheritor === 'dashboard_admin') continue;
initGroup(inheritor);
addPermWithDeps(allUpdates.get(inheritor)!, permId);
}
}
} else {
const removeSet = new Set(fgPermIds);
newPerms = current.filter(p => !removeSet.has(p));
// 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) {
if (!allGroups.includes(lower) || lower === 'dashboard_admin') continue;
initGroup(lower);
removePermWithDependents(allUpdates.get(lower)!, permId);
}
}
}
// 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] || []);
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);
}
permissionMutation.mutate({ group: authentikGroup, permissions: newPerms });
},
[permissionMutation]
[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(
(permId: string): string => {
const deps = PERMISSION_DEPS[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;
});
return labels.length > 0 ? `Benötigt: ${labels.join(', ')}` : '';
},
[allPermissionIds, matrix],
);
if (isLoading || !matrix) {
@@ -114,6 +394,29 @@ function PermissionMatrixTab() {
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>
@@ -162,13 +465,22 @@ 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 }}>
dashboard_admin
admin
</TableCell>
</Tooltip>
{nonAdminGroups.map(g => (
@@ -215,7 +527,7 @@ function PermissionMatrixTab() {
{/* Per-group: select all / deselect all */}
{nonAdminGroups.map(g => {
const groupGrants = grants[g] || [];
const allGranted = 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));
return (
<TableCell key={g} align="center">
@@ -223,7 +535,7 @@ function PermissionMatrixTab() {
checked={allGranted}
indeterminate={someGranted && !allGranted}
onChange={() =>
handleSelectAllForGroup(g, fg.id, permissions, grants, !allGranted)
handleSelectAllForGroup(g, fg.id, permissions, grants, groups, !allGranted)
}
disabled={permissionMutation.isPending}
size="small"
@@ -239,41 +551,47 @@ function PermissionMatrixTab() {
<Collapse in={isExpanded} timeout="auto" unmountOnExit>
<Table size="small">
<TableBody>
{fgPerms.map((perm: Permission) => (
<TableRow key={perm.id} hover>
<TableCell
sx={{
pl: 6,
minWidth: 250,
position: 'sticky',
left: 0,
zIndex: 1,
bgcolor: 'background.paper',
}}
>
<Tooltip title={perm.description || ''} 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);
return (
<TableCell key={g} align="center" sx={{ minWidth: 120 }}>
<Checkbox
checked={isGranted}
onChange={() => handlePermissionToggle(g, perm.id, grants)}
disabled={permissionMutation.isPending}
size="small"
/>
</TableCell>
);
})}
</TableRow>
))}
{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>
{/* 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);
return (
<TableCell key={g} align="center" sx={{ minWidth: 120 }}>
<Checkbox
checked={isGranted}
onChange={() =>
handlePermissionToggle(g, perm.id, grants, groups)
}
disabled={permissionMutation.isPending}
size="small"
/>
</TableCell>
);
})}
</TableRow>
);
})}
</TableBody>
</Table>
</Collapse>

View File

@@ -40,7 +40,7 @@ function makeDefaults() {
const EventQuickAddWidget: React.FC = () => {
const { hasPermission } = usePermissionContext();
const canWrite = hasPermission('kalender:create_events');
const canWrite = hasPermission('kalender:create');
const defaults = makeDefaults();
const [titel, setTitel] = useState('');

View File

@@ -1,3 +1,4 @@
import React from 'react';
import { Box, Typography } from '@mui/material';
interface WidgetGroupProps {
@@ -7,6 +8,11 @@ interface WidgetGroupProps {
}
function WidgetGroup({ title, children, gridColumn }: WidgetGroupProps) {
// Count non-null children to hide empty groups
const validChildren = React.Children.toArray(children).filter(Boolean);
if (validChildren.length === 0) return null;
return (
<Box
sx={{

View File

@@ -74,7 +74,7 @@ const baseNavigationItems: NavigationItem[] = [
icon: <CalendarMonth />,
path: '/kalender',
subItems: kalenderSubItems,
permission: 'kalender:access',
permission: 'kalender:view',
},
{
text: 'Fahrzeuge',
@@ -86,25 +86,25 @@ const baseNavigationItems: NavigationItem[] = [
text: 'Ausrüstung',
icon: <Build />,
path: '/ausruestung',
permission: 'ausruestung:access',
permission: 'ausruestung:view',
},
{
text: 'Mitglieder',
icon: <People />,
path: '/mitglieder',
permission: 'mitglieder:access',
permission: 'mitglieder:view_own',
},
{
text: 'Atemschutz',
icon: <Air />,
path: '/atemschutz',
permission: 'atemschutz:access',
permission: 'atemschutz:view',
},
{
text: 'Wissen',
icon: <MenuBook />,
path: '/wissen',
permission: 'wissen:access',
permission: 'wissen:view',
},
];
@@ -130,7 +130,7 @@ function Sidebar({ mobileOpen, onMobileClose }: SidebarProps) {
const navigate = useNavigate();
const location = useLocation();
const { sidebarCollapsed, toggleSidebar } = useLayout();
const { hasPermission, isAdmin } = usePermissionContext();
const { hasPermission } = usePermissionContext();
// Fetch vehicle list for dynamic dropdown sub-items
const { data: vehicleList } = useQuery({
@@ -154,13 +154,13 @@ function Sidebar({ mobileOpen, onMobileClose }: SidebarProps) {
icon: <DirectionsCar />,
path: '/fahrzeuge',
subItems: vehicleSubItems.length > 0 ? vehicleSubItems : undefined,
permission: 'fahrzeuge:access',
permission: 'fahrzeuge:view',
};
const items = baseNavigationItems
.map((item) => item.path === '/fahrzeuge' ? fahrzeugeItem : item)
.filter((item) => !item.permission || hasPermission(item.permission));
return isAdmin ? [...items, adminItem, adminSettingsItem] : items;
}, [isAdmin, vehicleSubItems, hasPermission]);
return hasPermission('admin:view') ? [...items, adminItem, adminSettingsItem] : items;
}, [vehicleSubItems, hasPermission]);
// Expand state for items with sub-items — auto-expand when route matches
const [expandedItems, setExpandedItems] = useState<Record<string, boolean>>({});

View File

@@ -10,7 +10,7 @@ import BannerManagementTab from '../components/admin/BannerManagementTab';
import ServiceModeTab from '../components/admin/ServiceModeTab';
import FdiskSyncTab from '../components/admin/FdiskSyncTab';
import PermissionMatrixTab from '../components/admin/PermissionMatrixTab';
import { useAuth } from '../contexts/AuthContext';
import { usePermissionContext } from '../contexts/PermissionContext';
interface TabPanelProps {
children: React.ReactNode;
@@ -37,11 +37,9 @@ function AdminDashboard() {
const t = Number(searchParams.get('tab'));
if (t >= 0 && t < ADMIN_TAB_COUNT) setTab(t);
}, [searchParams]);
const { user } = useAuth();
const { hasPermission } = usePermissionContext();
const isAdmin = user?.groups?.includes('dashboard_admin') ?? false;
if (!isAdmin) {
if (!hasPermission('admin:view')) {
return <Navigate to="/dashboard" replace />;
}

View File

@@ -34,7 +34,7 @@ import { WidgetKey } from '../constants/widgets';
function Dashboard() {
const { user } = useAuth();
const { hasPermission, isAdmin } = usePermissionContext();
const { hasPermission } = usePermissionContext();
const [dataLoading, setDataLoading] = useState(true);
const { data: preferences } = useQuery({
@@ -99,7 +99,7 @@ function Dashboard() {
{/* Status Group */}
<WidgetGroup title="Status" gridColumn="1 / -1">
{widgetVisible('vehicles') && (
{hasPermission('fahrzeuge:widget') && widgetVisible('vehicles') && (
<Fade in={!dataLoading} timeout={600} style={{ transitionDelay: '300ms' }}>
<Box>
<VehicleDashboardCard />
@@ -107,7 +107,7 @@ function Dashboard() {
</Fade>
)}
{widgetVisible('equipment') && (
{hasPermission('ausruestung:widget') && widgetVisible('equipment') && (
<Fade in={!dataLoading} timeout={600} style={{ transitionDelay: '350ms' }}>
<Box>
<EquipmentDashboardCard />
@@ -123,7 +123,7 @@ function Dashboard() {
</Fade>
)}
{isAdmin && widgetVisible('adminStatus') && (
{hasPermission('admin:view') && widgetVisible('adminStatus') && (
<Fade in={!dataLoading} timeout={600} style={{ transitionDelay: '440ms' }}>
<Box>
<AdminStatusWidget />
@@ -134,7 +134,7 @@ function Dashboard() {
{/* Kalender Group */}
<WidgetGroup title="Kalender" gridColumn="1 / -1">
{widgetVisible('events') && (
{hasPermission('kalender:widget_events') && widgetVisible('events') && (
<Fade in={!dataLoading} timeout={600} style={{ transitionDelay: '480ms' }}>
<Box>
<UpcomingEventsWidget />
@@ -142,7 +142,7 @@ function Dashboard() {
</Fade>
)}
{widgetVisible('vehicleBookingList') && (
{hasPermission('kalender:widget_bookings') && widgetVisible('vehicleBookingList') && (
<Fade in={!dataLoading} timeout={600} style={{ transitionDelay: '520ms' }}>
<Box>
<VehicleBookingListWidget />
@@ -150,7 +150,7 @@ function Dashboard() {
</Fade>
)}
{widgetVisible('vehicleBooking') && (
{hasPermission('kalender:create_bookings') && widgetVisible('vehicleBooking') && (
<Fade in={!dataLoading} timeout={600} style={{ transitionDelay: '560ms' }}>
<Box>
<VehicleBookingQuickAddWidget />
@@ -169,7 +169,7 @@ function Dashboard() {
{/* Dienste Group */}
<WidgetGroup title="Dienste" gridColumn="1 / -1">
{widgetVisible('bookstackRecent') && (
{hasPermission('wissen:widget_recent') && widgetVisible('bookstackRecent') && (
<Fade in={!dataLoading} timeout={600} style={{ transitionDelay: '600ms' }}>
<Box>
<BookStackRecentWidget />
@@ -177,7 +177,7 @@ function Dashboard() {
</Fade>
)}
{widgetVisible('bookstackSearch') && (
{hasPermission('wissen:widget_search') && widgetVisible('bookstackSearch') && (
<Fade in={!dataLoading} timeout={600} style={{ transitionDelay: '640ms' }}>
<Box>
<BookStackSearchWidget />
@@ -185,7 +185,7 @@ function Dashboard() {
</Fade>
)}
{widgetVisible('vikunjaTasks') && (
{hasPermission('vikunja:widget_tasks') && widgetVisible('vikunjaTasks') && (
<Fade in={!dataLoading} timeout={600} style={{ transitionDelay: '680ms' }}>
<Box>
<VikunjaMyTasksWidget />
@@ -193,7 +193,7 @@ function Dashboard() {
</Fade>
)}
{widgetVisible('vikunjaQuickAdd') && (
{hasPermission('vikunja:widget_quick_add') && widgetVisible('vikunjaQuickAdd') && (
<Fade in={!dataLoading} timeout={600} style={{ transitionDelay: '720ms' }}>
<Box>
<VikunjaQuickAddWidget />
@@ -204,7 +204,7 @@ function Dashboard() {
{/* Information Group */}
<WidgetGroup title="Information" gridColumn="1 / -1">
{widgetVisible('links') && linkCollections.map((collection, idx) => (
{hasPermission('dashboard:widget_links') && widgetVisible('links') && linkCollections.map((collection, idx) => (
<Fade key={collection.id} in={!dataLoading} timeout={600} style={{ transitionDelay: `${760 + idx * 40}ms` }}>
<Box>
<LinksWidget collection={collection} />
@@ -212,11 +212,13 @@ function Dashboard() {
</Fade>
))}
<Fade in={!dataLoading} timeout={600} style={{ transitionDelay: `${760 + linkCollections.length * 40}ms` }}>
<Box>
<BannerWidget />
</Box>
</Fade>
{hasPermission('dashboard:widget_banner') && (
<Fade in={!dataLoading} timeout={600} style={{ transitionDelay: `${760 + linkCollections.length * 40}ms` }}>
<Box>
<BannerWidget />
</Box>
</Fade>
)}
</WidgetGroup>
</Box>
</Container>

View File

@@ -96,6 +96,7 @@ function FahrzeugBuchungen() {
const notification = useNotification();
const canCreate = hasPermission('kalender:create_bookings');
const canWrite = hasPermission('kalender:edit_bookings');
const canCancelOwn = hasPermission('kalender:cancel_own_bookings');
const canChangeBuchungsArt = hasPermission('kalender:manage_categories');
// ── Week navigation ────────────────────────────────────────────────────────
@@ -691,7 +692,7 @@ function FahrzeugBuchungen() {
Von: {detailBooking.gebucht_von_name}
</Typography>
)}
{(canWrite || detailBooking.gebucht_von === user?.id) && (
{(canWrite || (canCancelOwn && detailBooking.gebucht_von === user?.id)) && (
<Box sx={{ mt: 1.5, display: 'flex', gap: 1 }}>
{canWrite && (
<Button

View File

@@ -1707,7 +1707,7 @@ export default function Kalender() {
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('sm'));
const canWriteEvents = hasPermission('kalender:create_events');
const canWriteEvents = hasPermission('kalender:create');
const canWriteBookings = hasPermission('kalender:edit_bookings');
const canCreateBookings = hasPermission('kalender:create_bookings');

View File

@@ -80,7 +80,7 @@ function Mitglieder() {
// --- redirect non-privileged users to their own profile ---
useEffect(() => {
if (!user) return;
if (!hasPermission('mitglieder:edit')) {
if (!hasPermission('mitglieder:view_all')) {
navigate(`/mitglieder/${(user as any).id}`, { replace: true });
}
}, [user, navigate, hasPermission]);

View File

@@ -1074,7 +1074,7 @@ export default function Veranstaltungen() {
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('sm'));
const canWrite = hasPermission('kalender:create_events');
const canWrite = hasPermission('kalender:create');
const today = new Date();
const [viewMonth, setViewMonth] = useState({ year: today.getFullYear(), month: today.getMonth() });

View File

@@ -24,4 +24,9 @@ export const permissionsApi = {
setMaintenanceFlag: async (featureGroup: string, active: boolean): Promise<void> => {
await api.put(`/api/permissions/admin/maintenance/${encodeURIComponent(featureGroup)}`, { active });
},
getUnknownGroups: async (): Promise<string[]> => {
const r = await api.get('/api/permissions/admin/unknown-groups');
return r.data.data;
},
};