This commit is contained in:
Matthias Hochmeister
2026-02-23 17:08:58 +01:00
commit f09748f4a1
97 changed files with 17729 additions and 0 deletions

90
frontend/src/App.tsx Normal file
View File

@@ -0,0 +1,90 @@
import { Routes, Route } from 'react-router-dom';
import { NotificationProvider } from './contexts/NotificationContext';
import { AuthProvider } from './contexts/AuthContext';
import ErrorBoundary from './components/shared/ErrorBoundary';
import ProtectedRoute from './components/auth/ProtectedRoute';
import LoginCallback from './components/auth/LoginCallback';
import Login from './pages/Login';
import Dashboard from './pages/Dashboard';
import Profile from './pages/Profile';
import Settings from './pages/Settings';
import Einsaetze from './pages/Einsaetze';
import Fahrzeuge from './pages/Fahrzeuge';
import Ausruestung from './pages/Ausruestung';
import Mitglieder from './pages/Mitglieder';
import NotFound from './pages/NotFound';
function App() {
return (
<ErrorBoundary>
<NotificationProvider>
<AuthProvider>
<Routes>
<Route path="/" element={<Login />} />
<Route path="/login" element={<Login />} />
<Route path="/auth/callback" element={<LoginCallback />} />
<Route
path="/dashboard"
element={
<ProtectedRoute>
<Dashboard />
</ProtectedRoute>
}
/>
<Route
path="/profile"
element={
<ProtectedRoute>
<Profile />
</ProtectedRoute>
}
/>
<Route
path="/settings"
element={
<ProtectedRoute>
<Settings />
</ProtectedRoute>
}
/>
<Route
path="/einsaetze"
element={
<ProtectedRoute>
<Einsaetze />
</ProtectedRoute>
}
/>
<Route
path="/fahrzeuge"
element={
<ProtectedRoute>
<Fahrzeuge />
</ProtectedRoute>
}
/>
<Route
path="/ausruestung"
element={
<ProtectedRoute>
<Ausruestung />
</ProtectedRoute>
}
/>
<Route
path="/mitglieder"
element={
<ProtectedRoute>
<Mitglieder />
</ProtectedRoute>
}
/>
<Route path="*" element={<NotFound />} />
</Routes>
</AuthProvider>
</NotificationProvider>
</ErrorBoundary>
);
}
export default App;

View File

@@ -0,0 +1,95 @@
import React, { useEffect, useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { useAuth } from '../../contexts/AuthContext';
import { Box, CircularProgress, Typography, Alert, Button } from '@mui/material';
const LoginCallback: React.FC = () => {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const { login } = useAuth();
const [error, setError] = useState<string>('');
useEffect(() => {
const handleCallback = async () => {
const code = searchParams.get('code');
const errorParam = searchParams.get('error');
if (errorParam) {
setError(`Authentifizierungsfehler: ${errorParam}`);
return;
}
if (!code) {
setError('Kein Autorisierungscode erhalten');
return;
}
try {
await login(code);
// Redirect to dashboard on success
navigate('/dashboard', { replace: true });
} catch (err) {
console.error('Login callback error:', err);
setError(
err instanceof Error
? err.message
: 'Anmeldung fehlgeschlagen. Bitte versuchen Sie es erneut.'
);
}
};
handleCallback();
}, [searchParams, login, navigate]);
if (error) {
return (
<Box
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
minHeight: '100vh',
padding: 3,
}}
>
<Alert
severity="error"
sx={{
maxWidth: 500,
mb: 2,
width: '100%',
}}
>
{error}
</Alert>
<Button
variant="contained"
onClick={() => navigate('/login')}
>
Zurück zur Anmeldung
</Button>
</Box>
);
}
return (
<Box
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
minHeight: '100vh',
gap: 2,
}}
>
<CircularProgress size={60} />
<Typography variant="h6" color="text.secondary">
Anmeldung wird abgeschlossen...
</Typography>
</Box>
);
};
export default LoginCallback;

View File

@@ -0,0 +1,43 @@
import React from 'react';
import { Navigate } from 'react-router-dom';
import { useAuth } from '../../contexts/AuthContext';
import { Box, CircularProgress, Typography } from '@mui/material';
interface ProtectedRouteProps {
children: React.ReactNode;
}
const ProtectedRoute: React.FC<ProtectedRouteProps> = ({ children }) => {
const { isAuthenticated, isLoading } = useAuth();
// Show loading spinner while checking authentication
if (isLoading) {
return (
<Box
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
minHeight: '100vh',
gap: 2,
}}
>
<CircularProgress size={60} />
<Typography variant="h6" color="text.secondary">
Authentifizierung wird überprüft...
</Typography>
</Box>
);
}
// If not authenticated, redirect to login
if (!isAuthenticated) {
return <Navigate to="/login" replace />;
}
// User is authenticated, render children
return <>{children}</>;
};
export default ProtectedRoute;

View File

@@ -0,0 +1,174 @@
import React from 'react';
import {
Card,
CardContent,
Typography,
List,
ListItem,
ListItemAvatar,
ListItemText,
Avatar,
Box,
} from '@mui/material';
import {
LocalFireDepartment,
Person,
DirectionsCar,
Assignment,
} from '@mui/icons-material';
interface Activity {
id: string;
type: 'incident' | 'member' | 'vehicle' | 'task';
title: string;
description: string;
timestamp: string;
}
// Placeholder activities
const placeholderActivities: Activity[] = [
{
id: '1',
type: 'incident',
title: 'Brandeinsatz',
description: 'Kleinbrand in der Hauptstraße',
timestamp: 'Vor 2 Stunden',
},
{
id: '2',
type: 'member',
title: 'Neues Mitglied',
description: 'Max Mustermann ist der Feuerwehr beigetreten',
timestamp: 'Vor 5 Stunden',
},
{
id: '3',
type: 'vehicle',
title: 'Fahrzeugwartung',
description: 'LF 16/12 - Wartung abgeschlossen',
timestamp: 'Gestern',
},
{
id: '4',
type: 'task',
title: 'Aufgabe zugewiesen',
description: 'Neue Aufgabe: Inventur Atemschutzgeräte',
timestamp: 'Vor 2 Tagen',
},
];
const ActivityFeed: React.FC = () => {
const getActivityIcon = (type: Activity['type']) => {
switch (type) {
case 'incident':
return <LocalFireDepartment />;
case 'member':
return <Person />;
case 'vehicle':
return <DirectionsCar />;
case 'task':
return <Assignment />;
default:
return <LocalFireDepartment />;
}
};
const getActivityColor = (type: Activity['type']) => {
switch (type) {
case 'incident':
return 'error.main';
case 'member':
return 'success.main';
case 'vehicle':
return 'warning.main';
case 'task':
return 'info.main';
default:
return 'primary.main';
}
};
return (
<Card>
<CardContent>
<Typography variant="h6" gutterBottom>
Letzte Aktivitäten
</Typography>
<List sx={{ pt: 2 }}>
{placeholderActivities.map((activity, index) => (
<React.Fragment key={activity.id}>
<ListItem
alignItems="flex-start"
sx={{
px: 0,
position: 'relative',
'&::before':
index < placeholderActivities.length - 1
? {
content: '""',
position: 'absolute',
left: 19,
top: 56,
bottom: -8,
width: 2,
bgcolor: 'divider',
}
: {},
}}
>
<ListItemAvatar>
<Avatar
sx={{
bgcolor: getActivityColor(activity.type),
width: 40,
height: 40,
}}
>
{getActivityIcon(activity.type)}
</Avatar>
</ListItemAvatar>
<ListItemText
primary={
<Typography variant="subtitle2" component="span">
{activity.title}
</Typography>
}
secondary={
<Box>
<Typography
variant="body2"
color="text.secondary"
component="span"
sx={{ display: 'block' }}
>
{activity.description}
</Typography>
<Typography
variant="caption"
color="text.secondary"
component="span"
>
{activity.timestamp}
</Typography>
</Box>
}
/>
</ListItem>
</React.Fragment>
))}
</List>
{placeholderActivities.length === 0 && (
<Box sx={{ textAlign: 'center', py: 4 }}>
<Typography variant="body2" color="text.secondary">
Keine Aktivitäten vorhanden
</Typography>
</Box>
)}
</CardContent>
</Card>
);
};
export default ActivityFeed;

View File

@@ -0,0 +1,21 @@
import React from 'react';
import { MenuBook } from '@mui/icons-material';
import ServiceCard from './ServiceCard';
interface BookstackCardProps {
onClick?: () => void;
}
const BookstackCard: React.FC<BookstackCardProps> = ({ onClick }) => {
return (
<ServiceCard
title="Bookstack"
description="Dokumentation und Wiki"
icon={MenuBook}
status="disconnected"
onClick={onClick}
/>
);
};
export default BookstackCard;

View File

