feat: service mode (maintenance mode)

Admins can toggle maintenance mode from the admin dashboard (new
"Wartung" tab). When active, all non-admin users see a full-page
maintenance screen instead of the app.

- Backend: GET /api/config/service-mode endpoint (authenticated)
- Backend: stores state in app_settings key 'service_mode'
- Frontend: ServiceModeGuard wraps all ProtectedRoutes
- Frontend: ServiceModePage full-screen maintenance UI
- Frontend: ServiceModeTab in admin dashboard with toggle + message
- Admins (dashboard_admin group) always bypass the guard

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Matthias Hochmeister
2026-03-12 13:21:49 +01:00
parent 6c1cbb0ef3
commit 21b7be22db
9 changed files with 197 additions and 3 deletions

View File

@@ -3,6 +3,16 @@ import environment from '../config/environment';
import settingsService from '../services/settings.service'; import settingsService from '../services/settings.service';
class ConfigController { class ConfigController {
async getServiceMode(_req: Request, res: Response): Promise<void> {
try {
const setting = await settingsService.get('service_mode');
const value = setting?.value ?? { active: false, message: '' };
res.json({ success: true, data: value });
} catch {
res.json({ success: true, data: { active: false, message: '' } });
}
}
async getExternalLinks(_req: Request, res: Response): Promise<void> { async getExternalLinks(_req: Request, res: Response): Promise<void> {
const envLinks: Record<string, string> = {}; const envLinks: Record<string, string> = {};
if (environment.nextcloudUrl) envLinks.nextcloud = environment.nextcloudUrl; if (environment.nextcloudUrl) envLinks.nextcloud = environment.nextcloudUrl;

View File

@@ -4,5 +4,6 @@ import { authenticate } from '../middleware/auth.middleware';
const router = Router(); const router = Router();
router.get('/external-links', authenticate, configController.getExternalLinks.bind(configController)); router.get('/external-links', authenticate, configController.getExternalLinks.bind(configController));
router.get('/service-mode', authenticate, configController.getServiceMode.bind(configController));
export default router; export default router;

View File

@@ -0,0 +1,103 @@
import { useState } from 'react';
import {
Box, Card, CardContent, Typography, Switch, FormControlLabel,
TextField, Button, Alert, CircularProgress,
} from '@mui/material';
import BuildIcon from '@mui/icons-material/Build';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { settingsApi } from '../../services/settings';
import { useNotification } from '../../contexts/NotificationContext';
export default function ServiceModeTab() {
const queryClient = useQueryClient();
const { showSuccess, showError } = useNotification();
const { data: setting, isLoading } = useQuery({
queryKey: ['admin', 'settings', 'service_mode'],
queryFn: () => settingsApi.get('service_mode'),
});
const currentValue = setting?.value ?? { active: false, message: '' };
const [active, setActive] = useState<boolean>(currentValue.active ?? false);
const [message, setMessage] = useState<string>(currentValue.message ?? '');
// Sync state when data loads
useState(() => {
if (setting?.value) {
setActive(setting.value.active ?? false);
setMessage(setting.value.message ?? '');
}
});
const mutation = useMutation({
mutationFn: (value: { active: boolean; message: string }) =>
settingsApi.update('service_mode', value),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['service-mode'] });
queryClient.invalidateQueries({ queryKey: ['admin', 'settings', 'service_mode'] });
showSuccess(active ? 'Wartungsmodus aktiviert' : 'Wartungsmodus deaktiviert');
},
onError: () => showError('Einstellung konnte nicht gespeichert werden'),
});
const handleSave = () => {
mutation.mutate({ active, message });
};
if (isLoading) {
return <Box sx={{ display: 'flex', justifyContent: 'center', p: 4 }}><CircularProgress /></Box>;
}
return (
<Box sx={{ maxWidth: 600 }}>
<Card>
<CardContent>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 3 }}>
<BuildIcon color={active ? 'error' : 'action'} />
<Typography variant="h6">Wartungsmodus</Typography>
</Box>
{active && (
<Alert severity="warning" sx={{ mb: 2 }}>
Wartungsmodus ist aktiv. Normale Benutzer sehen die Wartungsseite.
</Alert>
)}
<FormControlLabel
control={
<Switch
checked={active}
onChange={(e) => setActive(e.target.checked)}
color="error"
/>
}
label={active ? 'Wartungsmodus aktiviert' : 'Wartungsmodus deaktiviert'}
sx={{ mb: 3, display: 'block' }}
/>
<TextField
fullWidth
multiline
rows={3}
label="Nachricht für Benutzer"
placeholder="Das Dashboard befindet sich aktuell im Wartungsmodus..."
value={message}
onChange={(e) => setMessage(e.target.value)}
sx={{ mb: 3 }}
helperText="Diese Nachricht sehen Benutzer auf der Wartungsseite."
/>
<Button
variant="contained"
color={active ? 'error' : 'primary'}
onClick={handleSave}
disabled={mutation.isPending}
startIcon={mutation.isPending ? <CircularProgress size={16} /> : <BuildIcon />}
>
{mutation.isPending ? 'Wird gespeichert...' : 'Speichern'}
</Button>
</CardContent>
</Card>
</Box>
);
}

View File

