feat: dashboard widgets, auth fix, profile names, dynamic groups

- Add VehicleDashboardCard: self-contained widget modelled after
  AtemschutzDashboardCard, shows einsatzbereit ratio and inspection
  warnings inline; replaces StatsCard + InspectionAlerts in Dashboard

- Add EquipmentDashboardCard: consolidated equipment status widget
  showing only aggregated counts (no per-item listing); replaces
  EquipmentAlerts component in Dashboard

- Fix auth race condition: add authInitialized flag to api.ts so 401
  responses during initial token validation no longer trigger a
  spurious redirect to /login; save intended destination before login
  redirect and restore it after successful auth callback

- Fix profile firstname/lastname: add extractNames() helper to
  auth.controller.ts that falls back to splitting userinfo.name when
  Authentik does not provide separate given_name/family_name fields;
  applied on both create and update paths

- Dynamic groups endpoint: replace hardcoded KNOWN_GROUPS array in
  events.controller.ts with a DB query (SELECT DISTINCT unnest
  (authentik_groups) FROM users); known slugs get German labels via
  lookup map, unknown slugs are humanized automatically

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Matthias Hochmeister
2026-03-03 10:28:31 +01:00
parent 831927ae90
commit 2306741c4d
11 changed files with 470 additions and 110 deletions

View File

@@ -30,8 +30,10 @@ const LoginCallback: React.FC = () => {
try {
await login(code);
// Redirect to dashboard on success
navigate('/dashboard', { replace: true });
// Navigate to the originally intended page, falling back to the dashboard
const from = sessionStorage.getItem('auth_redirect_from') || '/dashboard';
sessionStorage.removeItem('auth_redirect_from');
navigate(from, { replace: true });
} catch (err) {
console.error('Login callback error:', err);
const is429 = err && typeof err === 'object' && 'status' in err && (err as any).status === 429;

View File

@@ -33,7 +33,7 @@ const ProtectedRoute: React.FC<ProtectedRouteProps> = ({ children }) => {
// If not authenticated, redirect to login
if (!isAuthenticated) {
return <Navigate to="/login" replace />;
return <Navigate to="/login" replace state={{ from: window.location.pathname + window.location.search }} />;
}
// User is authenticated, render children

View File

@@ -0,0 +1,168 @@
import React, { useEffect, useState } from 'react';
import {
Alert,
AlertTitle,
Box,
Card,
CardContent,
CircularProgress,
Link,
Typography,
} from '@mui/material';
import { Link as RouterLink } from 'react-router-dom';
import { equipmentApi } from '../../services/equipment';
import type { EquipmentStats } from '../../types/equipment.types';
interface EquipmentDashboardCardProps {
hideWhenEmpty?: boolean;
}
const EquipmentDashboardCard: React.FC<EquipmentDashboardCardProps> = ({
hideWhenEmpty = false,
}) => {
const [stats, setStats] = useState<EquipmentStats | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let mounted = true;
const fetchStats = async () => {
try {
setLoading(true);
setError(null);
const data = await equipmentApi.getStats();
if (mounted) setStats(data);
} catch {
if (mounted) setError('Ausrüstungsstatus konnte nicht geladen werden.');
} finally {
if (mounted) setLoading(false);
}
};
fetchStats();
return () => {
mounted = false;
};
}, []);
if (loading) {
return (
<Card>
<CardContent sx={{ display: 'flex', alignItems: 'center', gap: 1, py: 2 }}>
<CircularProgress size={16} />
<Typography variant="body2" color="text.secondary">
Ausrüstungsstatus wird geladen...
</Typography>
</CardContent>
</Card>
);
}
if (error) {
return (
<Card>
<CardContent>
<Typography variant="body2" color="error">
{error}
</Typography>
</CardContent>
</Card>
);
}
if (!stats) return null;
const hasCritical =
stats.wichtigNichtBereit > 0 || stats.inspectionsOverdue > 0 || stats.beschaedigt > 0;
const hasWarning =
stats.inspectionsDue > 0 || stats.inWartung > 0 || stats.ausserDienst > 0;
const allGood = stats.einsatzbereit === stats.total && !hasCritical && !hasWarning;
if (hideWhenEmpty && allGood) return null;
return (
<Card>
<CardContent>
<Typography variant="h6" gutterBottom>
Ausrüstung
</Typography>
{/* Main metric */}
<Typography variant="h4" fontWeight={700} color={allGood ? 'success.main' : 'text.primary'}>
{stats.einsatzbereit}/{stats.total}
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 1.5 }}>
einsatzbereit
</Typography>
{/* Alerts — consolidated counts only */}
{(hasCritical || hasWarning) && (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1, mt: 1 }}>
{hasCritical && (
<Alert severity="error" variant="outlined" sx={{ py: 0.5 }}>
<AlertTitle sx={{ fontWeight: 600, mb: 0.5 }}>Kritisch</AlertTitle>
{stats.wichtigNichtBereit > 0 && (
<Typography variant="body2">
{stats.wichtigNichtBereit} wichtige Teile nicht einsatzbereit
</Typography>
)}
{stats.inspectionsOverdue > 0 && (
<Typography variant="body2">
{stats.inspectionsOverdue} Prüfung{stats.inspectionsOverdue !== 1 ? 'en' : ''} überfällig
</Typography>
)}
{stats.beschaedigt > 0 && (
<Typography variant="body2">
{stats.beschaedigt} beschädigt
</Typography>
)}
</Alert>
)}
{hasWarning && (
<Alert severity="warning" variant="outlined" sx={{ py: 0.5 }}>
<AlertTitle sx={{ fontWeight: 600, mb: 0.5 }}>Achtung</AlertTitle>
{stats.inspectionsDue > 0 && (
<Typography variant="body2">
{stats.inspectionsDue} Prüfung{stats.inspectionsDue !== 1 ? 'en' : ''} bald fällig
</Typography>
)}
{stats.inWartung > 0 && (
<Typography variant="body2">
{stats.inWartung} in Wartung
</Typography>
)}
{stats.ausserDienst > 0 && (
<Typography variant="body2">
{stats.ausserDienst} außer Dienst
</Typography>
)}
</Alert>
)}
</Box>
)}
{/* All good message */}
{allGood && (
<Typography variant="body2" color="success.main">
Alle Ausrüstung einsatzbereit
</Typography>
)}
{/* Link to management page */}
<Box sx={{ mt: 2 }}>
<Link
component={RouterLink}
to="/ausruestung"
underline="hover"
variant="body2"
>
Zur Verwaltung
</Link>
</Box>
</CardContent>
</Card>
);
};
export default EquipmentDashboardCard;

