rights system
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import { Routes, Route } from 'react-router-dom';
|
||||
import { NotificationProvider } from './contexts/NotificationContext';
|
||||
import { AuthProvider } from './contexts/AuthContext';
|
||||
import { PermissionProvider } from './contexts/PermissionContext';
|
||||
import ErrorBoundary from './components/shared/ErrorBoundary';
|
||||
import ProtectedRoute from './components/auth/ProtectedRoute';
|
||||
import LoginCallback from './components/auth/LoginCallback';
|
||||
@@ -34,6 +35,7 @@ function App() {
|
||||
<ErrorBoundary>
|
||||
<NotificationProvider>
|
||||
<AuthProvider>
|
||||
<PermissionProvider>
|
||||
<Routes>
|
||||
<Route path="/" element={<Login />} />
|
||||
<Route path="/login" element={<Login />} />
|
||||
@@ -232,6 +234,7 @@ function App() {
|
||||
/>
|
||||
<Route path="*" element={<NotFound />} />
|
||||
</Routes>
|
||||
</PermissionProvider>
|
||||
</AuthProvider>
|
||||
</NotificationProvider>
|
||||
</ErrorBoundary>
|
||||
|
||||
294
frontend/src/components/admin/PermissionMatrixTab.tsx
Normal file
294
frontend/src/components/admin/PermissionMatrixTab.tsx
Normal file
@@ -0,0 +1,294 @@
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Card,
|
||||
CardContent,
|
||||
Checkbox,
|
||||
Chip,
|
||||
CircularProgress,
|
||||
Collapse,
|
||||
FormControlLabel,
|
||||
IconButton,
|
||||
Switch,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableRow,
|
||||
Tooltip,
|
||||
Typography,
|
||||
} from '@mui/material';
|
||||
import { ExpandMore, ExpandLess } 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';
|
||||
|
||||
function PermissionMatrixTab() {
|
||||
const queryClient = useQueryClient();
|
||||
const { showSuccess, showError } = useNotification();
|
||||
|
||||
const { data: matrix, isLoading } = useQuery<PermissionMatrix>({
|
||||
queryKey: ['admin-permission-matrix'],
|
||||
queryFn: permissionsApi.getMatrix,
|
||||
});
|
||||
|
||||
// Track which feature groups are expanded
|
||||
const [expandedGroups, setExpandedGroups] = useState<Record<string, boolean>>({});
|
||||
|
||||
const toggleGroup = (groupId: string) => {
|
||||
setExpandedGroups(prev => ({ ...prev, [groupId]: !prev[groupId] }));
|
||||
};
|
||||
|
||||
// ── Maintenance toggle mutation ──
|
||||
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'),
|
||||
});
|
||||
|
||||
// ── Permission toggle mutation (saves full group permissions) ──
|
||||
const permissionMutation = useMutation({
|
||||
mutationFn: ({ group, permissions }: { group: string; permissions: string[] }) =>
|
||||
permissionsApi.setGroupPermissions(group, permissions),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-permission-matrix'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['my-permissions'] });
|
||||
showSuccess('Berechtigungen gespeichert');
|
||||
},
|
||||
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 });
|
||||
},
|
||||
[permissionMutation]
|
||||
);
|
||||
|
||||
const handleSelectAllForGroup = useCallback(
|
||||
(
|
||||
authentikGroup: string,
|
||||
featureGroupId: string,
|
||||
permissions: Permission[],
|
||||
currentGrants: Record<string, string[]>,
|
||||
selectAll: boolean
|
||||
) => {
|
||||
const fgPermIds = permissions
|
||||
.filter(p => p.feature_group_id === featureGroupId)
|
||||
.map(p => p.id);
|
||||
const current = currentGrants[authentikGroup] || [];
|
||||
let newPerms: string[];
|
||||
if (selectAll) {
|
||||
const permSet = new Set([...current, ...fgPermIds]);
|
||||
newPerms = Array.from(permSet);
|
||||
} else {
|
||||
const removeSet = new Set(fgPermIds);
|
||||
newPerms = current.filter(p => !removeSet.has(p));
|
||||
}
|
||||
permissionMutation.mutate({ group: authentikGroup, permissions: newPerms });
|
||||
},
|
||||
[permissionMutation]
|
||||
);
|
||||
|
||||
if (isLoading || !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 }}>
|
||||
{/* 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: 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>
|
||||
|
||||
<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>
|
||||
{/* 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
|
||||
</TableCell>
|
||||
</Tooltip>
|
||||
{nonAdminGroups.map(g => (
|
||||
<TableCell key={g} align="center" sx={{ minWidth: 120, fontWeight: 'bold' }}>
|
||||
{g.replace('dashboard_', '')}
|
||||
</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; // default expanded
|
||||
|
||||
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)}
|
||||
>
|
||||
<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>
|
||||
{/* Admin: all checked */}
|
||||
<TableCell align="center">
|
||||
<Checkbox checked disabled sx={{ opacity: 0.3 }} />
|
||||
</TableCell>
|
||||
{/* Per-group: select all / deselect all */}
|
||||
{nonAdminGroups.map(g => {
|
||||
const groupGrants = grants[g] || [];
|
||||
const allGranted = 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, !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>
|
||||
<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>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Collapse>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export default PermissionMatrixTab;
|
||||
@@ -8,16 +8,13 @@ import {
|
||||
Button,
|
||||
Switch,
|
||||
FormControlLabel,
|
||||
Skeleton,
|
||||
} from '@mui/material';
|
||||
import { CalendarMonth } from '@mui/icons-material';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { eventsApi } from '../../services/events';
|
||||
import type { CreateVeranstaltungInput } from '../../types/events.types';
|
||||
import { useNotification } from '../../contexts/NotificationContext';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
|
||||
const WRITE_GROUPS = ['dashboard_admin', 'dashboard_kommando', 'dashboard_moderator', 'dashboard_gruppenfuehrer'];
|
||||
import { usePermissionContext } from '../../contexts/PermissionContext';
|
||||
|
||||
function toDatetimeLocal(date: Date): string {
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
@@ -42,8 +39,8 @@ function makeDefaults() {
|
||||
}
|
||||
|
||||
const EventQuickAddWidget: React.FC = () => {
|
||||
const { user } = useAuth();
|
||||
const canWrite = user?.groups?.some(g => WRITE_GROUPS.includes(g)) ?? false;
|
||||
const { hasPermission } = usePermissionContext();
|
||||
const canWrite = hasPermission('kalender:create_events');
|
||||
|
||||
const defaults = makeDefaults();
|
||||
const [titel, setTitel] = useState('');
|
||||
|
||||
@@ -29,7 +29,7 @@ import {
|
||||
import { useNavigate, useLocation } from 'react-router-dom';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useLayout, DRAWER_WIDTH, DRAWER_WIDTH_COLLAPSED } from '../../contexts/LayoutContext';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import { usePermissionContext } from '../../contexts/PermissionContext';
|
||||
import { vehiclesApi } from '../../services/vehicles';
|
||||
|
||||
export { DRAWER_WIDTH, DRAWER_WIDTH_COLLAPSED };
|
||||
@@ -44,6 +44,7 @@ interface NavigationItem {
|
||||
icon: JSX.Element;
|
||||
path: string;
|
||||
subItems?: SubItem[];
|
||||
permission?: string;
|
||||
}
|
||||
|
||||
const kalenderSubItems: SubItem[] = [
|
||||
@@ -58,6 +59,8 @@ const adminSubItems: SubItem[] = [
|
||||
{ text: 'Broadcast', path: '/admin?tab=3' },
|
||||
{ text: 'Banner', path: '/admin?tab=4' },
|
||||
{ text: 'Wartung', path: '/admin?tab=5' },
|
||||
{ text: 'FDISK Sync', path: '/admin?tab=6' },
|
||||
{ text: 'Berechtigungen', path: '/admin?tab=7' },
|
||||
];
|
||||
|
||||
const baseNavigationItems: NavigationItem[] = [
|
||||
@@ -71,31 +74,37 @@ const baseNavigationItems: NavigationItem[] = [
|
||||
icon: <CalendarMonth />,
|
||||
path: '/kalender',
|
||||
subItems: kalenderSubItems,
|
||||
permission: 'kalender:access',
|
||||
},
|
||||
{
|
||||
text: 'Fahrzeuge',
|
||||
icon: <DirectionsCar />,
|
||||
path: '/fahrzeuge',
|
||||
permission: 'fahrzeuge:access',
|
||||
},
|
||||
{
|
||||
text: 'Ausrüstung',
|
||||
icon: <Build />,
|
||||
path: '/ausruestung',
|
||||
permission: 'ausruestung:access',
|
||||
},
|
||||
{
|
||||
text: 'Mitglieder',
|
||||
icon: <People />,
|
||||
path: '/mitglieder',
|
||||
permission: 'mitglieder:access',
|
||||
},
|
||||
{
|
||||
text: 'Atemschutz',
|
||||
icon: <Air />,
|
||||
path: '/atemschutz',
|
||||
permission: 'atemschutz:access',
|
||||
},
|
||||
{
|
||||
text: 'Wissen',
|
||||
icon: <MenuBook />,
|
||||
path: '/wissen',
|
||||
permission: 'wissen:access',
|
||||
},
|
||||
];
|
||||
|
||||
@@ -121,9 +130,7 @@ function Sidebar({ mobileOpen, onMobileClose }: SidebarProps) {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { sidebarCollapsed, toggleSidebar } = useLayout();
|
||||
const { user } = useAuth();
|
||||
|
||||
const isAdmin = user?.groups?.includes('dashboard_admin') ?? false;
|
||||
const { hasPermission, isAdmin } = usePermissionContext();
|
||||
|
||||
// Fetch vehicle list for dynamic dropdown sub-items
|
||||
const { data: vehicleList } = useQuery({
|
||||
@@ -147,12 +154,13 @@ function Sidebar({ mobileOpen, onMobileClose }: SidebarProps) {
|
||||
icon: <DirectionsCar />,
|
||||
path: '/fahrzeuge',
|
||||
subItems: vehicleSubItems.length > 0 ? vehicleSubItems : undefined,
|
||||
permission: 'fahrzeuge:access',
|
||||
};
|
||||
const items = baseNavigationItems.map((item) =>
|
||||
item.path === '/fahrzeuge' ? fahrzeugeItem : item,
|
||||
);
|
||||
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]);
|
||||
}, [isAdmin, vehicleSubItems, hasPermission]);
|
||||
|
||||
// Expand state for items with sub-items — auto-expand when route matches
|
||||
const [expandedItems, setExpandedItems] = useState<Record<string, boolean>>({});
|
||||
|
||||
98
frontend/src/contexts/PermissionContext.tsx
Normal file
98
frontend/src/contexts/PermissionContext.tsx
Normal file
@@ -0,0 +1,98 @@
|
||||
import React, { createContext, useContext, useMemo, useCallback, ReactNode } from 'react';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useAuth } from './AuthContext';
|
||||
import { permissionsApi } from '../services/permissions';
|
||||
|
||||
interface PermissionContextType {
|
||||
permissions: Set<string>;
|
||||
maintenance: Record<string, boolean>;
|
||||
isAdmin: boolean;
|
||||
isLoading: boolean;
|
||||
hasPermission: (perm: string) => boolean;
|
||||
hasAnyPermission: (...perms: string[]) => boolean;
|
||||
isFeatureEnabled: (featureGroup: string) => boolean;
|
||||
refetch: () => void;
|
||||
}
|
||||
|
||||
const PermissionContext = createContext<PermissionContextType | undefined>(undefined);
|
||||
|
||||
interface PermissionProviderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export const PermissionProvider: React.FC<PermissionProviderProps> = ({ children }) => {
|
||||
const { isAuthenticated } = useAuth();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['my-permissions'],
|
||||
queryFn: permissionsApi.getMyPermissions,
|
||||
enabled: isAuthenticated,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
|
||||
const permissions = useMemo(
|
||||
() => new Set(data?.permissions ?? []),
|
||||
[data?.permissions]
|
||||
);
|
||||
|
||||
const maintenance = data?.maintenance ?? {};
|
||||
const isAdmin = data?.isAdmin ?? false;
|
||||
|
||||
const isFeatureEnabled = useCallback(
|
||||
(featureGroup: string): boolean => {
|
||||
if (isAdmin) return true;
|
||||
return !maintenance[featureGroup];
|
||||
},
|
||||
[isAdmin, maintenance]
|
||||
);
|
||||
|
||||
const hasPermission = useCallback(
|
||||
(perm: string): boolean => {
|
||||
if (isAdmin) return true;
|
||||
const featureGroup = perm.split(':')[0];
|
||||
if (!isFeatureEnabled(featureGroup)) return false;
|
||||
return permissions.has(perm);
|
||||
},
|
||||
[isAdmin, permissions, isFeatureEnabled]
|
||||
);
|
||||
|
||||
const hasAnyPermission = useCallback(
|
||||
(...perms: string[]): boolean => {
|
||||
return perms.some(p => hasPermission(p));
|
||||
},
|
||||
[hasPermission]
|
||||
);
|
||||
|
||||
const refetch = useCallback(() => {
|
||||
queryClient.invalidateQueries({ queryKey: ['my-permissions'] });
|
||||
}, [queryClient]);
|
||||
|
||||
const value = useMemo(
|
||||
(): PermissionContextType => ({
|
||||
permissions,
|
||||
maintenance,
|
||||
isAdmin,
|
||||
isLoading: isAuthenticated ? isLoading : false,
|
||||
hasPermission,
|
||||
hasAnyPermission,
|
||||
isFeatureEnabled,
|
||||
refetch,
|
||||
}),
|
||||
[permissions, maintenance, isAdmin, isAuthenticated, isLoading, hasPermission, hasAnyPermission, isFeatureEnabled, refetch]
|
||||
);
|
||||
|
||||
return (
|
||||
<PermissionContext.Provider value={value}>
|
||||
{children}
|
||||
</PermissionContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const usePermissionContext = (): PermissionContextType => {
|
||||
const context = useContext(PermissionContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('usePermissionContext must be used within a PermissionProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
@@ -1,27 +1,30 @@
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { usePermissionContext } from '../contexts/PermissionContext';
|
||||
import { AusruestungKategorie } from '../types/equipment.types';
|
||||
|
||||
export function usePermissions() {
|
||||
const { user } = useAuth();
|
||||
const groups = user?.groups ?? [];
|
||||
|
||||
const isAdmin = groups.includes('dashboard_admin');
|
||||
const isFahrmeister = groups.includes('dashboard_fahrmeister');
|
||||
const isZeugmeister = groups.includes('dashboard_zeugmeister');
|
||||
const { hasPermission, hasAnyPermission, isAdmin, isFeatureEnabled, isLoading, permissions, maintenance, refetch } = usePermissionContext();
|
||||
|
||||
return {
|
||||
// Core API
|
||||
hasPermission,
|
||||
hasAnyPermission,
|
||||
isFeatureEnabled,
|
||||
isAdmin,
|
||||
isFahrmeister,
|
||||
isZeugmeister,
|
||||
canChangeStatus: isAdmin || isFahrmeister || isZeugmeister,
|
||||
canManageEquipment: isAdmin || isFahrmeister || isZeugmeister,
|
||||
canManageMotorizedEquipment: isAdmin || isFahrmeister,
|
||||
canManageNonMotorizedEquipment: isAdmin || isZeugmeister,
|
||||
canManageCategory: (kategorie: AusruestungKategorie | null | undefined): boolean => {
|
||||
if (isAdmin) return true;
|
||||
if (!kategorie) return false;
|
||||
return kategorie.motorisiert ? isFahrmeister : isZeugmeister;
|
||||
isLoading,
|
||||
permissions,
|
||||
maintenance,
|
||||
refetch,
|
||||
|
||||
// Backward-compatible convenience flags
|
||||
isFahrmeister: false, // No longer needed — use hasPermission() instead
|
||||
isZeugmeister: false, // No longer needed — use hasPermission() instead
|
||||
canChangeStatus: hasPermission('fahrzeuge:change_status'),
|
||||
canManageEquipment: hasPermission('ausruestung:create'),
|
||||
canManageMotorizedEquipment: hasPermission('ausruestung:create'),
|
||||
canManageNonMotorizedEquipment: hasPermission('ausruestung:create'),
|
||||
canManageCategory: (_kategorie: AusruestungKategorie | null | undefined): boolean => {
|
||||
return hasPermission('ausruestung:create');
|
||||
},
|
||||
groups,
|
||||
groups: [] as string[], // Deprecated — use hasPermission() instead
|
||||
};
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import NotificationBroadcastTab from '../components/admin/NotificationBroadcastT
|
||||
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';
|
||||
|
||||
interface TabPanelProps {
|
||||
@@ -22,7 +23,7 @@ function TabPanel({ children, value, index }: TabPanelProps) {
|
||||
return <Box sx={{ pt: 3 }}>{children}</Box>;
|
||||
}
|
||||
|
||||
const ADMIN_TAB_COUNT = 7;
|
||||
const ADMIN_TAB_COUNT = 8;
|
||||
|
||||
function AdminDashboard() {
|
||||
const navigate = useNavigate();
|
||||
@@ -57,6 +58,7 @@ function AdminDashboard() {
|
||||
<Tab label="Banner" />
|
||||
<Tab label="Wartung" />
|
||||
<Tab label="FDISK Sync" />
|
||||
<Tab label="Berechtigungen" />
|
||||
</Tabs>
|
||||
</Box>
|
||||
|
||||
@@ -81,6 +83,9 @@ function AdminDashboard() {
|
||||
<TabPanel value={tab} index={6}>
|
||||
<FdiskSyncTab />
|
||||
</TabPanel>
|
||||
<TabPanel value={tab} index={7}>
|
||||
<PermissionMatrixTab />
|
||||
</TabPanel>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -45,7 +45,7 @@ import ChatAwareFab from '../components/shared/ChatAwareFab';
|
||||
import { atemschutzApi } from '../services/atemschutz';
|
||||
import { membersService } from '../services/members';
|
||||
import { useNotification } from '../contexts/NotificationContext';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { usePermissionContext } from '../contexts/PermissionContext';
|
||||
import { toGermanDate, fromGermanDate, isValidGermanDate } from '../utils/dateInput';
|
||||
import type {
|
||||
AtemschutzUebersicht,
|
||||
@@ -142,10 +142,9 @@ const StatCard: React.FC<StatCardProps> = ({ label, value, color, bgcolor }) =>
|
||||
|
||||
function Atemschutz() {
|
||||
const notification = useNotification();
|
||||
const { user } = useAuth();
|
||||
const ATEMSCHUTZ_PRIVILEGED = ['dashboard_admin', 'dashboard_kommando', 'dashboard_atemschutz', 'dashboard_moderator'];
|
||||
const canViewAll = user?.groups?.some(g => ATEMSCHUTZ_PRIVILEGED.includes(g)) ?? false;
|
||||
const canWrite = canViewAll;
|
||||
const { hasPermission } = usePermissionContext();
|
||||
const canViewAll = hasPermission('atemschutz:view');
|
||||
const canWrite = hasPermission('atemschutz:create');
|
||||
|
||||
// Data state
|
||||
const [traeger, setTraeger] = useState<AtemschutzUebersicht[]>([]);
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
} from '@mui/material';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { usePermissionContext } from '../contexts/PermissionContext';
|
||||
import DashboardLayout from '../components/dashboard/DashboardLayout';
|
||||
import SkeletonCard from '../components/shared/SkeletonCard';
|
||||
import UserProfile from '../components/dashboard/UserProfile';
|
||||
@@ -33,13 +34,7 @@ import { WidgetKey } from '../constants/widgets';
|
||||
|
||||
function Dashboard() {
|
||||
const { user } = useAuth();
|
||||
const isAdmin = user?.groups?.includes('dashboard_admin') ?? false;
|
||||
const canViewAtemschutz = user?.groups?.some(g =>
|
||||
['dashboard_admin', 'dashboard_kommando', 'dashboard_atemschutz', 'dashboard_moderator'].includes(g)
|
||||
) ?? false;
|
||||
const canWrite = user?.groups?.some(g =>
|
||||
['dashboard_admin', 'dashboard_kommando', 'dashboard_moderator', 'dashboard_gruppenfuehrer'].includes(g)
|
||||
) ?? false;
|
||||
const { hasPermission, isAdmin } = usePermissionContext();
|
||||
const [dataLoading, setDataLoading] = useState(true);
|
||||
|
||||
const { data: preferences } = useQuery({
|
||||
@@ -120,7 +115,7 @@ function Dashboard() {
|
||||
</Fade>
|
||||
)}
|
||||
|
||||
{canViewAtemschutz && widgetVisible('atemschutz') && (
|
||||
{hasPermission('atemschutz:widget') && widgetVisible('atemschutz') && (
|
||||
<Fade in={!dataLoading} timeout={600} style={{ transitionDelay: '400ms' }}>
|
||||
<Box>
|
||||
<AtemschutzDashboardCard />
|
||||
@@ -163,7 +158,7 @@ function Dashboard() {
|
||||
</Fade>
|
||||
)}
|
||||
|
||||
{canWrite && widgetVisible('eventQuickAdd') && (
|
||||
{hasPermission('kalender:widget_quick_add') && widgetVisible('eventQuickAdd') && (
|
||||
<Fade in={!dataLoading} timeout={600} style={{ transitionDelay: '600ms' }}>
|
||||
<Box>
|
||||
<EventQuickAddWidget />
|
||||
|
||||
@@ -49,7 +49,7 @@ import {
|
||||
EINSATZ_STATUS_LABELS,
|
||||
} from '../services/incidents';
|
||||
import CreateEinsatzDialog from '../components/incidents/CreateEinsatzDialog';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { usePermissionContext } from '../contexts/PermissionContext';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// COLOUR MAP for Einsatzart chips
|
||||
@@ -176,10 +176,8 @@ function StatsSummaryBar({ stats, loading }: StatsSummaryProps) {
|
||||
// ---------------------------------------------------------------------------
|
||||
function Einsaetze() {
|
||||
const navigate = useNavigate();
|
||||
const { user } = useAuth();
|
||||
const canWrite = user?.groups?.some((g: string) =>
|
||||
['dashboard_admin', 'dashboard_kommando', 'dashboard_gruppenfuehrer'].includes(g)
|
||||
) ?? false;
|
||||
const { hasPermission } = usePermissionContext();
|
||||
const canWrite = hasPermission('einsaetze:create');
|
||||
|
||||
// List state
|
||||
const [items, setItems] = useState<EinsatzListItem[]>([]);
|
||||
|
||||
@@ -43,7 +43,7 @@ import {
|
||||
EinsatzArt,
|
||||
} from '../services/incidents';
|
||||
import { useNotification } from '../contexts/NotificationContext';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { usePermissionContext } from '../contexts/PermissionContext';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// COLOUR MAPS
|
||||
@@ -165,10 +165,8 @@ function EinsatzDetail() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const notification = useNotification();
|
||||
const { user } = useAuth();
|
||||
const canWrite = user?.groups?.some((g: string) =>
|
||||
['dashboard_admin', 'dashboard_kommando', 'dashboard_gruppenfuehrer'].includes(g)
|
||||
) ?? false;
|
||||
const { hasPermission } = usePermissionContext();
|
||||
const canWrite = hasPermission('einsaetze:create');
|
||||
|
||||
const [einsatz, setEinsatz] = useState<EinsatzDetailType | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
@@ -46,6 +46,7 @@ import {
|
||||
import DashboardLayout from '../components/dashboard/DashboardLayout';
|
||||
import ChatAwareFab from '../components/shared/ChatAwareFab';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { usePermissionContext } from '../contexts/PermissionContext';
|
||||
import { useNotification } from '../contexts/NotificationContext';
|
||||
import { bookingApi, fetchVehicles } from '../services/bookings';
|
||||
import type {
|
||||
@@ -85,21 +86,17 @@ const EMPTY_FORM: CreateBuchungInput = {
|
||||
kontaktTelefon: '',
|
||||
};
|
||||
|
||||
const WRITE_GROUPS = ['dashboard_admin', 'dashboard_fahrmeister', 'dashboard_moderator'];
|
||||
const MANAGE_ART_GROUPS = ['dashboard_admin', 'dashboard_moderator'];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main Page
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function FahrzeugBuchungen() {
|
||||
const { user } = useAuth();
|
||||
const { hasPermission } = usePermissionContext();
|
||||
const notification = useNotification();
|
||||
const canCreate = !!user; // All authenticated users can create bookings
|
||||
const canWrite =
|
||||
user?.groups?.some((g) => WRITE_GROUPS.includes(g)) ?? false; // Can edit/cancel
|
||||
const canChangeBuchungsArt =
|
||||
user?.groups?.some((g) => MANAGE_ART_GROUPS.includes(g)) ?? false; // Can change booking type
|
||||
const canCreate = hasPermission('kalender:create_bookings');
|
||||
const canWrite = hasPermission('kalender:edit_bookings');
|
||||
const canChangeBuchungsArt = hasPermission('kalender:manage_categories');
|
||||
|
||||
// ── Week navigation ────────────────────────────────────────────────────────
|
||||
const [currentWeekStart, setCurrentWeekStart] = useState<Date>(() =>
|
||||
|
||||
@@ -72,6 +72,7 @@ import DashboardLayout from '../components/dashboard/DashboardLayout';
|
||||
import ChatAwareFab from '../components/shared/ChatAwareFab';
|
||||
import { toGermanDateTime, fromGermanDate, fromGermanDateTime, isValidGermanDateTime } from '../utils/dateInput';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { usePermissionContext } from '../contexts/PermissionContext';
|
||||
import { useNotification } from '../contexts/NotificationContext';
|
||||
import { trainingApi } from '../services/training';
|
||||
import { eventsApi } from '../services/events';
|
||||
@@ -117,9 +118,6 @@ import { de } from 'date-fns/locale';
|
||||
// Constants
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
const WRITE_GROUPS_EVENTS = ['dashboard_admin', 'dashboard_moderator'];
|
||||
const WRITE_GROUPS_BOOKINGS = ['dashboard_admin', 'dashboard_fahrmeister', 'dashboard_moderator'];
|
||||
|
||||
const WEEKDAY_LABELS = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'];
|
||||
const MONTH_LABELS = [
|
||||
'Januar', 'Februar', 'März', 'April', 'Mai', 'Juni',
|
||||
@@ -1704,15 +1702,14 @@ export default function Kalender() {
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const { user } = useAuth();
|
||||
const { hasPermission } = usePermissionContext();
|
||||
const notification = useNotification();
|
||||
const theme = useTheme();
|
||||
const isMobile = useMediaQuery(theme.breakpoints.down('sm'));
|
||||
|
||||
const canWriteEvents =
|
||||
user?.groups?.some((g) => WRITE_GROUPS_EVENTS.includes(g)) ?? false;
|
||||
const canWriteBookings =
|
||||
user?.groups?.some((g) => WRITE_GROUPS_BOOKINGS.includes(g)) ?? false;
|
||||
const canCreateBookings = !!user;
|
||||
const canWriteEvents = hasPermission('kalender:create_events');
|
||||
const canWriteBookings = hasPermission('kalender:edit_bookings');
|
||||
const canCreateBookings = hasPermission('kalender:create_bookings');
|
||||
|
||||
// ── Tab ─────────────────────────────────────────────────────────────────────
|
||||
const [activeTab, setActiveTab] = useState(() => {
|
||||
|
||||
@@ -40,6 +40,7 @@ import {
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import DashboardLayout from '../components/dashboard/DashboardLayout';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { usePermissionContext } from '../contexts/PermissionContext';
|
||||
import { membersService } from '../services/members';
|
||||
import { atemschutzApi } from '../services/atemschutz';
|
||||
import { toGermanDate, fromGermanDate } from '../utils/dateInput';
|
||||
@@ -67,9 +68,8 @@ import { UntersuchungErgebnisLabel } from '../types/atemschutz.types';
|
||||
// Role helpers
|
||||
// ----------------------------------------------------------------
|
||||
function useCanWrite(): boolean {
|
||||
const { user } = useAuth();
|
||||
const groups: string[] = (user as any)?.groups ?? [];
|
||||
return groups.includes('dashboard_admin') || groups.includes('dashboard_kommando');
|
||||
const { hasPermission } = usePermissionContext();
|
||||
return hasPermission('mitglieder:edit');
|
||||
}
|
||||
|
||||
function useCurrentUserId(): string | undefined {
|
||||
|
||||
@@ -34,6 +34,7 @@ import { useNavigate } from 'react-router-dom';
|
||||
import DashboardLayout from '../components/dashboard/DashboardLayout';
|
||||
import ChatAwareFab from '../components/shared/ChatAwareFab';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { usePermissionContext } from '../contexts/PermissionContext';
|
||||
import { membersService } from '../services/members';
|
||||
import {
|
||||
MemberListItem,
|
||||
@@ -51,9 +52,8 @@ import {
|
||||
// Helper: determine whether the current user can write member data
|
||||
// ----------------------------------------------------------------
|
||||
function useCanWrite(): boolean {
|
||||
const { user } = useAuth();
|
||||
const groups: string[] = (user as any)?.groups ?? [];
|
||||
return groups.includes('dashboard_admin') || groups.includes('dashboard_kommando');
|
||||
const { hasPermission } = usePermissionContext();
|
||||
return hasPermission('mitglieder:edit');
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
@@ -73,17 +73,17 @@ function useDebounce<T>(value: T, delay: number): T {
|
||||
// ----------------------------------------------------------------
|
||||
function Mitglieder() {
|
||||
const navigate = useNavigate();
|
||||
const { user } = useAuth(); const canWrite = useCanWrite();
|
||||
const { user } = useAuth();
|
||||
const canWrite = useCanWrite();
|
||||
const { hasPermission } = usePermissionContext();
|
||||
|
||||
// --- redirect non-admin/non-kommando users to their own profile ---
|
||||
// --- redirect non-privileged users to their own profile ---
|
||||
useEffect(() => {
|
||||
if (!user) return;
|
||||
const groups: string[] = (user as any)?.groups ?? [];
|
||||
const isAdmin = groups.includes('dashboard_admin') || groups.includes('dashboard_kommando');
|
||||
if (!isAdmin) {
|
||||
if (!hasPermission('mitglieder:edit')) {
|
||||
navigate(`/mitglieder/${(user as any).id}`, { replace: true });
|
||||
}
|
||||
}, [user, navigate]);
|
||||
}, [user, navigate, hasPermission]);
|
||||
|
||||
// --- data state ---
|
||||
const [members, setMembers] = useState<MemberListItem[]>([]);
|
||||
|
||||
@@ -34,7 +34,7 @@ import {
|
||||
Category as CategoryIcon,
|
||||
} from '@mui/icons-material';
|
||||
import DashboardLayout from '../components/dashboard/DashboardLayout';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { usePermissionContext } from '../contexts/PermissionContext';
|
||||
import { useNotification } from '../contexts/NotificationContext';
|
||||
import { eventsApi } from '../services/events';
|
||||
import type { VeranstaltungKategorie, GroupInfo } from '../types/events.types';
|
||||
@@ -298,10 +298,9 @@ function DeleteDialog({ open, kategorie, onClose, onDeleted }: DeleteDialogProps
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export default function VeranstaltungKategorien() {
|
||||
const { user } = useAuth();
|
||||
const { hasPermission } = usePermissionContext();
|
||||
|
||||
const canManage =
|
||||
user?.groups?.some((g) => ['dashboard_admin', 'dashboard_moderator'].includes(g)) ?? false;
|
||||
const canManage = hasPermission('kalender:manage_categories');
|
||||
|
||||
const [kategorien, setKategorien] = useState<VeranstaltungKategorie[]>([]);
|
||||
const [groups, setGroups] = useState<GroupInfo[]>([]);
|
||||
|
||||
@@ -52,7 +52,7 @@ import {
|
||||
import DashboardLayout from '../components/dashboard/DashboardLayout';
|
||||
import ChatAwareFab from '../components/shared/ChatAwareFab';
|
||||
import { toGermanDate, toGermanDateTime, fromGermanDate, fromGermanDateTime } from '../utils/dateInput';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { usePermissionContext } from '../contexts/PermissionContext';
|
||||
import { useNotification } from '../contexts/NotificationContext';
|
||||
import { eventsApi } from '../services/events';
|
||||
import type {
|
||||
@@ -1069,13 +1069,12 @@ function EventListView({ events, canWrite, onEdit, onCancel, onDelete }: ListVie
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export default function Veranstaltungen() {
|
||||
const { user } = useAuth();
|
||||
const { hasPermission } = usePermissionContext();
|
||||
const notification = useNotification();
|
||||
const theme = useTheme();
|
||||
const isMobile = useMediaQuery(theme.breakpoints.down('sm'));
|
||||
|
||||
const canWrite =
|
||||
user?.groups?.some((g) => ['dashboard_admin', 'dashboard_moderator'].includes(g)) ?? false;
|
||||
const canWrite = hasPermission('kalender:create_events');
|
||||
|
||||
const today = new Date();
|
||||
const [viewMonth, setViewMonth] = useState({ year: today.getFullYear(), month: today.getMonth() });
|
||||
|
||||
27
frontend/src/services/permissions.ts
Normal file
27
frontend/src/services/permissions.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { api } from './api';
|
||||
import type { MyPermissions, PermissionMatrix } from '../types/permissions.types';
|
||||
|
||||
export const permissionsApi = {
|
||||
getMyPermissions: async (): Promise<MyPermissions> => {
|
||||
const r = await api.get('/api/permissions/me');
|
||||
return r.data.data;
|
||||
},
|
||||
|
||||
getMatrix: async (): Promise<PermissionMatrix> => {
|
||||
const r = await api.get('/api/permissions/admin/matrix');
|
||||
return r.data.data;
|
||||
},
|
||||
|
||||
getGroups: async (): Promise<string[]> => {
|
||||
const r = await api.get('/api/permissions/admin/groups');
|
||||
return r.data.data;
|
||||
},
|
||||
|
||||
setGroupPermissions: async (group: string, permissions: string[]): Promise<void> => {
|
||||
await api.put(`/api/permissions/admin/group/${encodeURIComponent(group)}`, { permissions });
|
||||
},
|
||||
|
||||
setMaintenanceFlag: async (featureGroup: string, active: boolean): Promise<void> => {
|
||||
await api.put(`/api/permissions/admin/maintenance/${encodeURIComponent(featureGroup)}`, { active });
|
||||
},
|
||||
};
|
||||
28
frontend/src/types/permissions.types.ts
Normal file
28
frontend/src/types/permissions.types.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
export interface FeatureGroup {
|
||||
id: string;
|
||||
label: string;
|
||||
sort_order: number;
|
||||
maintenance: boolean;
|
||||
}
|
||||
|
||||
export interface Permission {
|
||||
id: string;
|
||||
feature_group_id: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
sort_order: number;
|
||||
}
|
||||
|
||||
export interface MyPermissions {
|
||||
permissions: string[];
|
||||
maintenance: Record<string, boolean>;
|
||||
isAdmin: boolean;
|
||||
}
|
||||
|
||||
export interface PermissionMatrix {
|
||||
featureGroups: FeatureGroup[];
|
||||
permissions: Permission[];
|
||||
groups: string[];
|
||||
grants: Record<string, string[]>;
|
||||
maintenance: Record<string, boolean>;
|
||||
}
|
||||
Reference in New Issue
Block a user