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:
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
103
frontend/src/components/admin/ServiceModeTab.tsx
Normal file
103
frontend/src/components/admin/ServiceModeTab.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
26
frontend/src/components/shared/ServiceModeGuard.tsx
Normal file
26
frontend/src/components/shared/ServiceModeGuard.tsx
Normal 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}</>;
|
||||||
|
}
|
||||||
38
frontend/src/components/shared/ServiceModePage.tsx
Normal file
38
frontend/src/components/shared/ServiceModePage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user