View File

@@ -0,0 +1,162 @@
import React, { useEffect, useState } from 'react';
import {
Alert,
AlertTitle,
Box,
Card,
CardContent,
CircularProgress,
Link,
Typography,
} from '@mui/material';
import { Link as RouterLink } from 'react-router-dom';
import { vehiclesApi } from '../../services/vehicles';
import type { VehicleStats, InspectionAlert } from '../../types/vehicle.types';
interface VehicleDashboardCardProps {
hideWhenEmpty?: boolean;
}
const VehicleDashboardCard: React.FC<VehicleDashboardCardProps> = ({
hideWhenEmpty = false,
}) => {
const [stats, setStats] = useState<VehicleStats | null>(null);
const [alerts, setAlerts] = useState<InspectionAlert[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let mounted = true;
const fetchData = async () => {
try {
setLoading(true);
setError(null);
const [statsData, alertsData] = await Promise.all([
vehiclesApi.getStats(),
vehiclesApi.getAlerts(30),
]);
if (mounted) {
setStats(statsData);
setAlerts(alertsData);
}
} catch {
if (mounted) setError('Fahrzeugstatus konnte nicht geladen werden.');
} finally {
if (mounted) setLoading(false);
}
};
fetchData();
return () => {
mounted = false;
};
}, []);
if (loading) {
return (
<Card>
<CardContent sx={{ display: 'flex', alignItems: 'center', gap: 1, py: 2 }}>
<CircularProgress size={16} />
<Typography variant="body2" color="text.secondary">
Fahrzeugstatus wird geladen...
</Typography>
</CardContent>
</Card>
);
}
if (error) {
return (
<Card>
<CardContent>
<Typography variant="body2" color="error">
{error}
</Typography>
</CardContent>
</Card>
);
}
if (!stats) return null;
const overdueAlerts = alerts.filter((a) => a.tage < 0);
const upcomingAlerts = alerts.filter((a) => a.tage >= 0 && a.tage <= 30);
const hasConcerns =
overdueAlerts.length > 0 ||
upcomingAlerts.length > 0 ||
stats.ausserDienst > 0;
const allGood = stats.einsatzbereit === stats.total && !hasConcerns;
// If hideWhenEmpty and everything is fine, render nothing
if (hideWhenEmpty && allGood) return null;
return (
<Card>
<CardContent>
<Typography variant="h6" gutterBottom>
Fahrzeuge
</Typography>
{/* Main metric */}
<Typography variant="h4" fontWeight={700} color={allGood ? 'success.main' : 'text.primary'}>
{stats.einsatzbereit}/{stats.total}
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 1.5 }}>
einsatzbereit
</Typography>
{/* Concerns list — using Alert components for consistent warning styling */}
{hasConcerns && (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1, mt: 1 }}>
{overdueAlerts.length > 0 && (
<Alert severity="error" variant="outlined" sx={{ py: 0.5 }}>
<AlertTitle sx={{ fontWeight: 600, mb: 0.5 }}>Überfällig</AlertTitle>
<Typography variant="body2">
{overdueAlerts.length} Prüfung{overdueAlerts.length !== 1 ? 'en' : ''} überfällig
</Typography>
</Alert>
)}
{stats.ausserDienst > 0 && (
<Alert severity="error" variant="outlined" sx={{ py: 0.5 }}>
<AlertTitle sx={{ fontWeight: 600, mb: 0.5 }}>Außer Dienst</AlertTitle>
<Typography variant="body2">
{stats.ausserDienst} Fahrzeug{stats.ausserDienst !== 1 ? 'e' : ''} außer Dienst
</Typography>
</Alert>
)}
{upcomingAlerts.length > 0 && (
<Alert severity="warning" variant="outlined" sx={{ py: 0.5 }}>
<AlertTitle sx={{ fontWeight: 600, mb: 0.5 }}>Bald fällig</AlertTitle>
<Typography variant="body2">
{upcomingAlerts.length} Prüfung{upcomingAlerts.length !== 1 ? 'en' : ''} bald fällig
</Typography>
</Alert>
)}
</Box>
)}
{/* All good message */}
{allGood && (
<Typography variant="body2" color="success.main">
Alle Fahrzeuge einsatzbereit
</Typography>
)}
{/* Link to management page */}
<Box sx={{ mt: 2 }}>
<Link
component={RouterLink}
to="/fahrzeuge"
underline="hover"
variant="body2"
>
Zur Verwaltung
</Link>
</Box>
</CardContent>
</Card>
);
};
export default VehicleDashboardCard;