@@ -0,0 +1,46 @@
import { useState, ReactNode } from 'react';
import { Box, Toolbar } from '@mui/material';
import Header from '../shared/Header';
import Sidebar from '../shared/Sidebar';
import { useAuth } from '../../contexts/AuthContext';
import Loading from '../shared/Loading';
interface DashboardLayoutProps {
children: ReactNode;
}
function DashboardLayout({ children }: DashboardLayoutProps) {
const [mobileOpen, setMobileOpen] = useState(false);
const { isLoading } = useAuth();
const handleDrawerToggle = () => {
setMobileOpen(!mobileOpen);
};
if (isLoading) {
return <Loading message="Lade Dashboard..." />;
}
return (
<Box sx={{ display: 'flex' }}>
<Header onMenuClick={handleDrawerToggle} />
<Sidebar mobileOpen={mobileOpen} onMobileClose={() => setMobileOpen(false)} />
<Box
component="main"
sx={{
flexGrow: 1,
p: 3,
width: { sm: `calc(100% - 240px)` },
minHeight: '100vh',
backgroundColor: 'background.default',
}}
>
<Toolbar />
{children}
</Box>
</Box>
);
}
export default DashboardLayout;

View File

@@ -0,0 +1,21 @@
import React from 'react';
import { Cloud } from '@mui/icons-material';
import ServiceCard from './ServiceCard';
interface NextcloudCardProps {
onClick?: () => void;
}
const NextcloudCard: React.FC<NextcloudCardProps> = ({ onClick }) => {
return (
<ServiceCard
title="Nextcloud"
description="Dateien und Dokumente"
icon={Cloud}
status="disconnected"
onClick={onClick}
/>
);
};
export default NextcloudCard;

View File

@@ -0,0 +1,106 @@
import React from 'react';
import {
Card,
CardActionArea,
CardContent,
Typography,
Box,
Chip,
} from '@mui/material';
import { SvgIconComponent } from '@mui/icons-material';
interface ServiceCardProps {
title: string;
description: string;
icon: SvgIconComponent;
status: 'connected' | 'disconnected';
onClick?: () => void;
}
const ServiceCard: React.FC<ServiceCardProps> = ({
title,
description,
icon: Icon,
status,
onClick,
}) => {
const isConnected = status === 'connected';
return (
<Card
sx={{
height: '100%',
transition: 'all 0.3s ease',
'&:hover': {
transform: 'translateY(-4px)',
boxShadow: 4,
},
}}
>
<CardActionArea
onClick={onClick}
sx={{
height: '100%',
display: 'flex',
flexDirection: 'column',
alignItems: 'stretch',
justifyContent: 'flex-start',
}}
>
<CardContent sx={{ flexGrow: 1, width: '100%' }}>
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
mb: 2,
}}
>
<Box
sx={{
display: 'flex',
alignItems: 'center',
bgcolor: isConnected ? 'success.light' : 'grey.300',
borderRadius: '50%',
p: 1.5,
}}
>
<Icon
sx={{
fontSize: 32,
color: isConnected ? 'success.dark' : 'grey.600',
}}
/>
</Box>
<Box
sx={{
width: 12,
height: 12,
borderRadius: '50%',
bgcolor: isConnected ? 'success.main' : 'grey.400',
}}
/>
</Box>
<Typography variant="h6" component="div" gutterBottom>
{title}
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
{description}
</Typography>
<Chip
label={isConnected ? 'Verbunden' : 'Noch nicht konfiguriert'}
size="small"
color={isConnected ? 'success' : 'default'}
variant={isConnected ? 'filled' : 'outlined'}
/>
</CardContent>
</CardActionArea>
</Card>
);
};
export default ServiceCard;

View File

@@ -0,0 +1,74 @@
import React from 'react';
import { Card, CardContent, Typography, Box } from '@mui/material';
import { SvgIconComponent } from '@mui/icons-material';
interface StatsCardProps {
title: string;
value: string | number;
icon: SvgIconComponent;
color?: string;
}
const StatsCard: React.FC<StatsCardProps> = ({
title,
value,
icon: Icon,
color = 'primary.main',
}) => {
return (
<Card
sx={{
height: '100%',
transition: 'all 0.3s ease',
'&:hover': {
boxShadow: 3,
},
}}
>
<CardContent>
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
}}
>
<Box sx={{ flex: 1 }}>
<Typography
variant="body2"
color="text.secondary"
gutterBottom
sx={{ textTransform: 'uppercase', fontSize: '0.75rem' }}
>
{title}
</Typography>
<Typography variant="h4" component="div" sx={{ fontWeight: 'bold' }}>
{value}
</Typography>
</Box>
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
bgcolor: `${color}15`,
borderRadius: '50%',
width: 56,
height: 56,
}}
>
<Icon
sx={{
fontSize: 32,
color: color,
}}
/>
</Box>
</Box>
</CardContent>
</Card>
);
};
export default StatsCard;

View File

@@ -0,0 +1,137 @@
import React from 'react';
import {
Card,
CardContent,
Avatar,
Typography,
Box,
Chip,
} from '@mui/material';
import { User } from '../../types/auth.types';
interface UserProfileProps {
user: User;
}
const UserProfile: React.FC<UserProfileProps> = ({ user }) => {
// Get first letter of name for avatar
const getInitials = (name: string): string => {
return name.charAt(0).toUpperCase();
};
// Format date (placeholder until we have actual dates)
const formatDate = (date?: string): string => {
if (!date) return 'Nicht verfügbar';
return new Date(date).toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
});
};
return (
<Card
sx={{
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
color: 'white',
}}
>
<CardContent>
<Box
sx={{
display: 'flex',
alignItems: 'center',
flexDirection: { xs: 'column', sm: 'row' },
gap: 3,
}}
>
{/* Avatar */}
<Avatar
sx={{
width: 80,
height: 80,
bgcolor: 'rgba(255, 255, 255, 0.2)',
fontSize: '2rem',
fontWeight: 'bold',
}}
>
{getInitials(user.name)}
</Avatar>
{/* User Info */}
<Box sx={{ flex: 1, textAlign: { xs: 'center', sm: 'left' } }}>
<Typography variant="h5" component="div" gutterBottom>
{user.name}
</Typography>
<Typography variant="body2" sx={{ opacity: 0.9 }}>
{user.email}
</Typography>
{user.preferred_username && (
<Typography variant="body2" sx={{ opacity: 0.9 }}>
@{user.preferred_username}
</Typography>
)}
<Box
sx={{
display: 'flex',
gap: 1,
mt: 2,
flexWrap: 'wrap',
justifyContent: { xs: 'center', sm: 'flex-start' },
}}
>
<Chip
label="Aktiv"
size="small"
sx={{
bgcolor: 'rgba(255, 255, 255, 0.2)',
color: 'white',
}}
/>
{user.groups && user.groups.length > 0 && (
<Chip
label={`${user.groups.length} Gruppe${user.groups.length > 1 ? 'n' : ''}`}
size="small"
sx={{
bgcolor: 'rgba(255, 255, 255, 0.2)',
color: 'white',
}}
/>
)}
</Box>
</Box>
{/* Additional Info */}
<Box
sx={{
display: 'flex',
flexDirection: 'column',
gap: 1,
textAlign: { xs: 'center', sm: 'right' },
}}
>
<Box>
<Typography variant="caption" sx={{ opacity: 0.8 }}>
Letzter Login
</Typography>
<Typography variant="body2" sx={{ fontWeight: 'medium' }}>
Heute
</Typography>
</Box>
<Box>
<Typography variant="caption" sx={{ opacity: 0.8 }}>
Mitglied seit
</Typography>
<Typography variant="body2" sx={{ fontWeight: 'medium' }}>
{formatDate()}
</Typography>
</Box>
</Box>
</Box>
</CardContent>
</Card>
);
};
export default UserProfile;

View File

@@ -0,0 +1,21 @@
import React from 'react';
import { Assignment } from '@mui/icons-material';
import ServiceCard from './ServiceCard';
interface VikunjaCardProps {
onClick?: () => void;
}
const VikunjaCard: React.FC<VikunjaCardProps> = ({ onClick }) => {
return (
<ServiceCard
title="Vikunja"
description="Aufgaben und Projekte"
icon={Assignment}
status="disconnected"
onClick={onClick}
/>
);
};
export default VikunjaCard;

View File

@@ -0,0 +1,8 @@
export { default as UserProfile } from './UserProfile';
export { default as ServiceCard } from './ServiceCard';
export { default as NextcloudCard } from './NextcloudCard';
export { default as VikunjaCard } from './VikunjaCard';
export { default as BookstackCard } from './BookstackCard';
export { default as StatsCard } from './StatsCard';
export { default as ActivityFeed } from './ActivityFeed';
export { default as DashboardLayout } from './DashboardLayout';

View File