@@ -2,6 +2,7 @@ import React from 'react';
import { Navigate } from 'react-router-dom'; import { Navigate } from 'react-router-dom';
import { useAuth } from '../../contexts/AuthContext'; import { useAuth } from '../../contexts/AuthContext';
import { Box, CircularProgress, Typography } from '@mui/material'; import { Box, CircularProgress, Typography } from '@mui/material';
import ServiceModeGuard from '../shared/ServiceModeGuard';
interface ProtectedRouteProps { interface ProtectedRouteProps {
children: React.ReactNode; children: React.ReactNode;
@@ -36,8 +37,8 @@ const ProtectedRoute: React.FC<ProtectedRouteProps> = ({ children }) => {
return <Navigate to="/login" replace state={{ from: window.location.pathname + window.location.search }} />; return <Navigate to="/login" replace state={{ from: window.location.pathname + window.location.search }} />;
} }
// User is authenticated, render children // User is authenticated, apply service mode guard then render children
return <>{children}</>; return <ServiceModeGuard>{children}</ServiceModeGuard>;
}; };
export default ProtectedRoute; export default ProtectedRoute;

View File

@@ -0,0 +1,26 @@
import { useQuery } from '@tanstack/react-query';
import { useAuth } from '../../contexts/AuthContext';
import { configApi } from '../../services/config';
import ServiceModePage from './ServiceModePage';
interface Props {
children: React.ReactNode;
}
export default function ServiceModeGuard({ children }: Props) {
const { user } = useAuth();
const isAdmin = user?.groups?.includes('dashboard_admin') ?? false;
const { data: serviceMode } = useQuery({
queryKey: ['service-mode'],
queryFn: configApi.getServiceMode,
refetchInterval: 60_000,
retry: false,
});
if (serviceMode?.active && !isAdmin) {
return <ServiceModePage message={serviceMode.message} />;
}
return <>{children}</>;
}

View File

@@ -0,0 +1,38 @@
import { Box, Typography, Paper } from '@mui/material';
import BuildIcon from '@mui/icons-material/Build';
import LocalFireDepartmentIcon from '@mui/icons-material/LocalFireDepartment';
interface Props {
message?: string;
}
export default function ServiceModePage({ message }: Props) {
return (
<Box
sx={{
minHeight: '100vh',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
bgcolor: 'background.default',
p: 3,
}}
>
<Paper elevation={3} sx={{ p: 6, maxWidth: 520, textAlign: 'center', borderRadius: 3 }}>
<Box sx={{ display: 'flex', justifyContent: 'center', gap: 2, mb: 3 }}>
<LocalFireDepartmentIcon sx={{ fontSize: 48, color: 'error.main' }} />
<BuildIcon sx={{ fontSize: 48, color: 'text.secondary' }} />
</Box>
<Typography variant="h4" fontWeight={700} gutterBottom>
Wartungsarbeiten
</Typography>
<Typography variant="body1" color="text.secondary" sx={{ mb: 2 }}>
{message || 'Das Dashboard befindet sich aktuell im Wartungsmodus. Bitte versuche es später erneut.'}
</Typography>
<Typography variant="caption" color="text.disabled">
Feuerwehr Dashboard
</Typography>
</Paper>
</Box>
);
}

View File

@@ -7,6 +7,7 @@ import SystemHealthTab from '../components/admin/SystemHealthTab';
import UserOverviewTab from '../components/admin/UserOverviewTab'; import UserOverviewTab from '../components/admin/UserOverviewTab';
import NotificationBroadcastTab from '../components/admin/NotificationBroadcastTab'; import NotificationBroadcastTab from '../components/admin/NotificationBroadcastTab';
import BannerManagementTab from '../components/admin/BannerManagementTab'; import BannerManagementTab from '../components/admin/BannerManagementTab';
import ServiceModeTab from '../components/admin/ServiceModeTab';
import { useAuth } from '../contexts/AuthContext'; import { useAuth } from '../contexts/AuthContext';
interface TabPanelProps { interface TabPanelProps {
@@ -41,6 +42,7 @@ function AdminDashboard() {
<Tab label="Benutzer" /> <Tab label="Benutzer" />
<Tab label="Broadcast" /> <Tab label="Broadcast" />
<Tab label="Banner" /> <Tab label="Banner" />
<Tab label="Wartung" />
</Tabs> </Tabs>
</Box> </Box>
@@ -59,6 +61,9 @@ function AdminDashboard() {
<TabPanel value={tab} index={4}> <TabPanel value={tab} index={4}>
<BannerManagementTab /> <BannerManagementTab />
</TabPanel> </TabPanel>
<TabPanel value={tab} index={5}>
<ServiceModeTab />
</TabPanel>
</DashboardLayout> </DashboardLayout>
); );
} }

View File

@@ -1,5 +1,5 @@
import { api } from './api'; import { api } from './api';
import type { ExternalLinks } from '../types/config.types'; import type { ExternalLinks, ServiceModeStatus } from '../types/config.types';
interface ApiResponse<T> { interface ApiResponse<T> {
success: boolean; success: boolean;
@@ -12,4 +12,9 @@ export const configApi = {
.get<ApiResponse<ExternalLinks>>('/api/config/external-links') .get<ApiResponse<ExternalLinks>>('/api/config/external-links')
.then((r) => r.data.data); .then((r) => r.data.data);
}, },
getServiceMode(): Promise<ServiceModeStatus> {
return api
.get<ApiResponse<ServiceModeStatus>>('/api/config/service-mode')
.then((r) => r.data.data);
},
}; };

View File

@@ -4,3 +4,8 @@ export interface ExternalLinks {
vikunja?: string; vikunja?: string;
customLinks?: Array<{ name: string; url: string }>; customLinks?: Array<{ name: string; url: string }>;
} }
export interface ServiceModeStatus {
active: boolean;
message: string;
}