diff --git a/backend/src/controllers/auth.controller.ts b/backend/src/controllers/auth.controller.ts index 387c338..eb14a22 100644 --- a/backend/src/controllers/auth.controller.ts +++ b/backend/src/controllers/auth.controller.ts @@ -7,6 +7,41 @@ import { AuthRequest } from '../types/auth.types'; import auditService, { AuditAction, AuditResourceType } from '../services/audit.service'; import { extractIp, extractUserAgent } from '../middleware/audit.middleware'; +/** + * Extract given_name and family_name from Authentik userinfo. + * Falls back to splitting the `name` field if individual fields are missing or identical. + */ +function extractNames(userInfo: { name?: string; given_name?: string; family_name?: string }): { + given_name: string | undefined; + family_name: string | undefined; +} { + const givenName = userInfo.given_name?.trim(); + const familyName = userInfo.family_name?.trim(); + + // If Authentik provides both and they differ, use them directly + if (givenName && familyName && givenName !== familyName) { + return { given_name: givenName, family_name: familyName }; + } + + // Fall back to splitting the name field + if (userInfo.name) { + const parts = userInfo.name.trim().split(/\s+/); + if (parts.length >= 2) { + return { + given_name: parts[0], + family_name: parts.slice(1).join(' '), + }; + } + // Single word name — use as given_name only + return { + given_name: parts[0], + family_name: undefined, + }; + } + + return { given_name: givenName, family_name: familyName }; +} + class AuthController { /** * Handle OAuth callback @@ -56,12 +91,14 @@ class AuthController { email: userInfo.email, }); + const { given_name: newGivenName, family_name: newFamilyName } = extractNames(userInfo); + user = await userService.createUser({ email: userInfo.email, authentik_sub: userInfo.sub, preferred_username: userInfo.preferred_username, - given_name: userInfo.given_name, - family_name: userInfo.family_name, + given_name: newGivenName, + family_name: newFamilyName, name: userInfo.name, profile_picture_url: userInfo.picture, }); @@ -91,11 +128,13 @@ class AuthController { await userService.updateLastLogin(user.id); await userService.updateGroups(user.id, groups); + const { given_name: updatedGivenName, family_name: updatedFamilyName } = extractNames(userInfo); + // Refresh profile fields from Authentik on every login await userService.updateUser(user.id, { name: userInfo.name, - given_name: userInfo.given_name, - family_name: userInfo.family_name, + given_name: updatedGivenName, + family_name: updatedFamilyName, preferred_username: userInfo.preferred_username, }); @@ -114,6 +153,9 @@ class AuthController { }); } + // Extract normalised names once for use in the response + const { given_name: resolvedGivenName, family_name: resolvedFamilyName } = extractNames(userInfo); + // Check if user is active if (!user.is_active) { logger.warn('Inactive user attempted login', { userId: user.id }); @@ -170,8 +212,8 @@ class AuthController { email: user.email, name: userInfo.name || user.name, preferredUsername: userInfo.preferred_username || user.preferred_username, - givenName: userInfo.given_name || user.given_name, - familyName: userInfo.family_name || user.family_name, + givenName: resolvedGivenName || user.given_name, + familyName: resolvedFamilyName || user.family_name, profilePictureUrl: user.profile_picture_url, isActive: user.is_active, groups, diff --git a/backend/src/controllers/events.controller.ts b/backend/src/controllers/events.controller.ts index 981a37d..30dc960 100644 --- a/backend/src/controllers/events.controller.ts +++ b/backend/src/controllers/events.controller.ts @@ -9,21 +9,6 @@ import { } from '../models/events.model'; import logger from '../utils/logger'; -// --------------------------------------------------------------------------- -// Known Authentik groups exposed to the frontend for event targeting -// --------------------------------------------------------------------------- - -const KNOWN_GROUPS = [ - { id: 'dashboard_admin', label: 'Administratoren' }, - { id: 'dashboard_moderator', label: 'Moderatoren' }, - { id: 'dashboard_mitglied', label: 'Mitglieder' }, - { id: 'dashboard_fahrmeister', label: 'Fahrmeister' }, - { id: 'dashboard_zeugmeister', label: 'Zeugmeister' }, - { id: 'dashboard_atemschutz', label: 'Atemschutzwart' }, - { id: 'dashboard_jugend', label: 'Feuerwehrjugend' }, - { id: 'dashboard_kommandant', label: 'Kommandanten' }, -]; - // --------------------------------------------------------------------------- // Helper — extract userGroups from request // --------------------------------------------------------------------------- @@ -124,7 +109,13 @@ class EventsController { // GET /api/events/groups // ------------------------------------------------------------------------- getAvailableGroups = async (_req: Request, res: Response): Promise => { - res.json({ success: true, data: KNOWN_GROUPS }); + try { + const groups = await eventsService.getAvailableGroups(); + res.json({ success: true, data: groups }); + } catch (error) { + logger.error('getAvailableGroups error', { error }); + res.status(500).json({ success: false, message: 'Fehler beim Laden der Gruppen' }); + } }; // ------------------------------------------------------------------------- diff --git a/backend/src/services/events.service.ts b/backend/src/services/events.service.ts index b7f4358..ebb2f15 100644 --- a/backend/src/services/events.service.ts +++ b/backend/src/services/events.service.ts @@ -430,6 +430,48 @@ class EventsService { return { token, subscribeUrl }; } + // ------------------------------------------------------------------------- + // GROUPS + // ------------------------------------------------------------------------- + + /** + * Returns distinct group slugs from active users as { id, label } pairs. + * The label is derived from a known-translations map or humanized from the slug. + */ + async getAvailableGroups(): Promise> { + const knownLabels: Record = { + 'dashboard_admin': 'Administratoren', + 'dashboard_mitglied': 'Mitglieder', + 'dashboard_fahrmeister': 'Fahrmeister', + 'dashboard_zeugmeister': 'Zeugmeister', + 'dashboard_atemschutz': 'Atemschutzwart', + 'dashboard_jugend': 'Feuerwehrjugend', + 'dashboard_kommandant': 'Kommandanten', + 'dashboard_moderator': 'Moderatoren', + 'feuerwehr-admin': 'Feuerwehr Admin', + 'feuerwehr-kommandant': 'Feuerwehr Kommandant', + }; + + const humanizeGroupName = (slug: string): string => { + if (knownLabels[slug]) return knownLabels[slug]; + const stripped = slug.startsWith('dashboard_') ? slug.slice('dashboard_'.length) : slug; + const spaced = stripped.replace(/-/g, ' '); + return spaced.charAt(0).toUpperCase() + spaced.slice(1); + }; + + const result = await pool.query( + `SELECT DISTINCT unnest(authentik_groups) AS group_name + FROM users + WHERE is_active = true + ORDER BY group_name` + ); + + return result.rows.map((row) => ({ + id: row.group_name as string, + label: humanizeGroupName(row.group_name as string), + })); + } + // ------------------------------------------------------------------------- // ICAL EXPORT // ------------------------------------------------------------------------- diff --git a/frontend/src/components/auth/LoginCallback.tsx b/frontend/src/components/auth/LoginCallback.tsx index d321f51..0d38c46 100644 --- a/frontend/src/components/auth/LoginCallback.tsx +++ b/frontend/src/components/auth/LoginCallback.tsx @@ -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; diff --git a/frontend/src/components/auth/ProtectedRoute.tsx b/frontend/src/components/auth/ProtectedRoute.tsx index 541d539..9490ee9 100644 --- a/frontend/src/components/auth/ProtectedRoute.tsx +++ b/frontend/src/components/auth/ProtectedRoute.tsx @@ -33,7 +33,7 @@ const ProtectedRoute: React.FC = ({ children }) => { // If not authenticated, redirect to login if (!isAuthenticated) { - return ; + return ; } // User is authenticated, render children diff --git a/frontend/src/components/equipment/EquipmentDashboardCard.tsx b/frontend/src/components/equipment/EquipmentDashboardCard.tsx new file mode 100644 index 0000000..89b045b --- /dev/null +++ b/frontend/src/components/equipment/EquipmentDashboardCard.tsx @@ -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 = ({ + hideWhenEmpty = false, +}) => { + const [stats, setStats] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(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 ( + + + + + Ausrüstungsstatus wird geladen... + + + + ); + } + + if (error) { + return ( + + + + {error} + + + + ); + } + + 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 ( + + + + Ausrüstung + + + {/* Main metric */} + + {stats.einsatzbereit}/{stats.total} + + + einsatzbereit + + + {/* Alerts — consolidated counts only */} + {(hasCritical || hasWarning) && ( + + {hasCritical && ( + + Kritisch + {stats.wichtigNichtBereit > 0 && ( + + {stats.wichtigNichtBereit} wichtige Teile nicht einsatzbereit + + )} + {stats.inspectionsOverdue > 0 && ( + + {stats.inspectionsOverdue} Prüfung{stats.inspectionsOverdue !== 1 ? 'en' : ''} überfällig + + )} + {stats.beschaedigt > 0 && ( + + {stats.beschaedigt} beschädigt + + )} + + )} + {hasWarning && ( + + Achtung + {stats.inspectionsDue > 0 && ( + + {stats.inspectionsDue} Prüfung{stats.inspectionsDue !== 1 ? 'en' : ''} bald fällig + + )} + {stats.inWartung > 0 && ( + + {stats.inWartung} in Wartung + + )} + {stats.ausserDienst > 0 && ( + + {stats.ausserDienst} außer Dienst + + )} + + )} + + )} + + {/* All good message */} + {allGood && ( + + Alle Ausrüstung einsatzbereit + + )} + + {/* Link to management page */} + + + Zur Verwaltung + + + + + ); +}; + +export default EquipmentDashboardCard; diff --git a/frontend/src/components/vehicles/VehicleDashboardCard.tsx b/frontend/src/components/vehicles/VehicleDashboardCard.tsx new file mode 100644 index 0000000..dd14164 --- /dev/null +++ b/frontend/src/components/vehicles/VehicleDashboardCard.tsx @@ -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 = ({ + hideWhenEmpty = false, +}) => { + const [stats, setStats] = useState(null); + const [alerts, setAlerts] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(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 ( + + + + + Fahrzeugstatus wird geladen... + + + + ); + } + + if (error) { + return ( + + + + {error} + + + + ); + } + + 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 ( + + + + Fahrzeuge + + + {/* Main metric */} + + {stats.einsatzbereit}/{stats.total} + + + einsatzbereit + + + {/* Concerns list — using Alert components for consistent warning styling */} + {hasConcerns && ( + + {overdueAlerts.length > 0 && ( + + Überfällig + + {overdueAlerts.length} Prüfung{overdueAlerts.length !== 1 ? 'en' : ''} überfällig + + + )} + {stats.ausserDienst > 0 && ( + + Außer Dienst + + {stats.ausserDienst} Fahrzeug{stats.ausserDienst !== 1 ? 'e' : ''} außer Dienst + + + )} + {upcomingAlerts.length > 0 && ( + + Bald fällig + + {upcomingAlerts.length} Prüfung{upcomingAlerts.length !== 1 ? 'en' : ''} bald fällig + + + )} + + )} + + {/* All good message */} + {allGood && ( + + Alle Fahrzeuge einsatzbereit + + )} + + {/* Link to management page */} + + + Zur Verwaltung + + + + + ); +}; + +export default VehicleDashboardCard; diff --git a/frontend/src/contexts/AuthContext.tsx b/frontend/src/contexts/AuthContext.tsx index 4a897bb..da50b0b 100644 --- a/frontend/src/contexts/AuthContext.tsx +++ b/frontend/src/contexts/AuthContext.tsx @@ -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(undefined); @@ -47,6 +48,8 @@ export const AuthProvider: React.FC = ({ children }) => { isAuthenticated: false, isLoading: false, }); + } finally { + setAuthInitialized(true); } } else { setState({ @@ -55,6 +58,7 @@ export const AuthProvider: React.FC = ({ children }) => { isAuthenticated: false, isLoading: false, }); + setAuthInitialized(true); } }; diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index dbbd395..043614b 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -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(null); - const [vehicleWarnings, setVehicleWarnings] = useState([]); 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() { )} - {/* Live vehicle KPI — einsatzbereit count from API */} - - {dataLoading ? ( - - ) : ( - - - - {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 ( - - {vehicleCount} Fahrzeug{vehicleCount !== 1 ? 'e' : ''} mit Ausrüstungsmangel - {errorCount > 0 && ` (${errorCount} kritisch)`} - {warnCount > 0 && errorCount === 0 && ` (${warnCount} in Wartung)`} - - ); - })()} - - - )} - - - {/* Inspection Alerts Panel — safety-critical, shown immediately */} - + {/* Vehicle Status Card */} + - + - {/* Equipment Alerts Panel */} - - + {/* Equipment Status Card */} + + - + diff --git a/frontend/src/pages/Login.tsx b/frontend/src/pages/Login.tsx index 903e513..c9f798a 100644 --- a/frontend/src/pages/Login.tsx +++ b/frontend/src/pages/Login.tsx @@ -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) { diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 22a9b46..cc20221 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -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