@@ -0,0 +1,55 @@
import React, { ReactNode } from 'react';
import { Box, Typography, Button } from '@mui/material';
interface EmptyStateProps {
icon: ReactNode;
title: string;
message: string;
action?: {
label: string;
onClick: () => void;
};
}
const EmptyState: React.FC<EmptyStateProps> = ({ icon, title, message, action }) => {
return (
<Box
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
py: 8,
px: 3,
textAlign: 'center',
}}
>
<Box
sx={{
fontSize: 80,
color: 'text.disabled',
mb: 2,
}}
>
{icon}
</Box>
<Typography variant="h5" component="h2" gutterBottom color="text.primary">
{title}
</Typography>
<Typography
variant="body1"
color="text.secondary"
sx={{ mb: action ? 3 : 0, maxWidth: 500 }}
>
{message}
</Typography>
{action && (
<Button variant="contained" color="primary" onClick={action.onClick}>
{action.label}
</Button>
)}
</Box>
);
};
export default EmptyState;

View File

@@ -0,0 +1,137 @@
import React, { Component, ErrorInfo, ReactNode } from 'react';
import { Box, Card, CardContent, Typography, Button } from '@mui/material';
import { ErrorOutline, Refresh } from '@mui/icons-material';
interface Props {
children: ReactNode;
}
interface State {
hasError: boolean;
error: Error | null;
errorInfo: ErrorInfo | null;
}
class ErrorBoundary extends Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = {
hasError: false,
error: null,
errorInfo: null,
};
}
static getDerivedStateFromError(error: Error): Partial<State> {
return { hasError: true, error };
}
componentDidCatch(error: Error, errorInfo: ErrorInfo): void {
console.error('ErrorBoundary caught an error:', error);
console.error('Error Info:', errorInfo);
this.setState({ error, errorInfo });
}
handleReset = (): void => {
this.setState({
hasError: false,
error: null,
errorInfo: null,
});
};
render(): ReactNode {
if (this.state.hasError) {
return (
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
minHeight: '100vh',
bgcolor: 'background.default',
p: 3,
}}
>
<Card
sx={{
maxWidth: 600,
width: '100%',
}}
>
<CardContent
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
p: 4,
}}
>
<ErrorOutline
sx={{
fontSize: 80,
color: 'error.main',
mb: 2,
}}
/>
<Typography variant="h5" component="h1" gutterBottom align="center">
Etwas ist schiefgelaufen
</Typography>
<Typography
variant="body1"
color="text.secondary"
align="center"
sx={{ mb: 3 }}
>
Ein unerwarteter Fehler ist aufgetreten. Bitte versuchen Sie es
erneut.
</Typography>
{this.state.error && (
<Box
sx={{
width: '100%',
bgcolor: 'grey.100',
p: 2,
borderRadius: 1,
mb: 3,
maxHeight: 200,
overflow: 'auto',
}}
>
<Typography
variant="caption"
component="pre"
sx={{
fontFamily: 'monospace',
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
margin: 0,
}}
>
{this.state.error.toString()}
</Typography>
</Box>
)}
<Button
variant="contained"
color="primary"
size="large"
startIcon={<Refresh />}
onClick={this.handleReset}
fullWidth
>
Erneut versuchen
</Button>
</CardContent>
</Card>
</Box>
);
}
return this.props.children;
}
}
export default ErrorBoundary;

View File

@@ -0,0 +1,161 @@
import { useState } from 'react';
import {
AppBar,
Toolbar,
Typography,
IconButton,
Menu,
MenuItem,
Avatar,
ListItemIcon,
Divider,
Box,
} from '@mui/material';
import {
LocalFireDepartment,
Person,
Settings,
Logout,
Menu as MenuIcon,
} from '@mui/icons-material';
import { useAuth } from '../../contexts/AuthContext';
import { useNavigate } from 'react-router-dom';
interface HeaderProps {
onMenuClick: () => void;
}
function Header({ onMenuClick }: HeaderProps) {
const { user, logout } = useAuth();
const navigate = useNavigate();
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
const handleMenuOpen = (event: React.MouseEvent<HTMLElement>) => {
setAnchorEl(event.currentTarget);
};
const handleMenuClose = () => {
setAnchorEl(null);
};
const handleProfile = () => {
handleMenuClose();
navigate('/profile');
};
const handleSettings = () => {
handleMenuClose();
navigate('/settings');
};
const handleLogout = () => {
handleMenuClose();
logout();
};
// Get initials for avatar
const getInitials = () => {
if (!user) return '?';
const initials = (user.given_name?.[0] || '') + (user.family_name?.[0] || '');
return initials || user.name?.[0] || '?';
};
return (
<AppBar
position="fixed"
sx={{
zIndex: (theme) => theme.zIndex.drawer + 1,
}}
>
<Toolbar>
<IconButton
color="inherit"
aria-label="Menü öffnen"
edge="start"
onClick={onMenuClick}
sx={{ mr: 2, display: { sm: 'none' } }}
>
<MenuIcon />
</IconButton>
<LocalFireDepartment sx={{ mr: 2 }} />
<Typography variant="h6" component="div" sx={{ flexGrow: 1 }}>
Feuerwehr Dashboard
</Typography>
{user && (
<>
<IconButton
onClick={handleMenuOpen}
size="small"
aria-label="Benutzerkonto"
aria-controls="user-menu"
aria-haspopup="true"
>
<Avatar
sx={{
bgcolor: 'secondary.main',
width: 32,
height: 32,
fontSize: '0.875rem',
}}
>
{getInitials()}
</Avatar>
</IconButton>
<Menu
id="user-menu"
anchorEl={anchorEl}
open={Boolean(anchorEl)}
onClose={handleMenuClose}
anchorOrigin={{
vertical: 'bottom',
horizontal: 'right',
}}
transformOrigin={{
vertical: 'top',
horizontal: 'right',
}}
PaperProps={{
elevation: 3,
sx: { minWidth: 250, mt: 1 },
}}
>
<Box sx={{ px: 2, py: 1.5 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 600 }}>
{user.name}
</Typography>
<Typography variant="body2" color="text.secondary">
{user.email}
</Typography>
</Box>
<Divider />
<MenuItem onClick={handleProfile}>
<ListItemIcon>
<Person fontSize="small" />
</ListItemIcon>
Profil
</MenuItem>
<MenuItem onClick={handleSettings}>
<ListItemIcon>
<Settings fontSize="small" />
</ListItemIcon>
Einstellungen
</MenuItem>
<Divider />
<MenuItem onClick={handleLogout}>
<ListItemIcon>
<Logout fontSize="small" />
</ListItemIcon>
Abmelden
</MenuItem>
</Menu>
</>
)}
</Toolbar>
</AppBar>
);
}
export default Header;

View File

@@ -0,0 +1,29 @@
import { Box, CircularProgress, Typography } from '@mui/material';
interface LoadingProps {
message?: string;
}
function Loading({ message }: LoadingProps) {
return (
<Box
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
minHeight: '100vh',
gap: 2,
}}
>
<CircularProgress />
{message && (
<Typography variant="body1" color="text.secondary">
{message}
</Typography>
)}
</Box>
);
}
export default Loading;

View File

@@ -0,0 +1,156 @@
import {
Drawer,
List,
ListItem,
ListItemButton,
ListItemIcon,
ListItemText,
Toolbar,
Tooltip,
} from '@mui/material';
import {
Dashboard as DashboardIcon,
LocalFireDepartment,
DirectionsCar,
Build,
People,
} from '@mui/icons-material';
import { useNavigate, useLocation } from 'react-router-dom';
const DRAWER_WIDTH = 240;
interface NavigationItem {
text: string;
icon: JSX.Element;
path: string;
}
const navigationItems: NavigationItem[] = [
{
text: 'Dashboard',
icon: <DashboardIcon />,
path: '/dashboard',
},
{
text: 'Einsätze',
icon: <LocalFireDepartment />,
path: '/einsaetze',
},
{
text: 'Fahrzeuge',
icon: <DirectionsCar />,
path: '/fahrzeuge',
},
{
text: 'Ausrüstung',
icon: <Build />,
path: '/ausruestung',
},
{
text: 'Mitglieder',
icon: <People />,
path: '/mitglieder',
},
];
interface SidebarProps {
mobileOpen: boolean;
onMobileClose: () => void;
}
function Sidebar({ mobileOpen, onMobileClose }: SidebarProps) {
const navigate = useNavigate();
const location = useLocation();
const handleNavigation = (path: string) => {
navigate(path);
onMobileClose();
};
const drawerContent = (
<>
<Toolbar />
<List>
{navigationItems.map((item) => {
const isActive = location.pathname === item.path;
return (
<ListItem key={item.text} disablePadding>
<Tooltip title={item.text} placement="right" arrow>
<ListItemButton
selected={isActive}
onClick={() => handleNavigation(item.path)}
aria-label={`Zu ${item.text} navigieren`}
sx={{
'&.Mui-selected': {
backgroundColor: 'primary.light',
color: 'primary.contrastText',
'&:hover': {
backgroundColor: 'primary.main',
},
'& .MuiListItemIcon-root': {
color: 'primary.contrastText',
},
},
}}
>
<ListItemIcon
sx={{
color: isActive ? 'inherit' : 'text.secondary',
}}
>
{item.icon}
</ListItemIcon>
<ListItemText primary={item.text} />
</ListItemButton>
</Tooltip>
</ListItem>
);
})}
</List>
</>
);
return (
<>
{/* Mobile drawer */}
<Drawer
variant="temporary"
open={mobileOpen}
onClose={onMobileClose}
ModalProps={{
keepMounted: true, // Better mobile performance
}}
sx={{
display: { xs: 'block', sm: 'none' },
'& .MuiDrawer-paper': {
boxSizing: 'border-box',
width: DRAWER_WIDTH,
},
}}
aria-label="Mobile Navigation"
>
{drawerContent}
</Drawer>
{/* Desktop drawer */}
<Drawer
variant="permanent"
sx={{
display: { xs: 'none', sm: 'block' },
width: DRAWER_WIDTH,
flexShrink: 0,
'& .MuiDrawer-paper': {
width: DRAWER_WIDTH,
boxSizing: 'border-box',
},
}}
open
aria-label="Desktop Navigation"
>
{drawerContent}
</Drawer>
</>
);
}
export default Sidebar;

