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

@@ -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>
);
}