diff --git a/backend/src/controllers/config.controller.ts b/backend/src/controllers/config.controller.ts index a47c43f..6da7256 100644 --- a/backend/src/controllers/config.controller.ts +++ b/backend/src/controllers/config.controller.ts @@ -3,6 +3,16 @@ import environment from '../config/environment'; import settingsService from '../services/settings.service'; class ConfigController { + async getServiceMode(_req: Request, res: Response): Promise { + 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 { const envLinks: Record = {}; if (environment.nextcloudUrl) envLinks.nextcloud = environment.nextcloudUrl; diff --git a/backend/src/routes/config.routes.ts b/backend/src/routes/config.routes.ts index 7d7e52f..93ca8f4 100644 --- a/backend/src/routes/config.routes.ts +++ b/backend/src/routes/config.routes.ts @@ -4,5 +4,6 @@ import { authenticate } from '../middleware/auth.middleware'; const router = Router(); router.get('/external-links', authenticate, configController.getExternalLinks.bind(configController)); +router.get('/service-mode', authenticate, configController.getServiceMode.bind(configController)); export default router; diff --git a/frontend/src/components/admin/ServiceModeTab.tsx b/frontend/src/components/admin/ServiceModeTab.tsx new file mode 100644 index 0000000..7ddb031 --- /dev/null +++ b/frontend/src/components/admin/ServiceModeTab.tsx @@ -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(currentValue.active ?? false); + const [message, setMessage] = useState(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 ; + } + + return ( + + + + + + Wartungsmodus + + + {active && ( + + Wartungsmodus ist aktiv. Normale Benutzer sehen die Wartungsseite. + + )} + + setActive(e.target.checked)} + color="error" + /> + } + label={active ? 'Wartungsmodus aktiviert' : 'Wartungsmodus deaktiviert'} + sx={{ mb: 3, display: 'block' }} + /> + + setMessage(e.target.value)} + sx={{ mb: 3 }} + helperText="Diese Nachricht sehen Benutzer auf der Wartungsseite." + /> + + + + + + ); +} diff --git a/frontend/src/components/auth/ProtectedRoute.tsx b/frontend/src/components/auth/ProtectedRoute.tsx index 9490ee9..933db4c 100644 --- a/frontend/src/components/auth/ProtectedRoute.tsx +++ b/frontend/src/components/auth/ProtectedRoute.tsx @@ -2,6 +2,7 @@ import React from 'react'; import { Navigate } from 'react-router-dom'; import { useAuth } from '../../contexts/AuthContext'; import { Box, CircularProgress, Typography } from '@mui/material'; +import ServiceModeGuard from '../shared/ServiceModeGuard'; interface ProtectedRouteProps { children: React.ReactNode; @@ -36,8 +37,8 @@ const ProtectedRoute: React.FC = ({ children }) => { return ; } - // User is authenticated, render children - return <>{children}; + // User is authenticated, apply service mode guard then render children + return {children}; }; export default ProtectedRoute; diff --git a/frontend/src/components/shared/ServiceModeGuard.tsx b/frontend/src/components/shared/ServiceModeGuard.tsx new file mode 100644 index 0000000..22e5364 --- /dev/null +++ b/frontend/src/components/shared/ServiceModeGuard.tsx @@ -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 ; + } + + return <>{children}; +} diff --git a/frontend/src/components/shared/ServiceModePage.tsx b/frontend/src/components/shared/ServiceModePage.tsx new file mode 100644 index 0000000..4936dc4 --- /dev/null +++ b/frontend/src/components/shared/ServiceModePage.tsx @@ -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 ( + + + + + + + + Wartungsarbeiten + + + {message || 'Das Dashboard befindet sich aktuell im Wartungsmodus. Bitte versuche es später erneut.'} + + + Feuerwehr Dashboard + + + + ); +} diff --git a/frontend/src/pages/AdminDashboard.tsx b/frontend/src/pages/AdminDashboard.tsx index 3479953..65876ab 100644 --- a/frontend/src/pages/AdminDashboard.tsx +++ b/frontend/src/pages/AdminDashboard.tsx @@ -7,6 +7,7 @@ import SystemHealthTab from '../components/admin/SystemHealthTab'; import UserOverviewTab from '../components/admin/UserOverviewTab'; import NotificationBroadcastTab from '../components/admin/NotificationBroadcastTab'; import BannerManagementTab from '../components/admin/BannerManagementTab'; +import ServiceModeTab from '../components/admin/ServiceModeTab'; import { useAuth } from '../contexts/AuthContext'; interface TabPanelProps { @@ -41,6 +42,7 @@ function AdminDashboard() { + @@ -59,6 +61,9 @@ function AdminDashboard() { + + + ); } diff --git a/frontend/src/services/config.ts b/frontend/src/services/config.ts index a6ea61d..a702747 100644 --- a/frontend/src/services/config.ts +++ b/frontend/src/services/config.ts @@ -1,5 +1,5 @@ import { api } from './api'; -import type { ExternalLinks } from '../types/config.types'; +import type { ExternalLinks, ServiceModeStatus } from '../types/config.types'; interface ApiResponse { success: boolean; @@ -12,4 +12,9 @@ export const configApi = { .get>('/api/config/external-links') .then((r) => r.data.data); }, + getServiceMode(): Promise { + return api + .get>('/api/config/service-mode') + .then((r) => r.data.data); + }, }; diff --git a/frontend/src/types/config.types.ts b/frontend/src/types/config.types.ts index c0a6848..583b896 100644 --- a/frontend/src/types/config.types.ts +++ b/frontend/src/types/config.types.ts @@ -4,3 +4,8 @@ export interface ExternalLinks { vikunja?: string; customLinks?: Array<{ name: string; url: string }>; } + +export interface ServiceModeStatus { + active: boolean; + message: string; +}