View File

@@ -0,0 +1,44 @@
import React from 'react';
import { Card, CardContent, Skeleton, Box } from '@mui/material';
interface SkeletonCardProps {
variant?: 'basic' | 'withAvatar' | 'detailed';
}
const SkeletonCard: React.FC<SkeletonCardProps> = ({ variant = 'basic' }) => {
return (
<Card>
<CardContent>
{variant === 'withAvatar' && (
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
<Skeleton variant="circular" width={40} height={40} sx={{ mr: 2 }} />
<Box sx={{ flex: 1 }}>
<Skeleton variant="text" width="60%" height={24} />
<Skeleton variant="text" width="40%" height={20} />
</Box>
</Box>
)}
{variant === 'detailed' && (
<>
<Skeleton variant="text" width="80%" height={32} sx={{ mb: 1 }} />
<Skeleton variant="text" width="100%" height={20} sx={{ mb: 1 }} />
<Skeleton variant="text" width="100%" height={20} sx={{ mb: 1 }} />
<Skeleton variant="text" width="60%" height={20} sx={{ mb: 2 }} />
<Skeleton variant="rectangular" width="100%" height={140} />
</>
)}
{variant === 'basic' && (
<>
<Skeleton variant="text" width="70%" height={28} sx={{ mb: 1 }} />
<Skeleton variant="text" width="100%" height={20} sx={{ mb: 1 }} />
<Skeleton variant="text" width="90%" height={20} />
</>
)}
</CardContent>
</Card>
);
};
export default SkeletonCard;

View File

@@ -0,0 +1,7 @@
// Shared components barrel export
export { default as ErrorBoundary } from './ErrorBoundary';
export { default as EmptyState } from './EmptyState';
export { default as SkeletonCard } from './SkeletonCard';
export { default as Header } from './Header';
export { default as Sidebar } from './Sidebar';
export { default as Loading } from './Loading';

View File

@@ -0,0 +1,151 @@
import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react';
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';
const AuthContext = createContext<AuthContextType | undefined>(undefined);
interface AuthProviderProps {
children: ReactNode;
}
export const AuthProvider: React.FC<AuthProviderProps> = ({ children }) => {
const notification = useNotification();
const [state, setState] = useState<AuthState>({
user: null,
token: null,
isAuthenticated: false,
isLoading: true,
});
// Check for existing token on mount
useEffect(() => {
const initializeAuth = async () => {
const token = getToken();
const user = getUser();
if (token && user) {
setState({
user,
token,
isAuthenticated: true,
isLoading: false,
});
// Optionally verify token is still valid
try {
await authService.getCurrentUser();
} catch (error) {
console.error('Token validation failed:', error);
// Token is invalid, clear it
removeToken();
removeUser();
setState({
user: null,
token: null,
isAuthenticated: false,
isLoading: false,
});
}
} else {
setState({
user: null,
token: null,
isAuthenticated: false,
isLoading: false,
});
}
};
initializeAuth();
}, []);
const login = async (code: string): Promise<void> => {
try {
setState((prev) => ({ ...prev, isLoading: true }));
const { token, user } = await authService.handleCallback(code);
// Save to localStorage
setToken(token);
setUser(user);
// Update state
setState({
user,
token,
isAuthenticated: true,
isLoading: false,
});
// Show success notification
notification.showSuccess('Anmeldung erfolgreich');
} catch (error) {
console.error('Login failed:', error);
setState({
user: null,
token: null,
isAuthenticated: false,
isLoading: false,
});
// Show error notification
notification.showError('Anmeldung fehlgeschlagen. Bitte versuchen Sie es erneut.');
throw error;
}
};
const logout = (): void => {
// Call backend logout (fire and forget)
authService.logout().catch((error) => {
console.error('Backend logout failed:', error);
});
// Clear local state
removeToken();
removeUser();
setState({
user: null,
token: null,
isAuthenticated: false,
isLoading: false,
});
// Show logout notification
notification.showSuccess('Abmeldung erfolgreich');
// Redirect to login after a short delay to show notification
setTimeout(() => {
window.location.href = '/login';
}, 1000);
};
const refreshAuth = async (): Promise<void> => {
try {
const user = await authService.getCurrentUser();
setUser(user);
setState((prev) => ({ ...prev, user }));
} catch (error) {
console.error('Failed to refresh user data:', error);
logout();
}
};
const value: AuthContextType = {
...state,
login,
logout,
refreshAuth,
};
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
};
export const useAuth = (): AuthContextType => {
const context = useContext(AuthContext);
if (context === undefined) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
};

View File

@@ -0,0 +1,109 @@
import React, { createContext, useContext, useState, ReactNode, useCallback } from 'react';
import { Snackbar, Alert, AlertColor } from '@mui/material';
interface Notification {
id: number;
message: string;
severity: AlertColor;
}
interface NotificationContextType {
showSuccess: (message: string) => void;
showError: (message: string) => void;
showWarning: (message: string) => void;
showInfo: (message: string) => void;
}
const NotificationContext = createContext<NotificationContextType | undefined>(undefined);
interface NotificationProviderProps {
children: ReactNode;
}
export const NotificationProvider: React.FC<NotificationProviderProps> = ({ children }) => {
const [notifications, setNotifications] = useState<Notification[]>([]);
const [currentNotification, setCurrentNotification] = useState<Notification | null>(null);
const addNotification = useCallback((message: string, severity: AlertColor) => {
const id = Date.now();
const notification: Notification = { id, message, severity };
setNotifications((prev) => [...prev, notification]);
// If no notification is currently displayed, show this one immediately
if (!currentNotification) {
setCurrentNotification(notification);
}
}, [currentNotification]);
const showSuccess = useCallback((message: string) => {
addNotification(message, 'success');
}, [addNotification]);
const showError = useCallback((message: string) => {
addNotification(message, 'error');
}, [addNotification]);
const showWarning = useCallback((message: string) => {
addNotification(message, 'warning');
}, [addNotification]);
const showInfo = useCallback((message: string) => {
addNotification(message, 'info');
}, [addNotification]);
const handleClose = (_event?: React.SyntheticEvent | Event, reason?: string) => {
if (reason === 'clickaway') {
return;
}
setCurrentNotification(null);
// Show next notification after a short delay
setTimeout(() => {
setNotifications((prev) => {
const remaining = prev.filter((n) => n.id !== currentNotification?.id);
if (remaining.length > 0) {
setCurrentNotification(remaining[0]);
}
return remaining;
});
}, 200);
};
const value: NotificationContextType = {
showSuccess,
showError,
showWarning,
showInfo,
};
return (
<NotificationContext.Provider value={value}>
{children}
<Snackbar
open={currentNotification !== null}
autoHideDuration={6000}
onClose={handleClose}
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
>
<Alert
onClose={handleClose}
severity={currentNotification?.severity || 'info'}
variant="filled"
sx={{ width: '100%' }}
>
{currentNotification?.message}
</Alert>
</Snackbar>
</NotificationContext.Provider>
);
};
export const useNotification = (): NotificationContextType => {
const context = useContext(NotificationContext);
if (context === undefined) {
throw new Error('useNotification must be used within a NotificationProvider');
}
return context;
};

17
frontend/src/main.tsx Normal file
View File

@@ -0,0 +1,17 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import { CssBaseline, ThemeProvider } from '@mui/material';
import { lightTheme } from './theme/theme';
import App from './App';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<BrowserRouter>
<ThemeProvider theme={lightTheme}>
<CssBaseline />
<App />
</ThemeProvider>
</BrowserRouter>
</React.StrictMode>,
);

View File

