diff --git a/backend/src/middleware/rbac.middleware.ts b/backend/src/middleware/rbac.middleware.ts index 1b284cc..974a45e 100644 --- a/backend/src/middleware/rbac.middleware.ts +++ b/backend/src/middleware/rbac.middleware.ts @@ -63,6 +63,16 @@ const PERMISSION_ROLE_MIN: Record = { 'bookings:delete': 'admin', }; +/** + * Derive an AppRole from Authentik JWT groups (highest matching role wins). + */ +function roleFromGroups(groups: string[]): AppRole { + if (groups.includes('dashboard_admin')) return 'admin'; + if (groups.includes('dashboard_kommando')) return 'kommandant'; + if (groups.includes('dashboard_fahrmeister') || groups.includes('dashboard_zeugmeister')) return 'gruppenfuehrer'; + return 'mitglied'; +} + function hasPermission(role: AppRole, permission: string): boolean { const minRole = PERMISSION_ROLE_MIN[permission]; if (!minRole) { @@ -116,24 +126,16 @@ export function requirePermission(permission: string) { return; } - const role = (req.user as any).role + const dbRole = (req.user as any).role ? (req.user as any).role as AppRole : await getUserRole(req.user.id); + const groupRole = roleFromGroups(req.user?.groups ?? []); + const role = ROLE_HIERARCHY.indexOf(groupRole) > ROLE_HIERARCHY.indexOf(dbRole) ? groupRole : dbRole; // Attach role to request for downstream use (e.g., bericht_text redaction) (req as Request & { userRole?: AppRole }).userRole = role; if (!hasPermission(role, permission)) { - // Fallback: dashboard_admin group grants admin:access - if (permission === 'admin:access') { - const userGroups: string[] = req.user?.groups ?? []; - if (userGroups.includes('dashboard_admin')) { - (req as Request & { userRole?: AppRole }).userRole = 'admin'; - next(); - return; - } - } - logger.warn('Permission denied', { userId: req.user.id, role, diff --git a/frontend/src/components/chat/ChatPanel.tsx b/frontend/src/components/chat/ChatPanel.tsx index 02b68fd..97aaab2 100644 --- a/frontend/src/components/chat/ChatPanel.tsx +++ b/frontend/src/components/chat/ChatPanel.tsx @@ -49,16 +49,20 @@ const ChatPanelInner: React.FC = () => { // Mark unread rooms as read in Nextcloud whenever panel is open and rooms update React.useEffect(() => { if (!chatPanelOpen) return; - rooms.filter((r) => r.unreadMessages > 0).forEach((r) => { - nextcloudApi.markAsRead(r.token).catch(() => {}); + const unread = rooms.filter((r) => r.unreadMessages > 0); + if (unread.length === 0) return; + Promise.allSettled(unread.map((r) => nextcloudApi.markAsRead(r.token))).then(() => { + queryClient.invalidateQueries({ queryKey: ['nextcloud', 'rooms'] }); }); - }, [chatPanelOpen, rooms]); + }, [chatPanelOpen, rooms, queryClient]); // Mark the selected room as read when a conversation is opened React.useEffect(() => { if (!selectedRoomToken) return; - nextcloudApi.markAsRead(selectedRoomToken).catch(() => {}); - }, [selectedRoomToken]); + nextcloudApi.markAsRead(selectedRoomToken).then(() => { + queryClient.invalidateQueries({ queryKey: ['nextcloud', 'rooms'] }); + }).catch(() => {}); + }, [selectedRoomToken, queryClient]); if (!chatPanelOpen) { return ( diff --git a/frontend/src/pages/AdminDashboard.tsx b/frontend/src/pages/AdminDashboard.tsx index 867c642..842522d 100644 --- a/frontend/src/pages/AdminDashboard.tsx +++ b/frontend/src/pages/AdminDashboard.tsx @@ -1,6 +1,6 @@ import { useState, useEffect } from 'react'; import { Box, Tabs, Tab, Typography } from '@mui/material'; -import { Navigate, useSearchParams } from 'react-router-dom'; +import { Navigate, useNavigate, useSearchParams } from 'react-router-dom'; import DashboardLayout from '../components/dashboard/DashboardLayout'; import ServiceManagerTab from '../components/admin/ServiceManagerTab'; import SystemHealthTab from '../components/admin/SystemHealthTab'; @@ -24,6 +24,7 @@ function TabPanel({ children, value, index }: TabPanelProps) { const ADMIN_TAB_COUNT = 6; function AdminDashboard() { + const navigate = useNavigate(); const [searchParams] = useSearchParams(); const [tab, setTab] = useState(() => { const t = Number(searchParams.get('tab')); @@ -47,7 +48,7 @@ function AdminDashboard() { Administration - setTab(v)}> + { setTab(v); navigate(`/admin?tab=${v}`, { replace: true }); }}> diff --git a/frontend/src/pages/AusruestungDetail.tsx b/frontend/src/pages/AusruestungDetail.tsx index 3bc9091..d7861d5 100644 --- a/frontend/src/pages/AusruestungDetail.tsx +++ b/frontend/src/pages/AusruestungDetail.tsx @@ -159,8 +159,8 @@ const UebersichtTab: React.FC = ({ equipment, onStatusUpdate await equipmentApi.updateStatus(equipment.id, payload); setStatusDialogOpen(false); onStatusUpdated(); - } catch { - setSaveError('Status konnte nicht gespeichert werden.'); + } catch (err: any) { + setSaveError(err?.response?.data?.message || 'Status konnte nicht gespeichert werden.'); } finally { setSaving(false); } diff --git a/frontend/src/pages/FahrzeugDetail.tsx b/frontend/src/pages/FahrzeugDetail.tsx index d1a1251..ce22948 100644 --- a/frontend/src/pages/FahrzeugDetail.tsx +++ b/frontend/src/pages/FahrzeugDetail.tsx @@ -153,8 +153,8 @@ const UebersichtTab: React.FC = ({ vehicle, onStatusUpdated, await vehiclesApi.updateStatus(vehicle.id, payload); setStatusDialogOpen(false); onStatusUpdated(); - } catch { - setSaveError('Status konnte nicht gespeichert werden.'); + } catch (err: any) { + setSaveError(err?.response?.data?.message || 'Status konnte nicht gespeichert werden.'); } finally { setSaving(false); } diff --git a/frontend/src/pages/Kalender.tsx b/frontend/src/pages/Kalender.tsx index a9fc4b6..a0f30f0 100644 --- a/frontend/src/pages/Kalender.tsx +++ b/frontend/src/pages/Kalender.tsx @@ -2158,7 +2158,7 @@ export default function Kalender() { {/* Tabs */} setActiveTab(v)} + onChange={(_, v) => { setActiveTab(v); navigate(`/kalender?tab=${v}`, { replace: true }); }} sx={{ mb: 2, borderBottom: 1, borderColor: 'divider' }} > } iconPosition="start" label="Dienste & Veranstaltungen" />