View File

@@ -3,6 +3,7 @@ import { AuthContextType, AuthState, User } from '../types/auth.types';
import { authService } from '../services/auth';
import { getToken, setToken, removeToken, getUser, setUser, removeUser } from '../utils/storage';
import { useNotification } from './NotificationContext';
import { setAuthInitialized } from '../services/api';
const AuthContext = createContext<AuthContextType | undefined>(undefined);
@@ -47,6 +48,8 @@ export const AuthProvider: React.FC<AuthProviderProps> = ({ children }) => {
isAuthenticated: false,
isLoading: false,
});
} finally {
setAuthInitialized(true);
}
} else {
setState({
@@ -55,6 +58,7 @@ export const AuthProvider: React.FC<AuthProviderProps> = ({ children }) => {
isAuthenticated: false,
isLoading: false,
});
setAuthInitialized(true);
}
};

View File

@@ -1,57 +1,32 @@
import { useState, useEffect } from 'react';
import {
Alert,
Container,
Box,
Typography,
Grid,
Fade,
} from '@mui/material';
import {
DirectionsCar,
} from '@mui/icons-material';
import { useAuth } from '../contexts/AuthContext';
import DashboardLayout from '../components/dashboard/DashboardLayout';
import SkeletonCard from '../components/shared/SkeletonCard';
import UserProfile from '../components/dashboard/UserProfile';
import NextcloudTalkWidget from '../components/dashboard/NextcloudTalkWidget';
import UpcomingEventsWidget from '../components/dashboard/UpcomingEventsWidget';
import StatsCard from '../components/dashboard/StatsCard';
import ActivityFeed from '../components/dashboard/ActivityFeed';
import InspectionAlerts from '../components/vehicles/InspectionAlerts';
import EquipmentAlerts from '../components/equipment/EquipmentAlerts';
import AtemschutzDashboardCard from '../components/atemschutz/AtemschutzDashboardCard';
import EquipmentDashboardCard from '../components/equipment/EquipmentDashboardCard';
import VehicleDashboardCard from '../components/vehicles/VehicleDashboardCard';
import PersonalWarningsBanner from '../components/dashboard/PersonalWarningsBanner';
import { vehiclesApi } from '../services/vehicles';
import { equipmentApi } from '../services/equipment';
import type { VehicleStats } from '../types/vehicle.types';
import type { VehicleEquipmentWarning } from '../types/equipment.types';
function Dashboard() {
const { user } = useAuth();
const [dataLoading, setDataLoading] = useState(true);
const [vehicleStats, setVehicleStats] = useState<VehicleStats | null>(null);
const [vehicleWarnings, setVehicleWarnings] = useState<VehicleEquipmentWarning[]>([]);
useEffect(() => {
const timer = setTimeout(() => {
setDataLoading(false);
}, 800);
// Fetch live vehicle stats for the KPI strip
vehiclesApi.getStats()
.then((stats) => setVehicleStats(stats))
.catch(() => {
// Non-critical — KPI will fall back to placeholder
});
// Fetch vehicle equipment warnings
equipmentApi.getVehicleWarnings()
.then((w) => setVehicleWarnings(w))
.catch(() => {
// Non-critical — warning indicator simply won't appear
});
return () => clearTimeout(timer);
}, []);
@@ -100,63 +75,20 @@ function Dashboard() {
</Grid>
)}
{/* Live vehicle KPI — einsatzbereit count from API */}
<Grid item xs={12} sm={6} md={3}>
{dataLoading ? (
<SkeletonCard variant="basic" />
) : (
<Fade in={true} timeout={600} style={{ transitionDelay: '200ms' }}>
<Box>
<StatsCard
title="Fahrzeuge einsatzbereit"
value={
vehicleStats
? `${vehicleStats?.einsatzbereit}/${vehicleStats?.total}`
: '—'
}
icon={DirectionsCar}
color="success.main"
/>
{vehicleWarnings.length > 0 && (() => {
const errorCount = vehicleWarnings.filter(w =>
w.status === 'beschaedigt' || w.status === 'ausser_dienst'
).length;
const warnCount = vehicleWarnings.filter(w =>
w.status === 'in_wartung'
).length;
const vehicleCount = new Set(vehicleWarnings.map(w => w.fahrzeug_id)).size;
const severity = errorCount > 0 ? 'error' : 'warning';
return (
<Alert
severity={severity}
variant="outlined"
sx={{ mt: 1, py: 0.5, '& .MuiAlert-message': { fontSize: '0.8rem' } }}
>
{vehicleCount} Fahrzeug{vehicleCount !== 1 ? 'e' : ''} mit Ausrüstungsmangel
{errorCount > 0 && ` (${errorCount} kritisch)`}
{warnCount > 0 && errorCount === 0 && ` (${warnCount} in Wartung)`}
</Alert>
);
})()}
</Box>
</Fade>
)}
</Grid>
{/* Inspection Alerts Panel — safety-critical, shown immediately */}
<Grid item xs={12}>
{/* Vehicle Status Card */}
<Grid item xs={12} md={6}>
<Fade in={!dataLoading} timeout={600} style={{ transitionDelay: '380ms' }}>
<Box>
<InspectionAlerts daysAhead={30} hideWhenEmpty={true} />
<VehicleDashboardCard />
</Box>
</Fade>
</Grid>
{/* Equipment Alerts Panel */}
<Grid item xs={12}>
<Fade in={!dataLoading} timeout={600} style={{ transitionDelay: '400ms' }}>
{/* Equipment Status Card */}
<Grid item xs={12} md={6}>
<Fade in={!dataLoading} timeout={600} style={{ transitionDelay: '450ms' }}>
<Box>
<EquipmentAlerts daysAhead={30} hideWhenEmpty={true} />
<EquipmentDashboardCard />
</Box>
</Fade>
</Grid>