@@ -0,0 +1,69 @@
import {
Container,
Typography,
Card,
CardContent,
Box,
} from '@mui/material';
import { Build } from '@mui/icons-material';
import DashboardLayout from '../components/dashboard/DashboardLayout';
function Ausruestung() {
return (
<DashboardLayout>
<Container maxWidth="lg">
<Typography variant="h4" gutterBottom sx={{ mb: 4 }}>
Ausrüstungsverwaltung
</Typography>
<Card>
<CardContent>
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
<Build color="primary" sx={{ fontSize: 48, mr: 2 }} />
<Box>
<Typography variant="h6">Ausrüstung</Typography>
<Typography variant="body2" color="text.secondary">
Diese Funktion wird in Kürze verfügbar sein
</Typography>
</Box>
</Box>
<Box sx={{ mt: 3 }}>
<Typography variant="body1" color="text.secondary" paragraph>
Geplante Features:
</Typography>
<ul>
<li>
<Typography variant="body2" color="text.secondary">
Inventarverwaltung
</Typography>
</li>
<li>
<Typography variant="body2" color="text.secondary">
Wartungsprüfungen und -protokolle
</Typography>
</li>
<li>
<Typography variant="body2" color="text.secondary">
Prüffristen und Erinnerungen
</Typography>
</li>
<li>
<Typography variant="body2" color="text.secondary">
Schutzausrüstung (PSA)
</Typography>
</li>
<li>
<Typography variant="body2" color="text.secondary">
Atemschutzgeräte und -wartung
</Typography>
</li>
</ul>
</Box>
</CardContent>
</Card>
</Container>
</DashboardLayout>
);
}
export default Ausruestung;

View File

@@ -0,0 +1,203 @@
import { useState, useEffect } from 'react';
import {
Container,
Box,
Typography,
Grid,
Fade,
} from '@mui/material';
import {
People,
Warning,
EventNote,
LocalFireDepartment,
} 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 NextcloudCard from '../components/dashboard/NextcloudCard';
import VikunjaCard from '../components/dashboard/VikunjaCard';
import BookstackCard from '../components/dashboard/BookstackCard';
import StatsCard from '../components/dashboard/StatsCard';
import ActivityFeed from '../components/dashboard/ActivityFeed';
function Dashboard() {
const { user } = useAuth();
const [dataLoading, setDataLoading] = useState(true);
useEffect(() => {
// Simulate loading data
const timer = setTimeout(() => {
setDataLoading(false);
}, 800);
return () => clearTimeout(timer);
}, []);
return (
<DashboardLayout>
<Container maxWidth="lg">
<Grid container spacing={3}>
{/* Welcome Message */}
<Grid item xs={12}>
{dataLoading ? (
<SkeletonCard variant="basic" />
) : (
<Fade in={true} timeout={600}>
<Box>
<Typography variant="h4" gutterBottom>
Willkommen zurück, {user?.given_name || user?.name.split(' ')[0]}!
</Typography>
</Box>
</Fade>
)}
</Grid>
{/* User Profile Card */}
{user && (
<Grid item xs={12}>
{dataLoading ? (
<SkeletonCard variant="detailed" />
) : (
<Fade in={true} timeout={600} style={{ transitionDelay: '100ms' }}>
<Box>
<UserProfile user={user} />
</Box>
</Fade>
)}
</Grid>
)}
{/* Stats Cards Row */}
<Grid item xs={12} sm={6} md={3}>
{dataLoading ? (
<SkeletonCard variant="basic" />
) : (
<Fade in={true} timeout={600} style={{ transitionDelay: '200ms' }}>
<Box>
<StatsCard
title="Aktive Mitglieder"
value="24"
icon={People}
color="primary.main"
/>
</Box>
</Fade>
)}
</Grid>
<Grid item xs={12} sm={6} md={3}>
{dataLoading ? (
<SkeletonCard variant="basic" />
) : (
<Fade in={true} timeout={600} style={{ transitionDelay: '250ms' }}>
<Box>
<StatsCard
title="Einsätze (Jahr)"
value="18"
icon={Warning}
color="error.main"
/>
</Box>
</Fade>
)}
</Grid>
<Grid item xs={12} sm={6} md={3}>
{dataLoading ? (
<SkeletonCard variant="basic" />
) : (
<Fade in={true} timeout={600} style={{ transitionDelay: '300ms' }}>
<Box>
<StatsCard
title="Offene Aufgaben"
value="7"
icon={EventNote}
color="warning.main"
/>
</Box>
</Fade>
)}
</Grid>
<Grid item xs={12} sm={6} md={3}>
{dataLoading ? (
<SkeletonCard variant="basic" />
) : (
<Fade in={true} timeout={600} style={{ transitionDelay: '350ms' }}>
<Box>
<StatsCard
title="Fahrzeuge"
value="5"
icon={LocalFireDepartment}
color="success.main"
/>
</Box>
</Fade>
)}
</Grid>
{/* Service Integration Cards */}
<Grid item xs={12}>
<Typography variant="h6" gutterBottom sx={{ mt: 2 }}>
Dienste und Integrationen
</Typography>
</Grid>
<Grid item xs={12} md={4}>
{dataLoading ? (
<SkeletonCard variant="basic" />
) : (
<Fade in={true} timeout={600} style={{ transitionDelay: '400ms' }}>
<Box>
<NextcloudCard
onClick={() => console.log('Nextcloud clicked')}
/>
</Box>
</Fade>
)}
</Grid>
<Grid item xs={12} md={4}>
{dataLoading ? (
<SkeletonCard variant="basic" />
) : (
<Fade in={true} timeout={600} style={{ transitionDelay: '450ms' }}>
<Box>
<VikunjaCard
onClick={() => console.log('Vikunja clicked')}
/>
</Box>
</Fade>
)}
</Grid>
<Grid item xs={12} md={4}>
{dataLoading ? (
<SkeletonCard variant="basic" />
) : (
<Fade in={true} timeout={600} style={{ transitionDelay: '500ms' }}>
<Box>
<BookstackCard
onClick={() => console.log('Bookstack clicked')}
/>
</Box>
</Fade>
)}
</Grid>
{/* Activity Feed */}
<Grid item xs={12}>
{dataLoading ? (
<SkeletonCard variant="detailed" />
) : (
<Fade in={true} timeout={600} style={{ transitionDelay: '550ms' }}>
<Box>
<ActivityFeed />
</Box>
</Fade>
)}
</Grid>
</Grid>
</Container>
</DashboardLayout>
);
}
export default Dashboard;

View File

@@ -0,0 +1,69 @@
import {
Container,
Typography,
Card,
CardContent,
Box,
} from '@mui/material';
import { LocalFireDepartment } from '@mui/icons-material';
import DashboardLayout from '../components/dashboard/DashboardLayout';
function Einsaetze() {
return (
<DashboardLayout>
<Container maxWidth="lg">
<Typography variant="h4" gutterBottom sx={{ mb: 4 }}>
Einsatzübersicht
</Typography>
<Card>
<CardContent>
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
<LocalFireDepartment color="primary" sx={{ fontSize: 48, mr: 2 }} />
<Box>
<Typography variant="h6">Einsatzverwaltung</Typography>
<Typography variant="body2" color="text.secondary">
Diese Funktion wird in Kürze verfügbar sein
</Typography>
</Box>
</Box>
<Box sx={{ mt: 3 }}>
<Typography variant="body1" color="text.secondary" paragraph>
Geplante Features:
</Typography>
<ul>
<li>
<Typography variant="body2" color="text.secondary">
Einsatzliste mit Filteroptionen
</Typography>
</li>
<li>
<Typography variant="body2" color="text.secondary">
Einsatzberichte erstellen und verwalten
</Typography>
</li>
<li>
<Typography variant="body2" color="text.secondary">
Statistiken und Auswertungen
</Typography>
</li>
<li>
<Typography variant="body2" color="text.secondary">
Einsatzdokumentation
</Typography>
</li>
<li>
<Typography variant="body2" color="text.secondary">
Alarmstufen und Kategorien
</Typography>
</li>
</ul>
</Box>
</CardContent>
</Card>
</Container>
</DashboardLayout>
);
}
export default Einsaetze;

View File

@@ -0,0 +1,69 @@
import {
Container,
Typography,
Card,
CardContent,
Box,
} from '@mui/material';
import { DirectionsCar } from '@mui/icons-material';
import DashboardLayout from '../components/dashboard/DashboardLayout';
function Fahrzeuge() {
return (
<DashboardLayout>
<Container maxWidth="lg">
<Typography variant="h4" gutterBottom sx={{ mb: 4 }}>
Fahrzeugverwaltung
</Typography>
<Card>
<CardContent>
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
<DirectionsCar color="primary" sx={{ fontSize: 48, mr: 2 }} />
<Box>
<Typography variant="h6">Fahrzeuge</Typography>
<Typography variant="body2" color="text.secondary">
Diese Funktion wird in Kürze verfügbar sein
</Typography>
</Box>
</Box>
<Box sx={{ mt: 3 }}>
<Typography variant="body1" color="text.secondary" paragraph>
Geplante Features:
</Typography>
<ul>
<li>
<Typography variant="body2" color="text.secondary">
Fahrzeugliste mit Details
</Typography>
</li>
<li>
<Typography variant="body2" color="text.secondary">
Wartungspläne und -historie
</Typography>
</li>
<li>
<Typography variant="body2" color="text.secondary">
Tankbuch und Kilometerstände
</Typography>
</li>
<li>
<Typography variant="body2" color="text.secondary">
TÜV/HU Erinnerungen
</Typography>
</li>
<li>
<Typography variant="body2" color="text.secondary">
Fahrzeugdokumentation
</Typography>
</li>
</ul>
</Box>
</CardContent>
</Card>
</Container>
</DashboardLayout>
);
}
export default Fahrzeuge;

