From 71a04aee897b54f0eae3ef30bed2fdb13453c74e Mon Sep 17 00:00:00 2001 From: Matthias Hochmeister Date: Thu, 12 Mar 2026 11:37:25 +0100 Subject: [PATCH] resolve issues with new features --- backend/src/app.ts | 2 + backend/src/controllers/auth.controller.ts | 8 + backend/src/controllers/config.controller.ts | 20 +- .../src/controllers/nextcloud.controller.ts | 21 +- .../src/controllers/settings.controller.ts | 62 ++++ .../migrations/024_create_app_settings.sql | 12 + backend/src/middleware/rbac.middleware.ts | 10 + backend/src/routes/settings.routes.ts | 13 + backend/src/services/bookstack.service.ts | 6 +- backend/src/services/nextcloud.service.ts | 135 +++++--- backend/src/services/settings.service.ts | 43 +++ frontend/src/App.tsx | 9 + frontend/src/components/chat/ChatPanel.tsx | 3 + .../dashboard/BookStackRecentWidget.tsx | 2 +- .../dashboard/NextcloudTalkWidget.tsx | 4 +- .../dashboard/VikunjaMyTasksWidget.tsx | 4 +- .../incidents/IncidentStatsChart.tsx | 2 +- .../src/components/shared/ErrorBoundary.tsx | 2 +- frontend/src/components/shared/Header.tsx | 20 +- frontend/src/components/shared/Sidebar.tsx | 9 +- .../components/vehicles/InspectionAlerts.tsx | 16 +- frontend/src/contexts/AuthContext.tsx | 2 +- frontend/src/contexts/ChatContext.tsx | 11 +- frontend/src/contexts/NotificationContext.tsx | 2 +- frontend/src/pages/AdminSettings.tsx | 318 ++++++++++++++++++ frontend/src/pages/EinsatzDetail.tsx | 7 +- frontend/src/pages/FahrzeugBuchungen.tsx | 12 +- frontend/src/pages/FahrzeugDetail.tsx | 8 +- frontend/src/pages/Fahrzeuge.tsx | 1 - frontend/src/pages/MitgliedDetail.tsx | 5 - frontend/src/pages/Mitglieder.tsx | 2 +- frontend/src/pages/Profile.tsx | 1 - frontend/src/pages/UebungDetail.tsx | 5 +- .../src/pages/VeranstaltungKategorien.tsx | 2 +- frontend/src/pages/Veranstaltungen.tsx | 3 +- frontend/src/pages/admin/AuditLog.tsx | 6 +- frontend/src/services/settings.ts | 18 + frontend/src/types/config.types.ts | 1 + 38 files changed, 699 insertions(+), 108 deletions(-) create mode 100644 backend/src/controllers/settings.controller.ts create mode 100644 backend/src/database/migrations/024_create_app_settings.sql create mode 100644 backend/src/routes/settings.routes.ts create mode 100644 backend/src/services/settings.service.ts create mode 100644 frontend/src/pages/AdminSettings.tsx create mode 100644 frontend/src/services/settings.ts diff --git a/backend/src/app.ts b/backend/src/app.ts index 733a3e6..dc2e7b7 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -85,6 +85,7 @@ import bookstackRoutes from './routes/bookstack.routes'; import vikunjaRoutes from './routes/vikunja.routes'; import configRoutes from './routes/config.routes'; import serviceMonitorRoutes from './routes/serviceMonitor.routes'; +import settingsRoutes from './routes/settings.routes'; app.use('/api/auth', authRoutes); app.use('/api/user', userRoutes); @@ -103,6 +104,7 @@ app.use('/api/bookstack', bookstackRoutes); app.use('/api/vikunja', vikunjaRoutes); app.use('/api/config', configRoutes); app.use('/api/admin', serviceMonitorRoutes); +app.use('/api/admin/settings', settingsRoutes); // 404 handler app.use(notFoundHandler); diff --git a/backend/src/controllers/auth.controller.ts b/backend/src/controllers/auth.controller.ts index 47e423f..18d105d 100644 --- a/backend/src/controllers/auth.controller.ts +++ b/backend/src/controllers/auth.controller.ts @@ -7,6 +7,7 @@ import logger from '../utils/logger'; import auditService, { AuditAction, AuditResourceType } from '../services/audit.service'; import { extractIp, extractUserAgent } from '../middleware/audit.middleware'; import { getUserRole } from '../middleware/rbac.middleware'; +import pool from '../config/database'; /** * Extract given_name and family_name from Authentik userinfo. @@ -372,10 +373,17 @@ class AuthController { // Generate new access token const role = await getUserRole(user.id); + // Fetch groups from DB so refreshed tokens retain group info + const groupsResult = await pool.query( + 'SELECT authentik_groups FROM users WHERE id = $1', + [user.id] + ); + const groups: string[] = groupsResult.rows[0]?.authentik_groups ?? []; const accessToken = tokenService.generateToken({ userId: user.id, email: user.email, authentikSub: user.authentik_sub, + groups, role, }); diff --git a/backend/src/controllers/config.controller.ts b/backend/src/controllers/config.controller.ts index 3e6c8dd..a47c43f 100644 --- a/backend/src/controllers/config.controller.ts +++ b/backend/src/controllers/config.controller.ts @@ -1,13 +1,23 @@ import { Request, Response } from 'express'; import environment from '../config/environment'; +import settingsService from '../services/settings.service'; class ConfigController { async getExternalLinks(_req: Request, res: Response): Promise { - const links: Record = {}; - if (environment.nextcloudUrl) links.nextcloud = environment.nextcloudUrl; - if (environment.bookstack.url) links.bookstack = environment.bookstack.url; - if (environment.vikunja.url) links.vikunja = environment.vikunja.url; - res.status(200).json({ success: true, data: links }); + const envLinks: Record = {}; + if (environment.nextcloudUrl) envLinks.nextcloud = environment.nextcloudUrl; + if (environment.bookstack.url) envLinks.bookstack = environment.bookstack.url; + if (environment.vikunja.url) envLinks.vikunja = environment.vikunja.url; + + const customLinks = await settingsService.getExternalLinks(); + + res.status(200).json({ + success: true, + data: { + ...envLinks, + customLinks, + }, + }); } } diff --git a/backend/src/controllers/nextcloud.controller.ts b/backend/src/controllers/nextcloud.controller.ts index da14d2a..8cbf787 100644 --- a/backend/src/controllers/nextcloud.controller.ts +++ b/backend/src/controllers/nextcloud.controller.ts @@ -115,7 +115,12 @@ class NextcloudController { } const messages = await nextcloudService.getMessages(token, credentials.loginName, credentials.appPassword); res.status(200).json({ success: true, data: messages }); - } catch (error) { + } catch (error: any) { + if (error?.code === 'NEXTCLOUD_AUTH_INVALID') { + await userService.clearNextcloudCredentials(req.user!.id); + res.status(200).json({ success: true, data: { connected: false } }); + return; + } logger.error('getMessages error', { error }); res.status(500).json({ success: false, message: 'Nachrichten konnten nicht geladen werden' }); } @@ -140,7 +145,12 @@ class NextcloudController { } await nextcloudService.sendMessage(token, message.trim(), credentials.loginName, credentials.appPassword); res.status(200).json({ success: true, data: null }); - } catch (error) { + } catch (error: any) { + if (error?.code === 'NEXTCLOUD_AUTH_INVALID') { + await userService.clearNextcloudCredentials(req.user!.id); + res.status(200).json({ success: true, data: { connected: false } }); + return; + } logger.error('sendMessage error', { error }); res.status(500).json({ success: false, message: 'Nachricht konnte nicht gesendet werden' }); } @@ -160,7 +170,12 @@ class NextcloudController { } await nextcloudService.markAsRead(token, credentials.loginName, credentials.appPassword); res.status(200).json({ success: true, data: null }); - } catch (error) { + } catch (error: any) { + if (error?.code === 'NEXTCLOUD_AUTH_INVALID') { + await userService.clearNextcloudCredentials(req.user!.id); + res.status(200).json({ success: true, data: { connected: false } }); + return; + } logger.error('markRoomAsRead error', { error }); res.status(500).json({ success: false, message: 'Raum konnte nicht als gelesen markiert werden' }); } diff --git a/backend/src/controllers/settings.controller.ts b/backend/src/controllers/settings.controller.ts new file mode 100644 index 0000000..f1fc9c5 --- /dev/null +++ b/backend/src/controllers/settings.controller.ts @@ -0,0 +1,62 @@ +import { Request, Response } from 'express'; +import { z } from 'zod'; +import settingsService from '../services/settings.service'; +import logger from '../utils/logger'; + +const updateSchema = z.object({ + value: z.any(), +}); + +const externalLinkSchema = z.array(z.object({ + name: z.string().min(1).max(200), + url: z.string().url().max(500), +})); + +class SettingsController { + async getAll(_req: Request, res: Response): Promise { + try { + const settings = await settingsService.getAll(); + res.json({ success: true, data: settings }); + } catch (error) { + logger.error('Failed to get settings', { error }); + res.status(500).json({ success: false, message: 'Failed to get settings' }); + } + } + + async get(req: Request, res: Response): Promise { + try { + const setting = await settingsService.get(req.params.key as string); + if (!setting) { + res.status(404).json({ success: false, message: 'Setting not found' }); + return; + } + res.json({ success: true, data: setting }); + } catch (error) { + logger.error('Failed to get setting', { error }); + res.status(500).json({ success: false, message: 'Failed to get setting' }); + } + } + + async update(req: Request, res: Response): Promise { + try { + const { value } = updateSchema.parse(req.body); + + // Validate external_links specifically + if ((req.params.key as string) === 'external_links') { + externalLinkSchema.parse(value); + } + + const setting = await settingsService.set(req.params.key as string, value, req.user!.id); + res.json({ success: true, data: setting }); + } catch (error) { + if (error instanceof z.ZodError) { + res.status(400).json({ success: false, message: 'Invalid input', errors: error.issues }); + return; + } + logger.error('Failed to update setting', { error }); + res.status(500).json({ success: false, message: 'Failed to update setting' }); + } + } +} + +export default new SettingsController(); diff --git a/backend/src/database/migrations/024_create_app_settings.sql b/backend/src/database/migrations/024_create_app_settings.sql new file mode 100644 index 0000000..c775ef5 --- /dev/null +++ b/backend/src/database/migrations/024_create_app_settings.sql @@ -0,0 +1,12 @@ +CREATE TABLE IF NOT EXISTS app_settings ( + key VARCHAR(100) PRIMARY KEY, + value JSONB NOT NULL DEFAULT '{}', + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_by UUID REFERENCES users(id) ON DELETE SET NULL +); + +-- Seed default external links (empty array) +INSERT INTO app_settings (key, value) VALUES + ('external_links', '[]'::jsonb), + ('refresh_intervals', '{"dashboard": 300000, "admin_services": 15000}'::jsonb) +ON CONFLICT (key) DO NOTHING; diff --git a/backend/src/middleware/rbac.middleware.ts b/backend/src/middleware/rbac.middleware.ts index ad92553..1b284cc 100644 --- a/backend/src/middleware/rbac.middleware.ts +++ b/backend/src/middleware/rbac.middleware.ts @@ -124,6 +124,16 @@ export function requirePermission(permission: string) { (req as Request & { userRole?: AppRole }).userRole = role; if (!hasPermission(role, permission)) { + // Fallback: dashboard_admin group grants admin:access + if (permission === 'admin:access') { + const userGroups: string[] = req.user?.groups ?? []; + if (userGroups.includes('dashboard_admin')) { + (req as Request & { userRole?: AppRole }).userRole = 'admin'; + next(); + return; + } + } + logger.warn('Permission denied', { userId: req.user.id, role, diff --git a/backend/src/routes/settings.routes.ts b/backend/src/routes/settings.routes.ts new file mode 100644 index 0000000..4646050 --- /dev/null +++ b/backend/src/routes/settings.routes.ts @@ -0,0 +1,13 @@ +import { Router } from 'express'; +import settingsController from '../controllers/settings.controller'; +import { authenticate } from '../middleware/auth.middleware'; +import { requirePermission } from '../middleware/rbac.middleware'; + +const router = Router(); +const auth = [authenticate, requirePermission('admin:access')] as const; + +router.get('/', ...auth, settingsController.getAll.bind(settingsController)); +router.get('/:key', ...auth, settingsController.get.bind(settingsController)); +router.put('/:key', ...auth, settingsController.update.bind(settingsController)); + +export default router; diff --git a/backend/src/services/bookstack.service.ts b/backend/src/services/bookstack.service.ts index c819969..deea166 100644 --- a/backend/src/services/bookstack.service.ts +++ b/backend/src/services/bookstack.service.ts @@ -98,7 +98,7 @@ async function getRecentPages(): Promise { const pages: BookStackPage[] = response.data?.data ?? []; return pages.map((p) => ({ ...p, - url: `${bookstack.url}/books/${p.book_slug}/page/${p.slug}`, + url: p.url && p.url.startsWith('http') ? p.url : `${bookstack.url}/books/${p.book_slug}/page/${p.slug}`, })); } catch (error) { if (axios.isAxiosError(error)) { @@ -122,7 +122,7 @@ async function searchPages(query: string): Promise { const response = await axios.get( `${bookstack.url}/api/search`, { - params: { query, count: 8 }, + params: { query, count: 50 }, headers: buildHeaders(), }, ); @@ -189,7 +189,7 @@ async function getPageById(id: number): Promise { html: page.html ?? '', created_at: page.created_at, updated_at: page.updated_at, - url: `${bookstack.url}/books/${page.book_slug}/page/${page.slug}`, + url: page.url && page.url.startsWith('http') ? page.url : `${bookstack.url}/books/${page.book_slug}/page/${page.slug}`, book: page.book, createdBy: page.created_by, updatedBy: page.updated_by, diff --git a/backend/src/services/nextcloud.service.ts b/backend/src/services/nextcloud.service.ts index e8097b1..4a8a4f3 100644 --- a/backend/src/services/nextcloud.service.ts +++ b/backend/src/services/nextcloud.service.ts @@ -200,30 +200,47 @@ async function getMessages(token: string, loginName: string, appPassword: string throw new Error('NEXTCLOUD_URL is not configured or is not a valid service URL'); } - const response = await axios.get( - `${baseUrl}/ocs/v2.php/apps/spreed/api/v4/chat/${encodeURIComponent(token)}`, - { - params: { lookIntoFuture: 0, limit: 50, setReadMarker: 0 }, - headers: { - 'Authorization': `Basic ${Buffer.from(loginName + ':' + appPassword).toString('base64')}`, - 'OCS-APIRequest': 'true', - 'Accept': 'application/json', + try { + const response = await axios.get( + `${baseUrl}/ocs/v2.php/apps/spreed/api/v4/chat/${encodeURIComponent(token)}`, + { + params: { lookIntoFuture: 0, limit: 50, setReadMarker: 0 }, + headers: { + 'Authorization': `Basic ${Buffer.from(loginName + ':' + appPassword).toString('base64')}`, + 'OCS-APIRequest': 'true', + 'Accept': 'application/json', + }, }, - }, - ); + ); - const messages: any[] = response.data?.ocs?.data ?? []; - return messages.map((m: any) => ({ - id: m.id, - token: m.token, - actorType: m.actorType, - actorId: m.actorId, - actorDisplayName: m.actorDisplayName, - message: m.message, - timestamp: m.timestamp, - messageType: m.messageType ?? '', - systemMessage: m.systemMessage ?? '', - })); + const messages: any[] = response.data?.ocs?.data ?? []; + return messages.map((m: any) => ({ + id: m.id, + token: m.token, + actorType: m.actorType, + actorId: m.actorId, + actorDisplayName: m.actorDisplayName, + message: m.message, + timestamp: m.timestamp, + messageType: m.messageType ?? '', + systemMessage: m.systemMessage ?? '', + })); + } catch (error) { + if (axios.isAxiosError(error) && error.response?.status === 401) { + const err = new Error('Nextcloud authentication invalid'); + (err as any).code = 'NEXTCLOUD_AUTH_INVALID'; + throw err; + } + if (axios.isAxiosError(error)) { + logger.error('NextcloudService.getMessages failed', { + status: error.response?.status, + statusText: error.response?.statusText, + }); + throw new Error(`Nextcloud API error: ${error.response?.status ?? 'unknown'}`); + } + logger.error('NextcloudService.getMessages failed', { error }); + throw new Error('Failed to fetch messages'); + } } async function sendMessage(token: string, message: string, loginName: string, appPassword: string): Promise { @@ -232,18 +249,35 @@ async function sendMessage(token: string, message: string, loginName: string, ap throw new Error('NEXTCLOUD_URL is not configured or is not a valid service URL'); } - await axios.post( - `${baseUrl}/ocs/v2.php/apps/spreed/api/v4/chat/${encodeURIComponent(token)}`, - { message }, - { - headers: { - 'Authorization': `Basic ${Buffer.from(loginName + ':' + appPassword).toString('base64')}`, - 'OCS-APIRequest': 'true', - 'Accept': 'application/json', - 'Content-Type': 'application/json', + try { + await axios.post( + `${baseUrl}/ocs/v2.php/apps/spreed/api/v4/chat/${encodeURIComponent(token)}`, + { message }, + { + headers: { + 'Authorization': `Basic ${Buffer.from(loginName + ':' + appPassword).toString('base64')}`, + 'OCS-APIRequest': 'true', + 'Accept': 'application/json', + 'Content-Type': 'application/json', + }, }, - }, - ); + ); + } catch (error) { + if (axios.isAxiosError(error) && error.response?.status === 401) { + const err = new Error('Nextcloud authentication invalid'); + (err as any).code = 'NEXTCLOUD_AUTH_INVALID'; + throw err; + } + if (axios.isAxiosError(error)) { + logger.error('NextcloudService.sendMessage failed', { + status: error.response?.status, + statusText: error.response?.statusText, + }); + throw new Error(`Nextcloud API error: ${error.response?.status ?? 'unknown'}`); + } + logger.error('NextcloudService.sendMessage failed', { error }); + throw new Error('Failed to send message'); + } } async function markAsRead(token: string, loginName: string, appPassword: string): Promise { @@ -252,16 +286,33 @@ async function markAsRead(token: string, loginName: string, appPassword: string) throw new Error('NEXTCLOUD_URL is not configured or is not a valid service URL'); } - await axios.delete( - `${baseUrl}/ocs/v2.php/apps/spreed/api/v4/chat/${encodeURIComponent(token)}/read`, - { - headers: { - 'Authorization': `Basic ${Buffer.from(loginName + ':' + appPassword).toString('base64')}`, - 'OCS-APIRequest': 'true', - 'Accept': 'application/json', + try { + await axios.delete( + `${baseUrl}/ocs/v2.php/apps/spreed/api/v4/chat/${encodeURIComponent(token)}/read`, + { + headers: { + 'Authorization': `Basic ${Buffer.from(loginName + ':' + appPassword).toString('base64')}`, + 'OCS-APIRequest': 'true', + 'Accept': 'application/json', + }, }, - }, - ); + ); + } catch (error) { + if (axios.isAxiosError(error) && error.response?.status === 401) { + const err = new Error('Nextcloud authentication invalid'); + (err as any).code = 'NEXTCLOUD_AUTH_INVALID'; + throw err; + } + if (axios.isAxiosError(error)) { + logger.error('NextcloudService.markAsRead failed', { + status: error.response?.status, + statusText: error.response?.statusText, + }); + throw new Error(`Nextcloud API error: ${error.response?.status ?? 'unknown'}`); + } + logger.error('NextcloudService.markAsRead failed', { error }); + throw new Error('Failed to mark conversation as read'); + } } async function getConversations(loginName: string, appPassword: string): Promise { diff --git a/backend/src/services/settings.service.ts b/backend/src/services/settings.service.ts new file mode 100644 index 0000000..0cf0e92 --- /dev/null +++ b/backend/src/services/settings.service.ts @@ -0,0 +1,43 @@ +import pool from '../config/database'; + +export interface AppSetting { + key: string; + value: any; + updated_at: string; + updated_by: string | null; +} + +class SettingsService { + async getAll(): Promise { + const result = await pool.query('SELECT * FROM app_settings ORDER BY key'); + return result.rows; + } + + async get(key: string): Promise { + const result = await pool.query('SELECT * FROM app_settings WHERE key = $1', [key]); + return result.rows[0] ?? null; + } + + async set(key: string, value: any, userId: string): Promise { + const result = await pool.query( + `INSERT INTO app_settings (key, value, updated_by, updated_at) + VALUES ($1, $2, $3, NOW()) + ON CONFLICT (key) DO UPDATE SET value = $2, updated_by = $3, updated_at = NOW() + RETURNING *`, + [key, JSON.stringify(value), userId] + ); + return result.rows[0]; + } + + async delete(key: string): Promise { + const result = await pool.query('DELETE FROM app_settings WHERE key = $1', [key]); + return (result.rowCount ?? 0) > 0; + } + + async getExternalLinks(): Promise> { + const setting = await this.get('external_links'); + return Array.isArray(setting?.value) ? setting.value : []; + } +} + +export default new SettingsService(); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index b836c30..2eda427 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -26,6 +26,7 @@ import Veranstaltungen from './pages/Veranstaltungen'; import VeranstaltungKategorien from './pages/VeranstaltungKategorien'; import Wissen from './pages/Wissen'; import AdminDashboard from './pages/AdminDashboard'; +import AdminSettings from './pages/AdminSettings'; import NotFound from './pages/NotFound'; function App() { @@ -221,6 +222,14 @@ function App() { } /> + + + + } + /> } /> diff --git a/frontend/src/components/chat/ChatPanel.tsx b/frontend/src/components/chat/ChatPanel.tsx index 46a6c92..a72daa0 100644 --- a/frontend/src/components/chat/ChatPanel.tsx +++ b/frontend/src/components/chat/ChatPanel.tsx @@ -6,6 +6,7 @@ import ChatIcon from '@mui/icons-material/Chat'; import Typography from '@mui/material/Typography'; import Avatar from '@mui/material/Avatar'; import Badge from '@mui/material/Badge'; +import Toolbar from '@mui/material/Toolbar'; import Tooltip from '@mui/material/Tooltip'; import List from '@mui/material/List'; import ListItem from '@mui/material/ListItem'; @@ -39,6 +40,7 @@ const ChatPanelInner: React.FC = () => { overflowY: 'auto', }} > + setChatPanelOpen(true)} aria-label="Chat öffnen"> @@ -96,6 +98,7 @@ const ChatPanelInner: React.FC = () => { overflow: 'hidden', }} > + { }); const configured = data?.configured ?? true; - const pages = data?.data ?? []; + const pages = (data?.data ?? []).slice(0, 5); if (!configured) { return ( diff --git a/frontend/src/components/dashboard/NextcloudTalkWidget.tsx b/frontend/src/components/dashboard/NextcloudTalkWidget.tsx index de7b486..f7ad41c 100644 --- a/frontend/src/components/dashboard/NextcloudTalkWidget.tsx +++ b/frontend/src/components/dashboard/NextcloudTalkWidget.tsx @@ -19,6 +19,7 @@ import { de } from 'date-fns/locale'; import { nextcloudApi } from '../../services/nextcloud'; import type { NextcloudConversation } from '../../types/nextcloud.types'; import { safeOpenUrl } from '../../utils/safeOpenUrl'; +import { useCountUp } from '../../hooks/useCountUp'; const POLL_INTERVAL = 2000; const POLL_TIMEOUT = 5 * 60 * 1000; @@ -112,6 +113,7 @@ const NextcloudTalkWidget: React.FC = () => { const connected = data?.connected ?? false; const conversations = data?.conversations?.slice(0, 5) ?? []; const totalUnread = data?.totalUnread ?? 0; + const animatedUnread = useCountUp(totalUnread); const stopPolling = useCallback(() => { if (pollIntervalRef.current) { @@ -199,7 +201,7 @@ const NextcloudTalkWidget: React.FC = () => { {connected && totalUnread > 0 && ( = { 0: { label: 'Keine', color: 'default' }, @@ -95,6 +96,7 @@ const VikunjaMyTasksWidget: React.FC = () => { const configured = data?.configured ?? true; const tasks = data?.data ?? []; + const animatedTaskCount = useCountUp(tasks.length); if (!configured) { return ( @@ -129,7 +131,7 @@ const VikunjaMyTasksWidget: React.FC = () => { Meine Aufgaben {!isLoading && !isError && tasks.length > 0 && ( - + )} diff --git a/frontend/src/components/incidents/IncidentStatsChart.tsx b/frontend/src/components/incidents/IncidentStatsChart.tsx index 53fe759..38733a3 100644 --- a/frontend/src/components/incidents/IncidentStatsChart.tsx +++ b/frontend/src/components/incidents/IncidentStatsChart.tsx @@ -215,7 +215,7 @@ const IncidentStatsChart: React.FC = ({ stats, loading outerRadius={100} paddingAngle={2} dataKey="value" - label={({ name, percent }) => + label={({ percent }: { percent: number }) => percent > 0.05 ? `${(percent * 100).toFixed(0)}%` : '' } labelLine={false} diff --git a/frontend/src/components/shared/ErrorBoundary.tsx b/frontend/src/components/shared/ErrorBoundary.tsx index 2c22357..1da51d6 100644 --- a/frontend/src/components/shared/ErrorBoundary.tsx +++ b/frontend/src/components/shared/ErrorBoundary.tsx @@ -1,4 +1,4 @@ -import React, { Component, ErrorInfo, ReactNode } from 'react'; +import { Component, ErrorInfo, ReactNode } from 'react'; import { Box, Card, CardContent, Typography, Button } from '@mui/material'; import { ErrorOutline, Refresh } from '@mui/icons-material'; diff --git a/frontend/src/components/shared/Header.tsx b/frontend/src/components/shared/Header.tsx index de00c14..e6cd8a7 100644 --- a/frontend/src/components/shared/Header.tsx +++ b/frontend/src/components/shared/Header.tsx @@ -91,9 +91,12 @@ function Header({ onMenuClick }: HeaderProps) { }; const linkEntries = externalLinks - ? Object.entries(externalLinks).filter(([, url]) => !!url) + ? Object.entries(externalLinks).filter(([key, url]) => key !== 'customLinks' && !!url) : []; + const customLinks: Array<{ name: string; url: string }> = + externalLinks?.customLinks ?? []; + const linkLabels: Record = { nextcloud: 'Nextcloud Dateien', bookstack: 'Wissensdatenbank', @@ -125,7 +128,7 @@ function Header({ onMenuClick }: HeaderProps) { {user && ( <> - {linkEntries.length > 0 && ( + {(linkEntries.length > 0 || customLinks.length > 0) && ( <> ))} + {customLinks.length > 0 && linkEntries.length > 0 && } + {customLinks.map((link, index) => ( + handleOpenExternal(link.url)}> + + + + {link.name} + + ))} )} diff --git a/frontend/src/components/shared/Sidebar.tsx b/frontend/src/components/shared/Sidebar.tsx index 774f30d..a3f5f33 100644 --- a/frontend/src/components/shared/Sidebar.tsx +++ b/frontend/src/components/shared/Sidebar.tsx @@ -20,6 +20,7 @@ import { CalendarMonth, MenuBook, AdminPanelSettings, + Settings, Menu as MenuIcon, } from '@mui/icons-material'; import { useNavigate, useLocation } from 'react-router-dom'; @@ -78,6 +79,12 @@ const adminItem: NavigationItem = { path: '/admin', }; +const adminSettingsItem: NavigationItem = { + text: 'Einstellungen', + icon: , + path: '/admin/settings', +}; + interface SidebarProps { mobileOpen: boolean; onMobileClose: () => void; @@ -92,7 +99,7 @@ function Sidebar({ mobileOpen, onMobileClose }: SidebarProps) { const isAdmin = user?.groups?.includes('dashboard_admin') ?? false; const navigationItems = useMemo(() => { - return isAdmin ? [...baseNavigationItems, adminItem] : baseNavigationItems; + return isAdmin ? [...baseNavigationItems, adminItem, adminSettingsItem] : baseNavigationItems; }, [isAdmin]); const handleNavigation = (path: string) => { diff --git a/frontend/src/components/vehicles/InspectionAlerts.tsx b/frontend/src/components/vehicles/InspectionAlerts.tsx index 8995029..ae2c17c 100644 --- a/frontend/src/components/vehicles/InspectionAlerts.tsx +++ b/frontend/src/components/vehicles/InspectionAlerts.tsx @@ -25,12 +25,6 @@ function alertTypeLabel(type: InspectionAlertType): string { type Urgency = 'overdue' | 'urgent' | 'warning'; -function getUrgency(tage: number): Urgency { - if (tage < 0) return 'overdue'; - if (tage <= 14) return 'urgent'; - return 'warning'; -} - const URGENCY_CONFIG: Record = { overdue: { severity: 'error', label: 'Überfällig' }, urgent: { severity: 'error', label: 'Dringend (≤ 14 Tage)' }, @@ -96,11 +90,11 @@ const InspectionAlerts: React.FC = ({ const urgent = alerts.filter((a) => a.tage >= 0 && a.tage <= 14); const warning = alerts.filter((a) => a.tage > 14); - const groups: Array<{ urgency: Urgency; items: InspectionAlert[] }> = [ - { urgency: 'overdue', items: overdue }, - { urgency: 'urgent', items: urgent }, - { urgency: 'warning', items: warning }, - ].filter((g) => g.items.length > 0); + const groups: Array<{ urgency: Urgency; items: InspectionAlert[] }> = ([ + { urgency: 'overdue' as Urgency, items: overdue }, + { urgency: 'urgent' as Urgency, items: urgent }, + { urgency: 'warning' as Urgency, items: warning }, + ] as Array<{ urgency: Urgency; items: InspectionAlert[] }>).filter((g) => g.items.length > 0); return ( diff --git a/frontend/src/contexts/AuthContext.tsx b/frontend/src/contexts/AuthContext.tsx index 628e78e..a9232fc 100644 --- a/frontend/src/contexts/AuthContext.tsx +++ b/frontend/src/contexts/AuthContext.tsx @@ -1,5 +1,5 @@ import React, { createContext, useCallback, useContext, useState, useEffect, ReactNode } from 'react'; -import { AuthContextType, AuthState, User } from '../types/auth.types'; +import { AuthContextType, AuthState } from '../types/auth.types'; import { authService } from '../services/auth'; import { getToken, setToken, removeToken, getUser, setUser, removeUser, setRefreshToken, removeRefreshToken } from '../utils/storage'; import { useNotification } from './NotificationContext'; diff --git a/frontend/src/contexts/ChatContext.tsx b/frontend/src/contexts/ChatContext.tsx index 75c8a35..3b911e6 100644 --- a/frontend/src/contexts/ChatContext.tsx +++ b/frontend/src/contexts/ChatContext.tsx @@ -22,11 +22,20 @@ export const ChatProvider: React.FC = ({ children }) => { const [selectedRoomToken, setSelectedRoomToken] = useState(null); const { chatPanelOpen } = useLayout(); + const { data: connData } = useQuery({ + queryKey: ['nextcloud', 'connection'], + queryFn: () => nextcloudApi.getConversations(), + refetchInterval: chatPanelOpen ? 30000 : 120000, + retry: false, + }); + + const isConnected = connData?.connected ?? false; + const { data } = useQuery({ queryKey: ['nextcloud', 'rooms'], queryFn: () => nextcloudApi.getRooms(), refetchInterval: chatPanelOpen ? 30000 : 120000, - enabled: true, + enabled: isConnected, }); const rooms = data?.rooms ?? []; diff --git a/frontend/src/contexts/NotificationContext.tsx b/frontend/src/contexts/NotificationContext.tsx index 6d91c2e..e61e70a 100644 --- a/frontend/src/contexts/NotificationContext.tsx +++ b/frontend/src/contexts/NotificationContext.tsx @@ -21,7 +21,7 @@ interface NotificationProviderProps { } export const NotificationProvider: React.FC = ({ children }) => { - const [notifications, setNotifications] = useState([]); + const [_notifications, setNotifications] = useState([]); const [currentNotification, setCurrentNotification] = useState(null); const addNotification = useCallback((message: string, severity: AlertColor) => { diff --git a/frontend/src/pages/AdminSettings.tsx b/frontend/src/pages/AdminSettings.tsx new file mode 100644 index 0000000..c697aa3 --- /dev/null +++ b/frontend/src/pages/AdminSettings.tsx @@ -0,0 +1,318 @@ +import { useState, useEffect } from 'react'; +import { + Container, + Typography, + Card, + CardContent, + Box, + Divider, + TextField, + Button, + IconButton, + MenuItem, + Select, + FormControl, + InputLabel, + Stack, + CircularProgress, +} from '@mui/material'; +import { + Delete, + Add, + Link as LinkIcon, + Timer, + Info, +} from '@mui/icons-material'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { Navigate } from 'react-router-dom'; +import DashboardLayout from '../components/dashboard/DashboardLayout'; +import { useAuth } from '../contexts/AuthContext'; +import { useNotification } from '../contexts/NotificationContext'; +import { settingsApi } from '../services/settings'; + +interface ExternalLink { + name: string; + url: string; +} + +interface RefreshIntervals { + dashboardWidgets: number; + adminServices: number; +} + +const DASHBOARD_INTERVAL_OPTIONS = [ + { value: 30, label: '30 Sekunden' }, + { value: 60, label: '1 Minute' }, + { value: 300, label: '5 Minuten' }, + { value: 600, label: '10 Minuten' }, +]; + +const ADMIN_INTERVAL_OPTIONS = [ + { value: 5, label: '5 Sekunden' }, + { value: 15, label: '15 Sekunden' }, + { value: 30, label: '30 Sekunden' }, + { value: 60, label: '60 Sekunden' }, +]; + +function AdminSettings() { + const { user } = useAuth(); + const { showSuccess, showError } = useNotification(); + const queryClient = useQueryClient(); + + const isAdmin = user?.groups?.includes('dashboard_admin') ?? false; + + // State for external links + const [externalLinks, setExternalLinks] = useState([]); + + // State for refresh intervals + const [refreshIntervals, setRefreshIntervals] = useState({ + dashboardWidgets: 60, + adminServices: 15, + }); + + // Fetch all settings + const { data: settings, isLoading } = useQuery({ + queryKey: ['admin-settings'], + queryFn: () => settingsApi.getAll(), + enabled: isAdmin, + }); + + // Initialize state from fetched settings + useEffect(() => { + if (settings) { + const linksSetting = settings.find((s) => s.key === 'external_links'); + if (linksSetting?.value) { + setExternalLinks(linksSetting.value); + } + + const intervalsSetting = settings.find((s) => s.key === 'refresh_intervals'); + if (intervalsSetting?.value) { + setRefreshIntervals({ + dashboardWidgets: intervalsSetting.value.dashboardWidgets ?? 60, + adminServices: intervalsSetting.value.adminServices ?? 15, + }); + } + } + }, [settings]); + + // Mutation for saving external links + const linksMutation = useMutation({ + mutationFn: (links: ExternalLink[]) => settingsApi.update('external_links', links), + onSuccess: () => { + showSuccess('Externe Links gespeichert'); + queryClient.invalidateQueries({ queryKey: ['admin-settings'] }); + queryClient.invalidateQueries({ queryKey: ['external-links'] }); + }, + onError: () => { + showError('Fehler beim Speichern der externen Links'); + }, + }); + + // Mutation for saving refresh intervals + const intervalsMutation = useMutation({ + mutationFn: (intervals: RefreshIntervals) => settingsApi.update('refresh_intervals', intervals), + onSuccess: () => { + showSuccess('Aktualisierungsintervalle gespeichert'); + queryClient.invalidateQueries({ queryKey: ['admin-settings'] }); + }, + onError: () => { + showError('Fehler beim Speichern der Aktualisierungsintervalle'); + }, + }); + + if (!isAdmin) { + return ; + } + + const handleAddLink = () => { + setExternalLinks([...externalLinks, { name: '', url: '' }]); + }; + + const handleRemoveLink = (index: number) => { + setExternalLinks(externalLinks.filter((_, i) => i !== index)); + }; + + const handleLinkChange = (index: number, field: keyof ExternalLink, value: string) => { + const updated = [...externalLinks]; + updated[index] = { ...updated[index], [field]: value }; + setExternalLinks(updated); + }; + + const handleSaveLinks = () => { + linksMutation.mutate(externalLinks); + }; + + const handleSaveIntervals = () => { + intervalsMutation.mutate(refreshIntervals); + }; + + // Find the most recent updated_at + const lastUpdated = settings?.reduce((latest, s) => { + if (!latest) return s.updated_at; + return new Date(s.updated_at) > new Date(latest) ? s.updated_at : latest; + }, '' as string); + + if (isLoading) { + return ( + + + + + + + + ); + } + + return ( + + + + Admin-Einstellungen + + + + {/* Section 1: External Links */} + + + + + FF Rems Tools — Externe Links + + + + + {externalLinks.map((link, index) => ( + + handleLinkChange(index, 'name', e.target.value)} + size="small" + sx={{ flex: 1 }} + /> + handleLinkChange(index, 'url', e.target.value)} + size="small" + sx={{ flex: 2 }} + /> + handleRemoveLink(index)} + aria-label="Link entfernen" + > + + + + ))} + + + + + + + + + + {/* Section 2: Refresh Intervals */} + + + + + Aktualisierungsintervalle + + + + + + Dashboard Widgets + + + + + Admin Services + + + + + + + + + + + {/* Section 3: Info */} + + + + + Info + + + + {lastUpdated + ? `Letzte Aktualisierung: ${new Date(lastUpdated).toLocaleString('de-DE')}` + : 'Noch keine Einstellungen gespeichert.'} + + + + + + + ); +} + +export default AdminSettings; diff --git a/frontend/src/pages/EinsatzDetail.tsx b/frontend/src/pages/EinsatzDetail.tsx index c8d6b15..83417a7 100644 --- a/frontend/src/pages/EinsatzDetail.tsx +++ b/frontend/src/pages/EinsatzDetail.tsx @@ -17,7 +17,6 @@ import { Tooltip, Paper, TextField, - IconButton, } from '@mui/material'; import { ArrowBack, @@ -37,7 +36,7 @@ import { de } from 'date-fns/locale'; import DashboardLayout from '../components/dashboard/DashboardLayout'; import { incidentsApi, - EinsatzDetail, + EinsatzDetail as EinsatzDetailType, EinsatzStatus, EINSATZ_ART_LABELS, EINSATZ_STATUS_LABELS, @@ -99,7 +98,7 @@ function initials(givenName: string | null, familyName: string | null, name: str return '??'; } -function displayName(p: EinsatzDetail['personal'][0]): string { +function displayName(p: EinsatzDetailType['personal'][0]): string { if (p.given_name && p.family_name) return `${p.given_name} ${p.family_name}`; if (p.name) return p.name; return p.email; @@ -166,7 +165,7 @@ function EinsatzDetail() { const navigate = useNavigate(); const notification = useNotification(); - const [einsatz, setEinsatz] = useState(null); + const [einsatz, setEinsatz] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); diff --git a/frontend/src/pages/FahrzeugBuchungen.tsx b/frontend/src/pages/FahrzeugBuchungen.tsx index f61e325..fbfbd92 100644 --- a/frontend/src/pages/FahrzeugBuchungen.tsx +++ b/frontend/src/pages/FahrzeugBuchungen.tsx @@ -398,7 +398,7 @@ function FahrzeugBuchungen() { - + theme.palette.mode === 'dark' ? 'grey.800' : 'grey.100' }}> Fahrzeug @@ -409,7 +409,7 @@ function FahrzeugBuchungen() { sx={{ fontWeight: isToday(day) ? 700 : 400, color: isToday(day) ? 'primary.main' : 'text.primary', - bgcolor: isToday(day) ? 'primary.50' : undefined, + bgcolor: isToday(day) ? (theme) => theme.palette.mode === 'dark' ? 'primary.900' : 'primary.50' : undefined, }} > @@ -445,10 +445,10 @@ function FahrzeugBuchungen() { isFree ? handleCellClick(vehicle.id, day) : undefined } sx={{ - bgcolor: isFree ? 'success.50' : undefined, + bgcolor: isFree ? (theme) => theme.palette.mode === 'dark' ? 'success.900' : 'success.50' : undefined, cursor: isFree && canCreate ? 'pointer' : 'default', '&:hover': isFree && canCreate - ? { bgcolor: 'success.100' } + ? { bgcolor: (theme) => theme.palette.mode === 'dark' ? 'success.800' : 'success.100' } : {}, p: 0.5, verticalAlign: 'top', @@ -511,9 +511,9 @@ function FahrzeugBuchungen() { sx={{ width: 16, height: 16, - bgcolor: 'success.50', + bgcolor: (theme) => theme.palette.mode === 'dark' ? 'success.900' : 'success.50', border: '1px solid', - borderColor: 'success.300', + borderColor: (theme) => theme.palette.mode === 'dark' ? 'success.700' : 'success.300', borderRadius: 0.5, }} /> diff --git a/frontend/src/pages/FahrzeugDetail.tsx b/frontend/src/pages/FahrzeugDetail.tsx index e87cbdd..09c9036 100644 --- a/frontend/src/pages/FahrzeugDetail.tsx +++ b/frontend/src/pages/FahrzeugDetail.tsx @@ -59,7 +59,7 @@ import { vehiclesApi } from '../services/vehicles'; import { fromGermanDate } from '../utils/dateInput'; import { equipmentApi } from '../services/equipment'; import { - FahrzeugDetail, + FahrzeugDetail as FahrzeugDetailType, FahrzeugWartungslog, FahrzeugStatus, FahrzeugStatusLabel, @@ -121,7 +121,7 @@ function inspectionBadgeColor(tage: number | null): 'success' | 'warning' | 'err // ── Übersicht Tab ───────────────────────────────────────────────────────────── interface UebersichtTabProps { - vehicle: FahrzeugDetail; + vehicle: FahrzeugDetailType; onStatusUpdated: () => void; canChangeStatus: boolean; } @@ -523,7 +523,7 @@ interface AusruestungTabProps { vehicleId: string; } -const AusruestungTab: React.FC = ({ equipment, vehicleId }) => { +const AusruestungTab: React.FC = ({ equipment, vehicleId: _vehicleId }) => { const navigate = useNavigate(); const hasProblems = equipment.some( @@ -642,7 +642,7 @@ function FahrzeugDetail() { const { isAdmin, canChangeStatus } = usePermissions(); const notification = useNotification(); - const [vehicle, setVehicle] = useState(null); + const [vehicle, setVehicle] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [activeTab, setActiveTab] = useState(0); diff --git a/frontend/src/pages/Fahrzeuge.tsx b/frontend/src/pages/Fahrzeuge.tsx index 0f530f4..47ebc51 100644 --- a/frontend/src/pages/Fahrzeuge.tsx +++ b/frontend/src/pages/Fahrzeuge.tsx @@ -11,7 +11,6 @@ import { Container, Fab, Grid, - IconButton, InputAdornment, TextField, Tooltip, diff --git a/frontend/src/pages/MitgliedDetail.tsx b/frontend/src/pages/MitgliedDetail.tsx index 051bc35..7c708f3 100644 --- a/frontend/src/pages/MitgliedDetail.tsx +++ b/frontend/src/pages/MitgliedDetail.tsx @@ -13,9 +13,6 @@ import { Tab, Grid, TextField, - FormControl, - InputLabel, - Select, MenuItem, CircularProgress, Alert, @@ -44,11 +41,9 @@ import { MemberWithProfile, StatusEnum, DienstgradEnum, - FunktionEnum, TshirtGroesseEnum, DIENSTGRAD_VALUES, STATUS_VALUES, - FUNKTION_VALUES, TSHIRT_GROESSE_VALUES, STATUS_LABELS, STATUS_COLORS, diff --git a/frontend/src/pages/Mitglieder.tsx b/frontend/src/pages/Mitglieder.tsx index 74e975a..4eb9933 100644 --- a/frontend/src/pages/Mitglieder.tsx +++ b/frontend/src/pages/Mitglieder.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect, useCallback, useRef } from 'react'; +import { useState, useEffect, useCallback, useRef } from 'react'; import { Container, Typography, diff --git a/frontend/src/pages/Profile.tsx b/frontend/src/pages/Profile.tsx index 05d0a58..bdacfe1 100644 --- a/frontend/src/pages/Profile.tsx +++ b/frontend/src/pages/Profile.tsx @@ -1,6 +1,5 @@ import { Container, - Paper, Box, Typography, Avatar, diff --git a/frontend/src/pages/UebungDetail.tsx b/frontend/src/pages/UebungDetail.tsx index eacab9e..060dcdc 100644 --- a/frontend/src/pages/UebungDetail.tsx +++ b/frontend/src/pages/UebungDetail.tsx @@ -5,7 +5,6 @@ import { Typography, Chip, Button, - Divider, Accordion, AccordionSummary, AccordionDetails, @@ -301,7 +300,7 @@ export default function UebungDetail() { const rsvpMutation = useMutation({ mutationFn: (status: 'zugesagt' | 'abgesagt') => trainingApi.updateRsvp(id!, status), - onSuccess: (_data, status) => { + onSuccess: (_data, _status) => { queryClient.invalidateQueries({ queryKey: ['training', 'event', id] }); queryClient.invalidateQueries({ queryKey: ['training', 'upcoming'] }); setRsvpLoading(null); @@ -342,7 +341,7 @@ export default function UebungDetail() { } const isPast = new Date(event.datum_von) < new Date(); - const isAlreadyRsvp = event.eigener_status === 'zugesagt' || event.eigener_status === 'abgesagt'; + // const isAlreadyRsvp = event.eigener_status === 'zugesagt' || event.eigener_status === 'abgesagt'; return ( diff --git a/frontend/src/pages/VeranstaltungKategorien.tsx b/frontend/src/pages/VeranstaltungKategorien.tsx index 51dea92..0fb6f9e 100644 --- a/frontend/src/pages/VeranstaltungKategorien.tsx +++ b/frontend/src/pages/VeranstaltungKategorien.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect, useCallback } from 'react'; +import { useState, useEffect, useCallback } from 'react'; import { Box, Container, diff --git a/frontend/src/pages/Veranstaltungen.tsx b/frontend/src/pages/Veranstaltungen.tsx index 721559f..5bb9cd5 100644 --- a/frontend/src/pages/Veranstaltungen.tsx +++ b/frontend/src/pages/Veranstaltungen.tsx @@ -1,7 +1,6 @@ -import React, { useState, useEffect, useCallback, useMemo } from 'react'; +import { useState, useEffect, useCallback, useMemo } from 'react'; import { Box, - Container, Typography, Button, IconButton, diff --git a/frontend/src/pages/admin/AuditLog.tsx b/frontend/src/pages/admin/AuditLog.tsx index 17237b5..92d7ad1 100644 --- a/frontend/src/pages/admin/AuditLog.tsx +++ b/frontend/src/pages/admin/AuditLog.tsx @@ -504,13 +504,13 @@ const AuditLog: React.FC = () => { const handleApplyFilters = () => { setApplied(filters); - setPaginationModel((prev) => ({ ...prev, page: 0 })); + setPaginationModel((prev: any) => ({ ...prev, page: 0 })); }; const handleResetFilters = () => { setFilters(DEFAULT_FILTERS); setApplied(DEFAULT_FILTERS); - setPaginationModel((prev) => ({ ...prev, page: 0 })); + setPaginationModel((prev: any) => ({ ...prev, page: 0 })); }; // ------------------------------------------------------------------------- @@ -723,7 +723,7 @@ const AuditLog: React.FC = () => { noRowsLabel: 'Keine Eintraege gefunden', MuiTablePagination: { labelRowsPerPage: 'Eintraege pro Seite:', - labelDisplayedRows: ({ from, to, count }) => + labelDisplayedRows: ({ from, to, count }: { from: any; to: any; count: any }) => `${from}–${to} von ${count !== -1 ? count : `mehr als ${to}`}`, }, }} diff --git a/frontend/src/services/settings.ts b/frontend/src/services/settings.ts new file mode 100644 index 0000000..d21803b --- /dev/null +++ b/frontend/src/services/settings.ts @@ -0,0 +1,18 @@ +import { api } from './api'; + +interface AppSetting { + key: string; + value: any; + updated_at: string; +} + +interface ApiResponse { + success: boolean; + data: T; +} + +export const settingsApi = { + getAll: () => api.get>('/api/admin/settings').then(r => r.data.data), + get: (key: string) => api.get>(`/api/admin/settings/${key}`).then(r => r.data.data), + update: (key: string, value: any) => api.put>(`/api/admin/settings/${key}`, { value }).then(r => r.data.data), +}; diff --git a/frontend/src/types/config.types.ts b/frontend/src/types/config.types.ts index 5a66fd7..c0a6848 100644 --- a/frontend/src/types/config.types.ts +++ b/frontend/src/types/config.types.ts @@ -2,4 +2,5 @@ export interface ExternalLinks { nextcloud?: string; bookstack?: string; vikunja?: string; + customLinks?: Array<{ name: string; url: string }>; }