View File

@@ -1,5 +1,5 @@
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useNavigate, useLocation } from 'react-router-dom';
import {
Container,
Box,
@@ -15,6 +15,7 @@ import { useAuth } from '../contexts/AuthContext';
function Login() {
const navigate = useNavigate();
const location = useLocation();
const { isAuthenticated, isLoading } = useAuth();
const [isRedirecting, setIsRedirecting] = useState(false);
@@ -22,12 +23,19 @@ function Login() {
useEffect(() => {
if (isAuthenticated) {
setIsRedirecting(true);
navigate('/dashboard', { replace: true });
const from = (location.state as any)?.from || '/dashboard';
navigate(from, { replace: true });
}
}, [isAuthenticated, navigate]);
}, [isAuthenticated, navigate, location.state]);
const handleLogin = () => {
try {
// Persist the intended destination so LoginCallback can restore it
// after the full-page Authentik redirect round-trip
const from = (location.state as any)?.from;
if (from) {
sessionStorage.setItem('auth_redirect_from', from);
}
const authUrl = authService.getAuthUrl();
window.location.href = authUrl;
} catch (error) {

View File

@@ -2,6 +2,12 @@ import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse, AxiosError } f
import { API_URL } from '../utils/config';
import { getToken, removeToken, removeUser } from '../utils/storage';
let authInitialized = false;
export function setAuthInitialized(value: boolean): void {
authInitialized = value;
}
export interface ApiError {
message: string;
status?: number;
@@ -40,11 +46,14 @@ class ApiService {
(response) => response,
async (error: AxiosError) => {
if (error.response?.status === 401) {
// Clear tokens and redirect to login
console.warn('Unauthorized request, redirecting to login');
removeToken();
removeUser();
window.location.href = '/login';
if (authInitialized) {
// Clear tokens and redirect to login
console.warn('Unauthorized request, redirecting to login');
removeToken();
removeUser();
window.location.href = '/login';
}
// During initialization, silently reject without redirecting
}
// Retry on 429 (Too Many Requests) with exponential backoff