View File

@@ -0,0 +1,122 @@
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Container,
Box,
Paper,
Button,
Typography,
CircularProgress,
Fade,
} from '@mui/material';
import { LocalFireDepartment, Login as LoginIcon } from '@mui/icons-material';
import { authService } from '../services/auth';
import { useAuth } from '../contexts/AuthContext';
function Login() {
const navigate = useNavigate();
const { isAuthenticated, isLoading } = useAuth();
const [isRedirecting, setIsRedirecting] = useState(false);
// Redirect to dashboard if already authenticated
useEffect(() => {
if (isAuthenticated) {
setIsRedirecting(true);
navigate('/dashboard', { replace: true });
}
}, [isAuthenticated, navigate]);
const handleLogin = () => {
try {
const authUrl = authService.getAuthUrl();
window.location.href = authUrl;
} catch (error) {
console.error('Failed to initiate login:', error);
}
};
if (isLoading || isRedirecting) {
return (
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
minHeight: '100vh',
bgcolor: 'background.default',
}}
>
<Box sx={{ textAlign: 'center' }}>
<CircularProgress size={60} />
<Typography variant="body1" sx={{ mt: 2 }} color="text.secondary">
{isRedirecting ? 'Weiterleitung...' : 'Lade...'}
</Typography>
</Box>
</Box>
);
}
return (
<Container component="main" maxWidth="xs">
<Box
sx={{
marginTop: 8,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
minHeight: '100vh',
}}
>
<Fade in={true} timeout={800}>
<Paper
elevation={3}
sx={{
padding: 4,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
width: '100%',
}}
>
<LocalFireDepartment sx={{ fontSize: 60, color: 'primary.main', mb: 2 }} />
<Typography component="h1" variant="h5" gutterBottom>
Feuerwehr Dashboard
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 3, textAlign: 'center' }}>
Bitte melden Sie sich mit Ihrem Authentik-Konto an
</Typography>
<Button
fullWidth
variant="contained"
size="large"
onClick={handleLogin}
startIcon={<LoginIcon />}
sx={{ mt: 2 }}
aria-label="Mit Authentik anmelden"
>
Mit Authentik anmelden
</Button>
</Paper>
</Fade>
<Box
component="footer"
sx={{
mt: 'auto',
py: 3,
}}
>
<Typography variant="caption" color="text.secondary" align="center" display="block">
Feuerwehr Dashboard v0.0.1
</Typography>
<Typography variant="caption" color="text.secondary" align="center" display="block">
{new Date().getFullYear()}
</Typography>
</Box>
</Box>
</Container>
);
}
export default Login;

View File

@@ -0,0 +1,69 @@
import {
Container,
Typography,
Card,
CardContent,
Box,
} from '@mui/material';
import { People } from '@mui/icons-material';
import DashboardLayout from '../components/dashboard/DashboardLayout';
function Mitglieder() {
return (
<DashboardLayout>
<Container maxWidth="lg">
<Typography variant="h4" gutterBottom sx={{ mb: 4 }}>
Mitgliederverwaltung
</Typography>
<Card>
<CardContent>
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
<People color="primary" sx={{ fontSize: 48, mr: 2 }} />
<Box>
<Typography variant="h6">Mitglieder</Typography>
<Typography variant="body2" color="text.secondary">
Diese Funktion wird in Kürze verfügbar sein
</Typography>
</Box>
</Box>
<Box sx={{ mt: 3 }}>
<Typography variant="body1" color="text.secondary" paragraph>
Geplante Features:
</Typography>
<ul>
<li>
<Typography variant="body2" color="text.secondary">
Mitgliederliste mit Kontaktdaten
</Typography>
</li>
<li>
<Typography variant="body2" color="text.secondary">
Qualifikationen und Lehrgänge
</Typography>
</li>
<li>
<Typography variant="body2" color="text.secondary">
Anwesenheitsverwaltung
</Typography>
</li>
<li>
<Typography variant="body2" color="text.secondary">
Dienstpläne und -einteilungen
</Typography>
</li>
<li>
<Typography variant="body2" color="text.secondary">
Atemschutz-G26 Untersuchungen
</Typography>
</li>
</ul>
</Box>
</CardContent>
</Card>
</Container>
</DashboardLayout>
);
}
export default Mitglieder;

View File

@@ -0,0 +1,50 @@
import { useNavigate } from 'react-router-dom';
import { Container, Box, Typography, Button, Paper } from '@mui/material';
import { Home } from '@mui/icons-material';
function NotFound() {
const navigate = useNavigate();
return (
<Container component="main" maxWidth="sm">
<Box
sx={{
marginTop: 8,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
}}
>
<Paper
elevation={3}
sx={{
padding: 4,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
width: '100%',
}}
>
<Typography variant="h1" component="h1" color="error" gutterBottom>
404
</Typography>
<Typography variant="h5" component="h2" gutterBottom>
Seite nicht gefunden
</Typography>
<Typography variant="body1" color="text.secondary" sx={{ mb: 3 }}>
Die angeforderte Seite existiert nicht.
</Typography>
<Button
variant="contained"
startIcon={<Home />}
onClick={() => navigate('/dashboard')}
>
Zurück zum Dashboard
</Button>
</Paper>
</Box>
</Container>
);
}
export default NotFound;

View File

@@ -0,0 +1,262 @@
import {
Container,
Paper,
Box,
Typography,
Avatar,
Grid,
TextField,
Card,
CardContent,
Divider,
Chip,
} from '@mui/material';
import { Person, Email, Badge, Group, AccessTime } from '@mui/icons-material';
import { useAuth } from '../contexts/AuthContext';
import DashboardLayout from '../components/dashboard/DashboardLayout';
function Profile() {
const { user } = useAuth();
if (!user) {
return null;
}
// Get initials for large avatar
const getInitials = () => {
const initials = (user.given_name?.[0] || '') + (user.family_name?.[0] || '');
return initials || user.name?.[0] || '?';
};
// Format date (if we had lastLogin)
const formatDate = (date?: Date | string) => {
if (!date) return 'Nicht verfügbar';
const d = typeof date === 'string' ? new Date(date) : date;
return d.toLocaleDateString('de-DE', {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
};
return (
<DashboardLayout>
<Container maxWidth="lg">
<Typography variant="h4" gutterBottom sx={{ mb: 4 }}>
Mein Profil
</Typography>
<Grid container spacing={3}>
{/* User Info Card */}
<Grid item xs={12} md={4}>
<Card>
<CardContent>
<Box
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
py: 2,
}}
>
<Avatar
sx={{
bgcolor: 'primary.main',
width: 120,
height: 120,
fontSize: '3rem',
mb: 2,
}}
>
{getInitials()}
</Avatar>
<Typography variant="h5" gutterBottom>
{user.name}
</Typography>
<Typography variant="body2" color="text.secondary" gutterBottom>
{user.email}
</Typography>
{user.preferred_username && (
<Typography
variant="body2"
color="text.secondary"
sx={{ display: 'flex', alignItems: 'center', gap: 0.5, mt: 1 }}
>
<Badge fontSize="small" />
@{user.preferred_username}
</Typography>
)}
</Box>
<Divider sx={{ my: 2 }} />
{/* Groups/Roles */}
{user.groups && user.groups.length > 0 && (
<Box sx={{ mt: 2 }}>
<Typography
variant="subtitle2"
color="text.secondary"
gutterBottom
sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}
>
<Group fontSize="small" />
Gruppen
</Typography>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1, mt: 1 }}>
{user.groups.map((group) => (
<Chip key={group} label={group} size="small" color="primary" />
))}
</Box>
</Box>
)}
</CardContent>
</Card>
</Grid>
{/* Personal Information */}
<Grid item xs={12} md={8}>
<Card>
<CardContent>
<Typography variant="h6" gutterBottom sx={{ mb: 3 }}>
Persönliche Informationen
</Typography>
<Grid container spacing={3}>
<Grid item xs={12} sm={6}>
<TextField
fullWidth
label="Vorname"
value={user.given_name || ''}
InputProps={{
readOnly: true,
startAdornment: (
<Person sx={{ mr: 1, color: 'text.secondary' }} />
),
}}
variant="outlined"
/>
</Grid>
<Grid item xs={12} sm={6}>
<TextField
fullWidth
label="Nachname"
value={user.family_name || ''}
InputProps={{
readOnly: true,
startAdornment: (
<Person sx={{ mr: 1, color: 'text.secondary' }} />
),
}}
variant="outlined"
/>
</Grid>
<Grid item xs={12}>
<TextField
fullWidth
label="E-Mail-Adresse"
value={user.email}
InputProps={{
readOnly: true,
startAdornment: (
<Email sx={{ mr: 1, color: 'text.secondary' }} />
),
}}
variant="outlined"
helperText="E-Mail-Adresse wird von Authentik verwaltet"
/>
</Grid>
{user.preferred_username && (
<Grid item xs={12}>
<TextField
fullWidth
label="Benutzername"
value={user.preferred_username}
InputProps={{
readOnly: true,
startAdornment: (
<Badge sx={{ mr: 1, color: 'text.secondary' }} />
),
}}
variant="outlined"
/>
</Grid>
)}
</Grid>
<Box
sx={{
mt: 3,
p: 2,
backgroundColor: 'info.lighter',
borderRadius: 1,
}}
>
<Typography variant="body2" color="info.dark">
Diese Informationen werden von Authentik verwaltet und können hier nicht
bearbeitet werden. Bitte wenden Sie sich an Ihren Administrator, um
Änderungen vorzunehmen.
</Typography>
</Box>
</CardContent>
</Card>
{/* Activity Information */}
<Card sx={{ mt: 3 }}>
<CardContent>
<Typography variant="h6" gutterBottom sx={{ mb: 3 }}>
Aktivitätsinformationen
</Typography>
<Grid container spacing={2}>
<Grid item xs={12}>
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 2,
p: 2,
backgroundColor: 'background.default',
borderRadius: 1,
}}
>
<AccessTime color="primary" />
<Box>
<Typography variant="body2" color="text.secondary">
Letzte Anmeldung
</Typography>
<Typography variant="body1">
{formatDate(new Date())}
</Typography>
</Box>
</Box>
</Grid>
</Grid>
</CardContent>
</Card>
{/* User Preferences */}
<Card sx={{ mt: 3 }}>
<CardContent>
<Typography variant="h6" gutterBottom sx={{ mb: 3 }}>
Benutzereinstellungen
</Typography>
<Typography variant="body2" color="text.secondary">
Kommende Features: Benachrichtigungseinstellungen, Anzeigeoptionen,
Spracheinstellungen
</Typography>
</CardContent>
</Card>
</Grid>
</Grid>
</Container>
</DashboardLayout>
);
}
export default Profile;

View File

@@ -0,0 +1,209 @@
import { useState } from 'react';
import {
Container,
Typography,
Card,
CardContent,
Grid,
FormGroup,
FormControlLabel,
Switch,
Divider,
Box,
Button,
} from '@mui/material';
import { Settings as SettingsIcon, Notifications, Palette, Language, Save } from '@mui/icons-material';
import DashboardLayout from '../components/dashboard/DashboardLayout';
import { useNotification } from '../contexts/NotificationContext';
function Settings() {
const notification = useNotification();
// Settings state
const [emailNotifications, setEmailNotifications] = useState(true);
const [alarmNotifications, setAlarmNotifications] = useState(true);
const [maintenanceReminders, setMaintenanceReminders] = useState(false);
const [systemNotifications, setSystemNotifications] = useState(true);
const [darkMode, setDarkMode] = useState(false);
const [compactView, setCompactView] = useState(true);
const [animations, setAnimations] = useState(true);
const handleSaveSettings = () => {
try {
// In a real application, save settings to backend
notification.showSuccess('Einstellungen erfolgreich gespeichert');
} catch (error) {
notification.showError('Fehler beim Speichern der Einstellungen');
}
};
return (
<DashboardLayout>
<Container maxWidth="lg">
<Typography variant="h4" gutterBottom sx={{ mb: 4 }}>
Einstellungen
</Typography>
<Grid container spacing={3}>
{/* Notification Settings */}
<Grid item xs={12} md={6}>
<Card>
<CardContent>
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
<Notifications color="primary" sx={{ mr: 2 }} />
<Typography variant="h6">Benachrichtigungen</Typography>
</Box>
<Divider sx={{ mb: 2 }} />
<FormGroup>
<FormControlLabel
control={
<Switch
checked={emailNotifications}
onChange={(e) => setEmailNotifications(e.target.checked)}
/>
}
label="E-Mail-Benachrichtigungen"
/>
<FormControlLabel
control={
<Switch
checked={alarmNotifications}
onChange={(e) => setAlarmNotifications(e.target.checked)}
/>
}
label="Einsatz-Alarme"
/>
<FormControlLabel
control={
<Switch
checked={maintenanceReminders}
onChange={(e) => setMaintenanceReminders(e.target.checked)}
/>
}
label="Wartungserinnerungen"
/>
<FormControlLabel
control={
<Switch
checked={systemNotifications}
onChange={(e) => setSystemNotifications(e.target.checked)}
/>
}
label="System-Benachrichtigungen"
/>
</FormGroup>
</CardContent>
</Card>
</Grid>
{/* Display Settings */}
<Grid item xs={12} md={6}>
<Card>
<CardContent>
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
<Palette color="primary" sx={{ mr: 2 }} />
<Typography variant="h6">Anzeigeoptionen</Typography>
</Box>
<Divider sx={{ mb: 2 }} />
<FormGroup>
<FormControlLabel
control={
<Switch
checked={darkMode}
onChange={(e) => {
setDarkMode(e.target.checked);
notification.showInfo('Dunkler Modus wird in einer zukünftigen Version verfügbar sein');
}}
/>
}
label="Dunkler Modus (Vorschau)"
/>
<FormControlLabel
control={
<Switch
checked={compactView}
onChange={(e) => setCompactView(e.target.checked)}
/>
}
label="Kompakte Ansicht"
/>
<FormControlLabel
control={
<Switch
checked={animations}
onChange={(e) => setAnimations(e.target.checked)}
/>
}
label="Animationen"
/>
</FormGroup>
</CardContent>
</Card>
</Grid>
{/* Language Settings */}
<Grid item xs={12} md={6}>
<Card>
<CardContent>
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
<Language color="primary" sx={{ mr: 2 }} />
<Typography variant="h6">Sprache</Typography>
</Box>
<Divider sx={{ mb: 2 }} />
<Typography variant="body2" color="text.secondary">
Aktuelle Sprache: Deutsch
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mt: 2 }}>
Kommende Features: Sprachauswahl, Datumsformat, Zeitzone
</Typography>
</CardContent>
</Card>
</Grid>
{/* General Settings */}
<Grid item xs={12} md={6}>
<Card>
<CardContent>
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
<SettingsIcon color="primary" sx={{ mr: 2 }} />
<Typography variant="h6">Allgemein</Typography>
</Box>
<Divider sx={{ mb: 2 }} />
<Typography variant="body2" color="text.secondary">
Kommende Features: Dashboard-Layout, Standardansichten, Exporteinstellungen
</Typography>
</CardContent>
</Card>
</Grid>
</Grid>
<Box
sx={{
mt: 3,
p: 2,
backgroundColor: 'info.lighter',
borderRadius: 1,
}}
>
<Typography variant="body2" color="info.dark">
Diese Einstellungen sind derzeit nur zur Demonstration verfügbar. Die Funktionalität
wird in zukünftigen Updates implementiert.
</Typography>
</Box>
<Box sx={{ mt: 3, display: 'flex', justifyContent: 'flex-end' }}>
<Button
variant="contained"
color="primary"
size="large"
startIcon={<Save />}
onClick={handleSaveSettings}
>
Einstellungen speichern
</Button>
</Box>
</Container>
</DashboardLayout>
);
}
export default Settings;

View File

@@ -0,0 +1,102 @@
import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse, AxiosError } from 'axios';
import { API_URL } from '../utils/config';
import { getToken, removeToken, removeUser } from '../utils/storage';
export interface ApiError {
message: string;
status?: number;
code?: string;
}
class ApiService {
private axiosInstance: AxiosInstance;
constructor() {
this.axiosInstance = axios.create({
baseURL: API_URL,
timeout: 30000, // 30 seconds timeout
headers: {
'Content-Type': 'application/json',
},
});
// Request interceptor: Add Authorization header with JWT
this.axiosInstance.interceptors.request.use(
(config) => {
const token = getToken();
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
},
(error) => {
console.error('Request interceptor error:', error);
return Promise.reject(this.handleError(error));
}
);
// Response interceptor: Handle errors
this.axiosInstance.interceptors.response.use(
(response) => response,
(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';
}
return Promise.reject(this.handleError(error));
}
);
}
private handleError(error: AxiosError): ApiError {
if (error.response) {
// Server responded with error
const message = (error.response.data as any)?.message ||
(error.response.data as any)?.error ||
error.message ||
'Ein Fehler ist aufgetreten';
return {
message,
status: error.response.status,
code: error.code,
};
} else if (error.request) {
// Request was made but no response received
return {
message: 'Keine Antwort vom Server. Bitte überprüfen Sie Ihre Internetverbindung.',
code: error.code,
};
} else {
// Something else happened
return {
message: error.message || 'Ein unerwarteter Fehler ist aufgetreten',
code: error.code,
};
}
}
async get<T = any>(url: string, config?: AxiosRequestConfig): Promise<AxiosResponse<T>> {
return this.axiosInstance.get<T>(url, config);
}
async post<T = any>(url: string, data?: any, config?: AxiosRequestConfig): Promise<AxiosResponse<T>> {
return this.axiosInstance.post<T>(url, data, config);
}
async put<T = any>(url: string, data?: any, config?: AxiosRequestConfig): Promise<AxiosResponse<T>> {
return this.axiosInstance.put<T>(url, data, config);
}
async delete<T = any>(url: string, config?: AxiosRequestConfig): Promise<AxiosResponse<T>> {
return this.axiosInstance.delete<T>(url, config);
}
async patch<T = any>(url: string, data?: any, config?: AxiosRequestConfig): Promise<AxiosResponse<T>> {
return this.axiosInstance.patch<T>(url, data, config);
}
}
export const api = new ApiService();

View File

@@ -0,0 +1,58 @@
import { api } from './api';
import { AUTHENTIK_URL, CLIENT_ID } from '../utils/config';
import { User } from '../types/auth.types';
const REDIRECT_URI = `${window.location.origin}/auth/callback`;
export interface AuthCallbackResponse {
token: string;
user: User;
}
export const authService = {
/**
* Generate Authentik authorization URL
*/
getAuthUrl(): string {
const params = new URLSearchParams({
client_id: CLIENT_ID,
redirect_uri: REDIRECT_URI,
response_type: 'code',
scope: 'openid profile email',
});
return `${AUTHENTIK_URL}/application/o/authorize/?${params.toString()}`;
},
/**
* Handle OAuth callback - send code to backend, receive JWT
*/
async handleCallback(code: string): Promise<AuthCallbackResponse> {
const response = await api.post<AuthCallbackResponse>('/api/auth/callback', {
code,
redirect_uri: REDIRECT_URI,
});
return response.data;
},
/**
* Logout - clear tokens
*/
async logout(): Promise<void> {
try {
// Optionally call backend logout endpoint
await api.post('/api/auth/logout');
} catch (error) {
console.error('Error during logout:', error);
// Continue with logout even if backend call fails
}
},
/**
* Get current user information
*/
async getCurrentUser(): Promise<User> {
const response = await api.get<User>('/api/user/me');
return response.data;
},
};

178
frontend/src/theme/theme.ts Normal file
View File

@@ -0,0 +1,178 @@
import { createTheme, ThemeOptions } from '@mui/material/styles';
// Fire department red color palette
const primaryRed = {
main: '#d32f2f',
light: '#ff6659',
dark: '#9a0007',
contrastText: '#ffffff',
};
const secondaryBlue = {
main: '#1976d2',
light: '#63a4ff',
dark: '#004ba0',
contrastText: '#ffffff',
};
const lightThemeOptions: ThemeOptions = {
palette: {
mode: 'light',
primary: primaryRed,
secondary: secondaryBlue,
background: {
default: '#f5f5f5',
paper: '#ffffff',
},
error: {
main: '#f44336',
},
warning: {
main: '#ff9800',
},
info: {
main: '#2196f3',
},
success: {
main: '#4caf50',
},
},
typography: {
fontFamily: [
'-apple-system',
'BlinkMacSystemFont',
'"Segoe UI"',
'Roboto',
'"Helvetica Neue"',
'Arial',
'sans-serif',
].join(','),
h1: {
fontSize: '2.5rem',
fontWeight: 600,
lineHeight: 1.2,
},
h2: {
fontSize: '2rem',
fontWeight: 600,
lineHeight: 1.3,
},
h3: {
fontSize: '1.75rem',
fontWeight: 600,
lineHeight: 1.4,
},
h4: {
fontSize: '1.5rem',
fontWeight: 600,
lineHeight: 1.4,
},
h5: {
fontSize: '1.25rem',
fontWeight: 600,
lineHeight: 1.5,
},
h6: {
fontSize: '1rem',
fontWeight: 600,
lineHeight: 1.6,
},
body1: {
fontSize: '1rem',
lineHeight: 1.5,
},
body2: {
fontSize: '0.875rem',
lineHeight: 1.43,
},
button: {
textTransform: 'none',
fontWeight: 500,
},
},
spacing: 8,
shape: {
borderRadius: 8,
},
components: {
MuiButton: {
styleOverrides: {
root: {
borderRadius: 8,
padding: '8px 16px',
},
contained: {
boxShadow: 'none',
'&:hover': {
boxShadow: '0 2px 4px rgba(0,0,0,0.2)',
},
},
},
},
MuiCard: {
styleOverrides: {
root: {
borderRadius: 12,
boxShadow: '0 1px 3px rgba(0,0,0,0.12), 0 1px 2px rgba(0,0,0,0.24)',
transition: 'all 0.3s cubic-bezier(.25,.8,.25,1)',
'&:hover': {
boxShadow: '0 4px 8px rgba(0,0,0,0.16), 0 3px 6px rgba(0,0,0,0.23)',
},
},
},
},
MuiPaper: {
styleOverrides: {
root: {
borderRadius: 8,
},
elevation1: {
boxShadow: '0 1px 3px rgba(0,0,0,0.12), 0 1px 2px rgba(0,0,0,0.24)',
},
elevation2: {
boxShadow: '0 3px 6px rgba(0,0,0,0.15), 0 2px 4px rgba(0,0,0,0.12)',
},
elevation3: {
boxShadow: '0 4px 8px rgba(0,0,0,0.16), 0 3px 6px rgba(0,0,0,0.23)',
},
},
},
MuiAppBar: {
styleOverrides: {
root: {
boxShadow: '0 2px 4px rgba(0,0,0,0.1)',
},
},
},
},
};
const darkThemeOptions: ThemeOptions = {
...lightThemeOptions,
palette: {
mode: 'dark',
primary: primaryRed,
secondary: secondaryBlue,
background: {
default: '#121212',
paper: '#1e1e1e',
},
error: {
main: '#f44336',
},
warning: {
main: '#ff9800',
},
info: {
main: '#2196f3',
},
success: {
main: '#4caf50',
},
},
};
export const lightTheme = createTheme(lightThemeOptions);
export const darkTheme = createTheme(darkThemeOptions);
export default lightTheme;

View File

@@ -0,0 +1,28 @@
export interface User {
id: string;
email: string;
name: string;
given_name: string;
family_name: string;
preferred_username?: string;
groups?: string[];
}
export interface AuthTokens {
token: string;
refreshToken?: string;
expiresIn?: number;
}
export interface AuthState {
user: User | null;
token: string | null;
isAuthenticated: boolean;
isLoading: boolean;
}
export interface AuthContextType extends AuthState {
login: (code: string) => Promise<void>;
logout: () => void;
refreshAuth: () => Promise<void>;
}

View File

@@ -0,0 +1,9 @@
export const config = {
apiUrl: import.meta.env.VITE_API_URL || 'http://localhost:3000',
authentikUrl: import.meta.env.VITE_AUTHENTIK_URL || 'https://authentik.yourdomain.com',
clientId: import.meta.env.VITE_CLIENT_ID || 'your_client_id_here',
};
export const API_URL = config.apiUrl;
export const AUTHENTIK_URL = config.authentikUrl;
export const CLIENT_ID = config.clientId;

View File

@@ -0,0 +1,56 @@
import { User } from '../types/auth.types';
const TOKEN_KEY = 'auth_token';
const USER_KEY = 'auth_user';
export const getToken = (): string | null => {
try {
return localStorage.getItem(TOKEN_KEY);
} catch (error) {
console.error('Error getting token from localStorage:', error);
return null;
}
};
export const setToken = (token: string): void => {
try {
localStorage.setItem(TOKEN_KEY, token);
} catch (error) {
console.error('Error setting token in localStorage:', error);
}
};
export const removeToken = (): void => {
try {
localStorage.removeItem(TOKEN_KEY);
} catch (error) {
console.error('Error removing token from localStorage:', error);
}
};
export const getUser = (): User | null => {
try {
const userStr = localStorage.getItem(USER_KEY);
if (!userStr) return null;
return JSON.parse(userStr) as User;
} catch (error) {
console.error('Error getting user from localStorage:', error);
return null;
}
};
export const setUser = (user: User): void => {
try {
localStorage.setItem(USER_KEY, JSON.stringify(user));
} catch (error) {
console.error('Error setting user in localStorage:', error);
}
};
export const removeUser = (): void => {
try {
localStorage.removeItem(USER_KEY);
} catch (error) {
console.error('Error removing user from localStorage:', error);
}
};

10
frontend/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1,10 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_API_URL: string;
// Add more env variables as needed
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}