From 31f1414e0652f8328ab8f1fe586af785307466c1 Mon Sep 17 00:00:00 2001 From: Matthias Hochmeister Date: Thu, 12 Mar 2026 08:16:34 +0100 Subject: [PATCH] adding chat features, admin features and bug fixes --- backend/src/app.ts | 4 + .../src/controllers/bookstack.controller.ts | 18 ++ backend/src/controllers/config.controller.ts | 14 + .../src/controllers/nextcloud.controller.ts | 85 +++++++ .../controllers/serviceMonitor.controller.ts | 192 ++++++++++++++ .../023_create_monitored_services.sql | 13 + backend/src/routes/bookstack.routes.ts | 1 + backend/src/routes/config.routes.ts | 8 + backend/src/routes/nextcloud.routes.ts | 5 + backend/src/routes/serviceMonitor.routes.ts | 20 ++ backend/src/services/bookstack.service.ts | 57 ++++- backend/src/services/nextcloud.service.ts | 140 +++++++++- .../src/services/serviceMonitor.service.ts | 193 ++++++++++++++ frontend/package.json | 1 + frontend/src/App.tsx | 18 ++ .../admin/NotificationBroadcastTab.tsx | 131 ++++++++++ .../components/admin/ServiceManagerTab.tsx | 205 +++++++++++++++ .../src/components/admin/SystemHealthTab.tsx | 117 +++++++++ .../src/components/admin/UserOverviewTab.tsx | 166 ++++++++++++ frontend/src/components/chat/ChatMessage.tsx | 72 ++++++ .../src/components/chat/ChatMessageView.tsx | 119 +++++++++ frontend/src/components/chat/ChatPanel.tsx | 93 +++++++ frontend/src/components/chat/ChatRoomList.tsx | 48 ++++ .../dashboard/AdminStatusWidget.tsx | 67 +++++ .../components/dashboard/DashboardLayout.tsx | 21 +- frontend/src/components/dashboard/index.ts | 1 + frontend/src/components/shared/Header.tsx | 97 ++++++- frontend/src/components/shared/Sidebar.tsx | 66 ++++- frontend/src/contexts/ChatContext.tsx | 57 +++++ frontend/src/contexts/LayoutContext.tsx | 70 +++++ frontend/src/dompurify.d.ts | 7 + frontend/src/hooks/useCountUp.ts | 42 +++ frontend/src/pages/AdminDashboard.tsx | 61 +++++ frontend/src/pages/Dashboard.tsx | 13 + frontend/src/pages/Wissen.tsx | 240 ++++++++++++++++++ frontend/src/services/admin.ts | 19 ++ frontend/src/services/bookstack.ts | 8 +- frontend/src/services/config.ts | 15 ++ frontend/src/services/nextcloud.ts | 26 +- frontend/src/types/admin.types.ts | 52 ++++ frontend/src/types/bookstack.types.ts | 21 ++ frontend/src/types/config.types.ts | 5 + frontend/src/types/nextcloud.types.ts | 18 ++ 43 files changed, 2610 insertions(+), 16 deletions(-) create mode 100644 backend/src/controllers/config.controller.ts create mode 100644 backend/src/controllers/serviceMonitor.controller.ts create mode 100644 backend/src/database/migrations/023_create_monitored_services.sql create mode 100644 backend/src/routes/config.routes.ts create mode 100644 backend/src/routes/serviceMonitor.routes.ts create mode 100644 backend/src/services/serviceMonitor.service.ts create mode 100644 frontend/src/components/admin/NotificationBroadcastTab.tsx create mode 100644 frontend/src/components/admin/ServiceManagerTab.tsx create mode 100644 frontend/src/components/admin/SystemHealthTab.tsx create mode 100644 frontend/src/components/admin/UserOverviewTab.tsx create mode 100644 frontend/src/components/chat/ChatMessage.tsx create mode 100644 frontend/src/components/chat/ChatMessageView.tsx create mode 100644 frontend/src/components/chat/ChatPanel.tsx create mode 100644 frontend/src/components/chat/ChatRoomList.tsx create mode 100644 frontend/src/components/dashboard/AdminStatusWidget.tsx create mode 100644 frontend/src/contexts/ChatContext.tsx create mode 100644 frontend/src/contexts/LayoutContext.tsx create mode 100644 frontend/src/dompurify.d.ts create mode 100644 frontend/src/hooks/useCountUp.ts create mode 100644 frontend/src/pages/AdminDashboard.tsx create mode 100644 frontend/src/pages/Wissen.tsx create mode 100644 frontend/src/services/admin.ts create mode 100644 frontend/src/services/config.ts create mode 100644 frontend/src/types/admin.types.ts create mode 100644 frontend/src/types/config.types.ts diff --git a/backend/src/app.ts b/backend/src/app.ts index cde779b..733a3e6 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -83,6 +83,8 @@ import bookingRoutes from './routes/booking.routes'; import notificationRoutes from './routes/notification.routes'; import bookstackRoutes from './routes/bookstack.routes'; import vikunjaRoutes from './routes/vikunja.routes'; +import configRoutes from './routes/config.routes'; +import serviceMonitorRoutes from './routes/serviceMonitor.routes'; app.use('/api/auth', authRoutes); app.use('/api/user', userRoutes); @@ -99,6 +101,8 @@ app.use('/api/bookings', bookingRoutes); app.use('/api/notifications', notificationRoutes); app.use('/api/bookstack', bookstackRoutes); app.use('/api/vikunja', vikunjaRoutes); +app.use('/api/config', configRoutes); +app.use('/api/admin', serviceMonitorRoutes); // 404 handler app.use(notFoundHandler); diff --git a/backend/src/controllers/bookstack.controller.ts b/backend/src/controllers/bookstack.controller.ts index 5e8893a..2234af7 100644 --- a/backend/src/controllers/bookstack.controller.ts +++ b/backend/src/controllers/bookstack.controller.ts @@ -40,6 +40,24 @@ class BookStackController { res.status(500).json({ success: false, message: 'BookStack-Suche fehlgeschlagen' }); } } + async getPage(req: Request, res: Response): Promise { + if (!environment.bookstack.url) { + res.status(200).json({ success: true, data: null, configured: false }); + return; + } + const id = parseInt(String(req.params.id), 10); + if (isNaN(id) || id <= 0) { + res.status(400).json({ success: false, message: 'Ungültige Seiten-ID' }); + return; + } + try { + const page = await bookstackService.getPageById(id); + res.status(200).json({ success: true, data: page, configured: true }); + } catch (error) { + logger.error('BookStackController.getPage error', { error }); + res.status(500).json({ success: false, message: 'BookStack-Seite konnte nicht geladen werden' }); + } + } } export default new BookStackController(); diff --git a/backend/src/controllers/config.controller.ts b/backend/src/controllers/config.controller.ts new file mode 100644 index 0000000..3e6c8dd --- /dev/null +++ b/backend/src/controllers/config.controller.ts @@ -0,0 +1,14 @@ +import { Request, Response } from 'express'; +import environment from '../config/environment'; + +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 }); + } +} + +export default new ConfigController(); diff --git a/backend/src/controllers/nextcloud.controller.ts b/backend/src/controllers/nextcloud.controller.ts index f7c57c7..da14d2a 100644 --- a/backend/src/controllers/nextcloud.controller.ts +++ b/backend/src/controllers/nextcloud.controller.ts @@ -80,6 +80,91 @@ class NextcloudController { res.status(500).json({ success: false, message: 'Nextcloud-Trennung fehlgeschlagen' }); } } + + async getRooms(req: Request, res: Response): Promise { + try { + const credentials = await userService.getNextcloudCredentials(req.user!.id); + if (!credentials) { + res.status(200).json({ success: true, data: { connected: false, rooms: [] } }); + return; + } + const rooms = await nextcloudService.getAllConversations(credentials.loginName, credentials.appPassword); + res.status(200).json({ success: true, data: { connected: true, rooms, loginName: credentials.loginName } }); + } catch (error: any) { + if (error?.code === 'NEXTCLOUD_AUTH_INVALID') { + await userService.clearNextcloudCredentials(req.user!.id); + res.status(200).json({ success: true, data: { connected: false, rooms: [] } }); + return; + } + logger.error('getRooms error', { error }); + res.status(500).json({ success: false, message: 'Nextcloud-Räume konnten nicht geladen werden' }); + } + } + + async getMessages(req: Request, res: Response): Promise { + try { + const credentials = await userService.getNextcloudCredentials(req.user!.id); + if (!credentials) { + res.status(401).json({ success: false, message: 'Nextcloud nicht verbunden' }); + return; + } + const token = req.params.token as string; + if (!token) { + res.status(400).json({ success: false, message: 'Room token fehlt' }); + return; + } + const messages = await nextcloudService.getMessages(token, credentials.loginName, credentials.appPassword); + res.status(200).json({ success: true, data: messages }); + } catch (error) { + logger.error('getMessages error', { error }); + res.status(500).json({ success: false, message: 'Nachrichten konnten nicht geladen werden' }); + } + } + + async sendMessage(req: Request, res: Response): Promise { + try { + const credentials = await userService.getNextcloudCredentials(req.user!.id); + if (!credentials) { + res.status(401).json({ success: false, message: 'Nextcloud nicht verbunden' }); + return; + } + const token = req.params.token as string; + const { message } = req.body; + if (!token || !message || typeof message !== 'string' || message.trim().length === 0) { + res.status(400).json({ success: false, message: 'Token und Nachricht erforderlich' }); + return; + } + if (message.length > 32000) { + res.status(400).json({ success: false, message: 'Nachricht zu lang' }); + return; + } + await nextcloudService.sendMessage(token, message.trim(), credentials.loginName, credentials.appPassword); + res.status(200).json({ success: true, data: null }); + } catch (error) { + logger.error('sendMessage error', { error }); + res.status(500).json({ success: false, message: 'Nachricht konnte nicht gesendet werden' }); + } + } + + async markRoomAsRead(req: Request, res: Response): Promise { + try { + const credentials = await userService.getNextcloudCredentials(req.user!.id); + if (!credentials) { + res.status(401).json({ success: false, message: 'Nextcloud nicht verbunden' }); + return; + } + const token = req.params.token as string; + if (!token) { + res.status(400).json({ success: false, message: 'Room token fehlt' }); + return; + } + await nextcloudService.markAsRead(token, credentials.loginName, credentials.appPassword); + res.status(200).json({ success: true, data: null }); + } catch (error) { + logger.error('markRoomAsRead error', { error }); + res.status(500).json({ success: false, message: 'Raum konnte nicht als gelesen markiert werden' }); + } + } } export default new NextcloudController(); diff --git a/backend/src/controllers/serviceMonitor.controller.ts b/backend/src/controllers/serviceMonitor.controller.ts new file mode 100644 index 0000000..eb1dd0f --- /dev/null +++ b/backend/src/controllers/serviceMonitor.controller.ts @@ -0,0 +1,192 @@ +import { Request, Response } from 'express'; +import { z } from 'zod'; +import serviceMonitorService from '../services/serviceMonitor.service'; +import notificationService from '../services/notification.service'; +import pool from '../config/database'; +import logger from '../utils/logger'; + +const createServiceSchema = z.object({ + name: z.string().min(1).max(200), + url: z.string().url().max(500), +}); + +const updateServiceSchema = z.object({ + name: z.string().min(1).max(200).optional(), + url: z.string().url().max(500).optional(), + is_active: z.boolean().optional(), +}); + +const broadcastSchema = z.object({ + titel: z.string().min(1).max(200), + nachricht: z.string().min(1).max(2000), + schwere: z.enum(['info', 'warnung', 'fehler']).default('info'), + targetGroup: z.string().optional(), +}); + +class ServiceMonitorController { + async getAll(_req: Request, res: Response): Promise { + try { + const services = await serviceMonitorService.getAllServices(); + res.json({ success: true, data: services }); + } catch (error) { + logger.error('Failed to get services', { error }); + res.status(500).json({ success: false, message: 'Failed to get services' }); + } + } + + async create(req: Request, res: Response): Promise { + try { + const { name, url } = createServiceSchema.parse(req.body); + const service = await serviceMonitorService.createService(name, url); + res.status(201).json({ success: true, data: service }); + } catch (error) { + if (error instanceof z.ZodError) { + res.status(400).json({ success: false, message: 'Invalid input', errors: error.issues }); + return; + } + logger.error('Failed to create service', { error }); + res.status(500).json({ success: false, message: 'Failed to create service' }); + } + } + + async update(req: Request, res: Response): Promise { + try { + const data = updateServiceSchema.parse(req.body); + const service = await serviceMonitorService.updateService(req.params.id as string, data); + res.json({ success: true, data: service }); + } 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 service', { error }); + res.status(500).json({ success: false, message: 'Failed to update service' }); + } + } + + async delete(req: Request, res: Response): Promise { + try { + const deleted = await serviceMonitorService.deleteService(req.params.id as string); + if (!deleted) { + res.status(404).json({ success: false, message: 'Service not found or is internal' }); + return; + } + res.json({ success: true }); + } catch (error) { + logger.error('Failed to delete service', { error }); + res.status(500).json({ success: false, message: 'Failed to delete service' }); + } + } + + async pingAll(_req: Request, res: Response): Promise { + try { + const results = await serviceMonitorService.pingAll(); + res.json({ success: true, data: results }); + } catch (error) { + logger.error('Failed to ping services', { error }); + res.status(500).json({ success: false, message: 'Failed to ping services' }); + } + } + + async getStatusSummary(_req: Request, res: Response): Promise { + try { + const summary = await serviceMonitorService.getStatusSummary(); + res.json({ success: true, data: summary }); + } catch (error) { + logger.error('Failed to get status summary', { error }); + res.status(500).json({ success: false, message: 'Failed to get status summary' }); + } + } + + async getSystemHealth(_req: Request, res: Response): Promise { + try { + let dbStatus = false; + let dbSize = '0'; + + try { + await pool.query('SELECT 1'); + dbStatus = true; + const sizeResult = await pool.query('SELECT pg_database_size(current_database()) as size'); + dbSize = sizeResult.rows[0].size; + } catch { + // DB is down + } + + const mem = process.memoryUsage(); + res.json({ + success: true, + data: { + nodeVersion: process.version, + uptime: process.uptime(), + memoryUsage: { + heapUsed: mem.heapUsed, + heapTotal: mem.heapTotal, + rss: mem.rss, + external: mem.external, + }, + dbStatus, + dbSize, + }, + }); + } catch (error) { + logger.error('Failed to get system health', { error }); + res.status(500).json({ success: false, message: 'Failed to get system health' }); + } + } + + async getUsers(_req: Request, res: Response): Promise { + try { + const result = await pool.query( + `SELECT id, email, name, role, authentik_groups as groups, is_active, last_login_at + FROM users ORDER BY name` + ); + res.json({ success: true, data: result.rows }); + } catch (error) { + logger.error('Failed to get users', { error }); + res.status(500).json({ success: false, message: 'Failed to get users' }); + } + } + + async broadcastNotification(req: Request, res: Response): Promise { + try { + const { titel, nachricht, schwere, targetGroup } = broadcastSchema.parse(req.body); + + let users; + if (targetGroup) { + const result = await pool.query( + `SELECT id FROM users WHERE is_active = TRUE AND $1 = ANY(authentik_groups)`, + [targetGroup] + ); + users = result.rows; + } else { + const result = await pool.query( + `SELECT id FROM users WHERE is_active = TRUE` + ); + users = result.rows; + } + + let sent = 0; + for (const user of users) { + await notificationService.createNotification({ + user_id: user.id, + typ: 'broadcast', + titel, + nachricht, + schwere, + }); + sent++; + } + + res.json({ success: true, data: { sent } }); + } catch (error) { + if (error instanceof z.ZodError) { + res.status(400).json({ success: false, message: 'Invalid input', errors: error.issues }); + return; + } + logger.error('Failed to broadcast notification', { error }); + res.status(500).json({ success: false, message: 'Failed to broadcast notification' }); + } + } +} + +export default new ServiceMonitorController(); diff --git a/backend/src/database/migrations/023_create_monitored_services.sql b/backend/src/database/migrations/023_create_monitored_services.sql new file mode 100644 index 0000000..4c61f74 --- /dev/null +++ b/backend/src/database/migrations/023_create_monitored_services.sql @@ -0,0 +1,13 @@ +CREATE TABLE IF NOT EXISTS monitored_services ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + name VARCHAR(200) NOT NULL, + url VARCHAR(500) NOT NULL, + type VARCHAR(20) NOT NULL DEFAULT 'custom' CHECK (type IN ('internal','custom')), + is_active BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE OR REPLACE TRIGGER update_monitored_services_updated_at + BEFORE UPDATE ON monitored_services + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); diff --git a/backend/src/routes/bookstack.routes.ts b/backend/src/routes/bookstack.routes.ts index 881a629..9b1112a 100644 --- a/backend/src/routes/bookstack.routes.ts +++ b/backend/src/routes/bookstack.routes.ts @@ -6,5 +6,6 @@ const router = Router(); router.get('/recent', authenticate, bookstackController.getRecent.bind(bookstackController)); router.get('/search', authenticate, bookstackController.search.bind(bookstackController)); +router.get('/pages/:id', authenticate, bookstackController.getPage.bind(bookstackController)); export default router; diff --git a/backend/src/routes/config.routes.ts b/backend/src/routes/config.routes.ts new file mode 100644 index 0000000..7d7e52f --- /dev/null +++ b/backend/src/routes/config.routes.ts @@ -0,0 +1,8 @@ +import { Router } from 'express'; +import configController from '../controllers/config.controller'; +import { authenticate } from '../middleware/auth.middleware'; + +const router = Router(); +router.get('/external-links', authenticate, configController.getExternalLinks.bind(configController)); + +export default router; diff --git a/backend/src/routes/nextcloud.routes.ts b/backend/src/routes/nextcloud.routes.ts index 1c6f8f5..cfaf2c6 100644 --- a/backend/src/routes/nextcloud.routes.ts +++ b/backend/src/routes/nextcloud.routes.ts @@ -9,4 +9,9 @@ router.post('/connect', authenticate, nextcloudController.initiateConnect.bind(n router.post('/poll', authenticate, nextcloudController.pollConnect.bind(nextcloudController)); router.delete('/connect', authenticate, nextcloudController.disconnect.bind(nextcloudController)); +router.get('/rooms', authenticate, nextcloudController.getRooms.bind(nextcloudController)); +router.get('/rooms/:token/messages', authenticate, nextcloudController.getMessages.bind(nextcloudController)); +router.post('/rooms/:token/messages', authenticate, nextcloudController.sendMessage.bind(nextcloudController)); +router.post('/rooms/:token/read', authenticate, nextcloudController.markRoomAsRead.bind(nextcloudController)); + export default router; diff --git a/backend/src/routes/serviceMonitor.routes.ts b/backend/src/routes/serviceMonitor.routes.ts new file mode 100644 index 0000000..0eeba43 --- /dev/null +++ b/backend/src/routes/serviceMonitor.routes.ts @@ -0,0 +1,20 @@ +import { Router } from 'express'; +import serviceMonitorController from '../controllers/serviceMonitor.controller'; +import { authenticate } from '../middleware/auth.middleware'; +import { requirePermission } from '../middleware/rbac.middleware'; + +const router = Router(); +const auth = [authenticate, requirePermission('admin:access')] as const; + +// Static routes first (before parameterized :id routes) +router.get('/services/ping', ...auth, serviceMonitorController.pingAll.bind(serviceMonitorController)); +router.get('/services/status-summary', ...auth, serviceMonitorController.getStatusSummary.bind(serviceMonitorController)); +router.get('/services', ...auth, serviceMonitorController.getAll.bind(serviceMonitorController)); +router.post('/services', ...auth, serviceMonitorController.create.bind(serviceMonitorController)); +router.put('/services/:id', ...auth, serviceMonitorController.update.bind(serviceMonitorController)); +router.delete('/services/:id', ...auth, serviceMonitorController.delete.bind(serviceMonitorController)); +router.get('/system/health', ...auth, serviceMonitorController.getSystemHealth.bind(serviceMonitorController)); +router.get('/users', ...auth, serviceMonitorController.getUsers.bind(serviceMonitorController)); +router.post('/notifications/broadcast', ...auth, serviceMonitorController.broadcastNotification.bind(serviceMonitorController)); + +export default router; diff --git a/backend/src/services/bookstack.service.ts b/backend/src/services/bookstack.service.ts index a7348ac..b72d5e0 100644 --- a/backend/src/services/bookstack.service.ts +++ b/backend/src/services/bookstack.service.ts @@ -151,4 +151,59 @@ async function searchPages(query: string): Promise { } } -export default { getRecentPages, searchPages }; +export interface BookStackPageDetail { + id: number; + name: string; + slug: string; + book_id: number; + book_slug: string; + chapter_id: number; + html: string; + created_at: string; + updated_at: string; + url: string; + book?: { name: string }; + createdBy?: { name: string }; + updatedBy?: { name: string }; +} + +async function getPageById(id: number): Promise { + const { bookstack } = environment; + if (!bookstack.url || !isValidServiceUrl(bookstack.url)) { + throw new Error('BOOKSTACK_URL is not configured or is not a valid service URL'); + } + + try { + const response = await axios.get( + `${bookstack.url}/api/pages/${id}`, + { headers: buildHeaders() }, + ); + const page = response.data; + return { + id: page.id, + name: page.name, + slug: page.slug, + book_id: page.book_id, + book_slug: page.book_slug ?? '', + chapter_id: page.chapter_id ?? 0, + html: page.html ?? '', + created_at: page.created_at, + updated_at: page.updated_at, + url: `${bookstack.url}/books/${page.book_slug}/page/${page.slug}`, + book: page.book, + createdBy: page.created_by, + updatedBy: page.updated_by, + }; + } catch (error) { + if (axios.isAxiosError(error)) { + logger.error('BookStack getPageById failed', { + status: error.response?.status, + statusText: error.response?.statusText, + }); + } + logger.error('BookStackService.getPageById failed', { error }); + throw new Error('Failed to fetch BookStack page'); + } +} + +export default { getRecentPages, searchPages, getPageById }; diff --git a/backend/src/services/nextcloud.service.ts b/backend/src/services/nextcloud.service.ts index 58aaefc..e8097b1 100644 --- a/backend/src/services/nextcloud.service.ts +++ b/backend/src/services/nextcloud.service.ts @@ -127,6 +127,143 @@ async function pollLoginFlow(pollEndpoint: string, pollToken: string): Promise { + const baseUrl = environment.nextcloudUrl; + if (!baseUrl || !isValidServiceUrl(baseUrl)) { + throw new Error('NEXTCLOUD_URL is not configured or is not a valid service URL'); + } + + try { + const response = await axios.get( + `${baseUrl}/ocs/v2.php/apps/spreed/api/v4/room?format=json`, + { + headers: { + 'Authorization': `Basic ${Buffer.from(loginName + ':' + appPassword).toString('base64')}`, + 'OCS-APIRequest': 'true', + 'Accept': 'application/json', + }, + }, + ); + + const rooms: any[] = response.data?.ocs?.data ?? []; + return rooms + .filter((r: any) => r.type !== 4) + .sort((a: any, b: any) => (b.lastActivity ?? 0) - (a.lastActivity ?? 0)) + .map((r: any) => ({ + token: r.token, + displayName: r.displayName, + unreadMessages: r.unreadMessages ?? 0, + lastActivity: r.lastActivity ?? 0, + lastMessage: r.lastMessage + ? { + text: r.lastMessage.message ?? '', + author: r.lastMessage.actorDisplayName ?? '', + timestamp: r.lastMessage.timestamp ?? 0, + } + : null, + type: r.type, + url: `${baseUrl}/call/${r.token}`, + })); + } catch (error) { + if (axios.isAxiosError(error) && error.response?.status === 401) { + const err = new Error('Nextcloud app password is invalid'); + (err as any).code = 'NEXTCLOUD_AUTH_INVALID'; + throw err; + } + if (axios.isAxiosError(error)) { + logger.error('Nextcloud getAllConversations failed', { + status: error.response?.status, + statusText: error.response?.statusText, + }); + throw new Error(`Nextcloud API error: ${error.response?.status ?? 'unknown'}`); + } + logger.error('NextcloudService.getAllConversations failed', { error }); + throw new Error('Failed to fetch Nextcloud conversations'); + } +} + +async function getMessages(token: string, loginName: string, appPassword: string): Promise { + const baseUrl = environment.nextcloudUrl; + if (!baseUrl || !isValidServiceUrl(baseUrl)) { + 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', + }, + }, + ); + + 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 ?? '', + })); +} + +async function sendMessage(token: string, message: string, loginName: string, appPassword: string): Promise { + const baseUrl = environment.nextcloudUrl; + if (!baseUrl || !isValidServiceUrl(baseUrl)) { + 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', + }, + }, + ); +} + +async function markAsRead(token: string, loginName: string, appPassword: string): Promise { + const baseUrl = environment.nextcloudUrl; + if (!baseUrl || !isValidServiceUrl(baseUrl)) { + 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', + }, + }, + ); +} + async function getConversations(loginName: string, appPassword: string): Promise { const baseUrl = environment.nextcloudUrl; if (!baseUrl || !isValidServiceUrl(baseUrl)) { @@ -193,4 +330,5 @@ async function getConversations(loginName: string, appPassword: string): Promise } } -export default { initiateLoginFlow, pollLoginFlow, getConversations }; +export type { NextcloudChatMessage }; +export default { initiateLoginFlow, pollLoginFlow, getConversations, getAllConversations, getMessages, sendMessage, markAsRead }; diff --git a/backend/src/services/serviceMonitor.service.ts b/backend/src/services/serviceMonitor.service.ts new file mode 100644 index 0000000..de2321d --- /dev/null +++ b/backend/src/services/serviceMonitor.service.ts @@ -0,0 +1,193 @@ +import axios from 'axios'; +import pool from '../config/database'; +import environment from '../config/environment'; + +export interface MonitoredService { + id: string; + name: string; + url: string; + type: 'internal' | 'custom'; + is_active: boolean; + created_at: string; + updated_at: string; +} + +export interface PingResult { + name: string; + url: string; + status: 'up' | 'down'; + latencyMs: number; + error?: string; +} + +export interface StatusSummary { + up: number; + total: number; +} + +class ServiceMonitorService { + async getAllServices(): Promise { + const result = await pool.query( + 'SELECT * FROM monitored_services ORDER BY created_at' + ); + return result.rows; + } + + async createService(name: string, url: string, type: string = 'custom'): Promise { + const result = await pool.query( + `INSERT INTO monitored_services (name, url, type) VALUES ($1, $2, $3) RETURNING *`, + [name, url, type] + ); + return result.rows[0]; + } + + async updateService(id: string, data: Partial>): Promise { + const fields: string[] = []; + const values: unknown[] = []; + let idx = 1; + + if (data.name !== undefined) { + fields.push(`name = $${idx++}`); + values.push(data.name); + } + if (data.url !== undefined) { + fields.push(`url = $${idx++}`); + values.push(data.url); + } + if (data.is_active !== undefined) { + fields.push(`is_active = $${idx++}`); + values.push(data.is_active); + } + + if (fields.length === 0) { + throw new Error('No fields to update'); + } + + values.push(id); + const result = await pool.query( + `UPDATE monitored_services SET ${fields.join(', ')} WHERE id = $${idx} RETURNING *`, + values + ); + + if (result.rowCount === 0) { + throw new Error('Service not found'); + } + return result.rows[0]; + } + + async deleteService(id: string): Promise { + const result = await pool.query( + `DELETE FROM monitored_services WHERE id = $1 AND type = 'custom'`, + [id] + ); + return (result.rowCount ?? 0) > 0; + } + + async pingService(url: string, headers?: Record): Promise { + const start = Date.now(); + try { + await axios.get(url, { timeout: 5000, headers }); + return { + name: '', + url, + status: 'up', + latencyMs: Date.now() - start, + }; + } catch (error) { + // Treat any HTTP response (even 4xx) as "service is reachable" only for status.php-type endpoints + // For API endpoints requiring auth, a 401/403 still means the service is up + if (axios.isAxiosError(error) && error.response) { + const status = error.response.status; + // If we got a response, the service is running (auth errors = service is up) + if (headers && (status === 401 || status === 403)) { + return { + name: '', + url, + status: 'up', + latencyMs: Date.now() - start, + }; + } + } + return { + name: '', + url, + status: 'down', + latencyMs: Date.now() - start, + error: axios.isAxiosError(error) + ? `${error.code ?? 'ERROR'}: ${error.message}` + : String(error), + }; + } + } + + async pingAll(): Promise { + const services = await this.getAllServices(); + const activeServices = services.filter((s) => s.is_active); + + const internalServices = this.getInternalServices(); + + const allTargets = [ + ...activeServices.map((s) => ({ name: s.name, url: s.url, pingUrl: s.url, headers: undefined as Record | undefined })), + ...internalServices, + ]; + + const results = await Promise.all( + allTargets.map(async (target) => { + const result = await this.pingService(target.pingUrl, target.headers); + result.name = target.name; + result.url = target.url; + return result; + }) + ); + + return results; + } + + async getStatusSummary(): Promise { + const results = await this.pingAll(); + return { + up: results.filter((r) => r.status === 'up').length, + total: results.length, + }; + } + + private getInternalServices(): Array<{ name: string; url: string; pingUrl: string; headers?: Record }> { + const internal: Array<{ name: string; url: string; pingUrl: string; headers?: Record }> = []; + + const { bookstack, vikunja, nextcloudUrl } = environment; + + if (nextcloudUrl) { + internal.push({ + name: 'Nextcloud', + url: nextcloudUrl, + pingUrl: `${nextcloudUrl}/status.php`, + }); + } + + if (bookstack.url) { + internal.push({ + name: 'BookStack', + url: bookstack.url, + pingUrl: `${bookstack.url}/api/pages?count=1`, + headers: { + 'Authorization': `Token ${bookstack.tokenId}:${bookstack.tokenSecret}`, + }, + }); + } + + if (vikunja.url) { + internal.push({ + name: 'Vikunja', + url: vikunja.url, + pingUrl: `${vikunja.url}/api/v1/tasks/all?per_page=1`, + headers: { + 'Authorization': `Bearer ${vikunja.apiToken}`, + }, + }); + } + + return internal; + } +} + +export default new ServiceMonitorService(); diff --git a/frontend/package.json b/frontend/package.json index 328b615..82754e1 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -23,6 +23,7 @@ "react": "^18.2.0", "react-dom": "^18.2.0", "react-router-dom": "^6.20.0", + "dompurify": "^2.5.8", "recharts": "^2.12.7" }, "devDependencies": { diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index e9ac0c3..b836c30 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -24,6 +24,8 @@ import Kalender from './pages/Kalender'; import UebungDetail from './pages/UebungDetail'; import Veranstaltungen from './pages/Veranstaltungen'; import VeranstaltungKategorien from './pages/VeranstaltungKategorien'; +import Wissen from './pages/Wissen'; +import AdminDashboard from './pages/AdminDashboard'; import NotFound from './pages/NotFound'; function App() { @@ -203,6 +205,22 @@ function App() { } /> + + + + } + /> + + + + } + /> } /> diff --git a/frontend/src/components/admin/NotificationBroadcastTab.tsx b/frontend/src/components/admin/NotificationBroadcastTab.tsx new file mode 100644 index 0000000..f9c8d75 --- /dev/null +++ b/frontend/src/components/admin/NotificationBroadcastTab.tsx @@ -0,0 +1,131 @@ +import { useState } from 'react'; +import { + Box, + TextField, + Button, + MenuItem, + Typography, + Dialog, + DialogTitle, + DialogContent, + DialogContentText, + DialogActions, + CircularProgress, +} from '@mui/material'; +import SendIcon from '@mui/icons-material/Send'; +import { useMutation } from '@tanstack/react-query'; +import { adminApi } from '../../services/admin'; +import { useNotification } from '../../contexts/NotificationContext'; +import type { BroadcastPayload } from '../../types/admin.types'; + +function NotificationBroadcastTab() { + const { showSuccess, showError } = useNotification(); + const [titel, setTitel] = useState(''); + const [nachricht, setNachricht] = useState(''); + const [schwere, setSchwere] = useState<'info' | 'warnung' | 'fehler'>('info'); + const [targetGroup, setTargetGroup] = useState(''); + const [confirmOpen, setConfirmOpen] = useState(false); + + const broadcastMutation = useMutation({ + mutationFn: (data: BroadcastPayload) => adminApi.broadcast(data), + onSuccess: (result) => { + showSuccess(`Benachrichtigung an ${result.sent} Benutzer gesendet`); + setTitel(''); + setNachricht(''); + setSchwere('info'); + setTargetGroup(''); + }, + onError: () => { + showError('Fehler beim Senden der Benachrichtigung'); + }, + }); + + const handleSubmit = () => { + setConfirmOpen(true); + }; + + const handleConfirm = () => { + setConfirmOpen(false); + broadcastMutation.mutate({ + titel, + nachricht, + schwere, + ...(targetGroup.trim() ? { targetGroup: targetGroup.trim() } : {}), + }); + }; + + return ( + + Benachrichtigung senden + + setTitel(e.target.value)} + sx={{ mb: 2 }} + inputProps={{ maxLength: 200 }} + /> + + setNachricht(e.target.value)} + sx={{ mb: 2 }} + inputProps={{ maxLength: 2000 }} + /> + + setSchwere(e.target.value as 'info' | 'warnung' | 'fehler')} + sx={{ mb: 2 }} + > + Info + Warnung + Fehler + + + setTargetGroup(e.target.value)} + helperText="Leer lassen um an alle aktiven Benutzer zu senden" + sx={{ mb: 3 }} + /> + + + + setConfirmOpen(false)}> + Benachrichtigung senden? + + + Sind Sie sicher, dass Sie diese Benachrichtigung + {targetGroup.trim() ? ` an die Gruppe "${targetGroup.trim()}"` : ' an alle aktiven Benutzer'} senden moechten? + + + + + + + + + ); +} + +export default NotificationBroadcastTab; diff --git a/frontend/src/components/admin/ServiceManagerTab.tsx b/frontend/src/components/admin/ServiceManagerTab.tsx new file mode 100644 index 0000000..70f722c --- /dev/null +++ b/frontend/src/components/admin/ServiceManagerTab.tsx @@ -0,0 +1,205 @@ +import { useState } from 'react'; +import { + Box, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + Paper, + Button, + Dialog, + DialogTitle, + DialogContent, + DialogActions, + TextField, + IconButton, + Typography, + CircularProgress, +} from '@mui/material'; +import DeleteIcon from '@mui/icons-material/Delete'; +import AddIcon from '@mui/icons-material/Add'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { adminApi } from '../../services/admin'; +import type { PingResult } from '../../types/admin.types'; + +function ServiceManagerTab() { + const queryClient = useQueryClient(); + const [dialogOpen, setDialogOpen] = useState(false); + const [newName, setNewName] = useState(''); + const [newUrl, setNewUrl] = useState(''); + + const { data: services, isLoading: servicesLoading } = useQuery({ + queryKey: ['admin', 'services'], + queryFn: adminApi.getServices, + refetchInterval: 15000, + }); + + const { data: pingResults, isLoading: pingLoading } = useQuery({ + queryKey: ['admin', 'services', 'ping'], + queryFn: adminApi.pingAll, + refetchInterval: 15000, + }); + + const createMutation = useMutation({ + mutationFn: (data: { name: string; url: string }) => adminApi.createService(data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['admin', 'services'] }); + setDialogOpen(false); + setNewName(''); + setNewUrl(''); + }, + }); + + const deleteMutation = useMutation({ + mutationFn: (id: string) => adminApi.deleteService(id), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['admin', 'services'] }); + }, + }); + + const getPingForUrl = (url: string): PingResult | undefined => { + return pingResults?.find((p) => p.url === url); + }; + + const handleCreate = () => { + if (newName.trim() && newUrl.trim()) { + createMutation.mutate({ name: newName.trim(), url: newUrl.trim() }); + } + }; + + if (servicesLoading) { + return ; + } + + const allItems = [ + ...(services ?? []).map((s) => ({ + id: s.id, + name: s.name, + url: s.url, + type: s.type, + isCustom: s.type === 'custom', + })), + ]; + + // Also include internal services from ping results that aren't in the DB + if (pingResults) { + for (const pr of pingResults) { + if (!allItems.find((item) => item.url === pr.url)) { + allItems.push({ + id: pr.url, + name: pr.name, + url: pr.url, + type: 'internal' as const, + isCustom: false, + }); + } + } + } + + return ( + + + Service Monitor + + + + + + + + Status + Name + URL + Typ + Latenz + Aktionen + + + + {allItems.map((item) => { + const ping = getPingForUrl(item.url); + return ( + + + {pingLoading ? ( + + ) : ( + + )} + + {item.name} + + {item.url} + + {item.type} + {ping ? `${ping.latencyMs}ms` : '-'} + + {item.isCustom && ( + deleteMutation.mutate(item.id)} + disabled={deleteMutation.isPending} + > + + + )} + + + ); + })} + {allItems.length === 0 && ( + + Keine Services konfiguriert + + )} + +
+
+ + setDialogOpen(false)} maxWidth="sm" fullWidth> + Neuen Service hinzufuegen + + setNewName(e.target.value)} + /> + setNewUrl(e.target.value)} + /> + + + + + + +
+ ); +} + +export default ServiceManagerTab; diff --git a/frontend/src/components/admin/SystemHealthTab.tsx b/frontend/src/components/admin/SystemHealthTab.tsx new file mode 100644 index 0000000..08ef4f3 --- /dev/null +++ b/frontend/src/components/admin/SystemHealthTab.tsx @@ -0,0 +1,117 @@ +import { Box, Card, CardContent, Typography, Chip, LinearProgress, CircularProgress, Grid } from '@mui/material'; +import { useQuery } from '@tanstack/react-query'; +import { adminApi } from '../../services/admin'; + +function formatUptime(seconds: number): string { + const days = Math.floor(seconds / 86400); + const hours = Math.floor((seconds % 86400) / 3600); + const mins = Math.floor((seconds % 3600) / 60); + const parts: string[] = []; + if (days > 0) parts.push(`${days}d`); + if (hours > 0) parts.push(`${hours}h`); + parts.push(`${mins}m`); + return parts.join(' '); +} + +function formatBytes(bytes: number): string { + const mb = bytes / (1024 * 1024); + return `${mb.toFixed(1)} MB`; +} + +function SystemHealthTab() { + const { data: health, isLoading } = useQuery({ + queryKey: ['admin', 'system', 'health'], + queryFn: adminApi.getSystemHealth, + refetchInterval: 30000, + }); + + if (isLoading || !health) { + return ; + } + + const heapPercent = (health.memoryUsage.heapUsed / health.memoryUsage.heapTotal) * 100; + + return ( + + Systemstatus + + + + + Node.js Version + + + + + + + + + Uptime + {formatUptime(health.uptime)} + + + + + + + + Datenbank + + + {health.dbStatus ? 'Verbunden' : 'Nicht erreichbar'} + + + Groesse: {formatBytes(Number(health.dbSize))} + + + + + + + + + Heap Speicher + + {formatBytes(health.memoryUsage.heapUsed)} / {formatBytes(health.memoryUsage.heapTotal)} + + 85 ? 'error' : heapPercent > 70 ? 'warning' : 'primary'} + /> + + + + + + + + RSS Speicher + {formatBytes(health.memoryUsage.rss)} + + + + + + + + External Speicher + {formatBytes(health.memoryUsage.external)} + + + + + + ); +} + +export default SystemHealthTab; diff --git a/frontend/src/components/admin/UserOverviewTab.tsx b/frontend/src/components/admin/UserOverviewTab.tsx new file mode 100644 index 0000000..137f5af --- /dev/null +++ b/frontend/src/components/admin/UserOverviewTab.tsx @@ -0,0 +1,166 @@ +import { useState, useMemo } from 'react'; +import { + Box, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + TableSortLabel, + Paper, + TextField, + Chip, + Typography, + CircularProgress, +} from '@mui/material'; +import { useQuery } from '@tanstack/react-query'; +import { adminApi } from '../../services/admin'; +import type { UserOverview } from '../../types/admin.types'; + +type SortKey = 'name' | 'email' | 'role' | 'is_active' | 'last_login_at'; +type SortDir = 'asc' | 'desc'; + +function formatRelativeTime(dateStr: string | null): string { + if (!dateStr) return 'Nie'; + const date = new Date(dateStr); + const now = new Date(); + const diffMs = now.getTime() - date.getTime(); + const diffMins = Math.floor(diffMs / 60000); + if (diffMins < 1) return 'Gerade eben'; + if (diffMins < 60) return `vor ${diffMins}m`; + const diffHours = Math.floor(diffMins / 60); + if (diffHours < 24) return `vor ${diffHours}h`; + const diffDays = Math.floor(diffHours / 24); + return `vor ${diffDays}d`; +} + +function UserOverviewTab() { + const [search, setSearch] = useState(''); + const [sortKey, setSortKey] = useState('name'); + const [sortDir, setSortDir] = useState('asc'); + + const { data: users, isLoading } = useQuery({ + queryKey: ['admin', 'users'], + queryFn: adminApi.getUsers, + }); + + const handleSort = (key: SortKey) => { + if (sortKey === key) { + setSortDir(sortDir === 'asc' ? 'desc' : 'asc'); + } else { + setSortKey(key); + setSortDir('asc'); + } + }; + + const filtered = useMemo(() => { + if (!users) return []; + const q = search.toLowerCase(); + let result = users.filter( + (u) => u.name?.toLowerCase().includes(q) || u.email?.toLowerCase().includes(q) + ); + + result.sort((a, b) => { + const valA = a[sortKey]; + const valB = b[sortKey]; + let cmp = 0; + if (valA == null && valB == null) cmp = 0; + else if (valA == null) cmp = -1; + else if (valB == null) cmp = 1; + else if (typeof valA === 'string' && typeof valB === 'string') { + cmp = valA.localeCompare(valB); + } else if (typeof valA === 'boolean' && typeof valB === 'boolean') { + cmp = valA === valB ? 0 : valA ? 1 : -1; + } + return sortDir === 'asc' ? cmp : -cmp; + }); + return result; + }, [users, search, sortKey, sortDir]); + + if (isLoading) { + return ; + } + + return ( + + + Benutzer ({filtered.length}) + setSearch(e.target.value)} + sx={{ minWidth: 280 }} + /> + + + + + + + + handleSort('name')}> + Name + + + + handleSort('email')}> + E-Mail + + + + handleSort('role')}> + Rolle + + + Gruppen + + handleSort('is_active')}> + Status + + + + handleSort('last_login_at')}> + Letzter Login + + + + + + {filtered.map((user: UserOverview) => ( + + {user.name} + {user.email} + + + + + + {(user.groups ?? []).map((g) => ( + + ))} + + + + + + {formatRelativeTime(user.last_login_at)} + + ))} + +
+
+
+ ); +} + +export default UserOverviewTab; diff --git a/frontend/src/components/chat/ChatMessage.tsx b/frontend/src/components/chat/ChatMessage.tsx new file mode 100644 index 0000000..c7991e8 --- /dev/null +++ b/frontend/src/components/chat/ChatMessage.tsx @@ -0,0 +1,72 @@ +import React from 'react'; +import Box from '@mui/material/Box'; +import Typography from '@mui/material/Typography'; +import Paper from '@mui/material/Paper'; +import type { NextcloudMessage } from '../../types/nextcloud.types'; + +interface ChatMessageProps { + message: NextcloudMessage; + isOwnMessage: boolean; +} + +const ChatMessage: React.FC = ({ message, isOwnMessage }) => { + const time = new Date(message.timestamp * 1000).toLocaleTimeString('de-DE', { + hour: '2-digit', + minute: '2-digit', + }); + + if (message.systemMessage) { + return ( + + + {message.message} - {time} + + + ); + } + + return ( + + theme.palette.mode === 'dark' ? 'grey.800' : 'grey.200', + color: isOwnMessage ? 'primary.contrastText' : 'text.primary', + borderRadius: 2, + }} + > + {!isOwnMessage && ( + + {message.actorDisplayName} + + )} + + {message.message} + + + {time} + + + + ); +}; + +export default ChatMessage; diff --git a/frontend/src/components/chat/ChatMessageView.tsx b/frontend/src/components/chat/ChatMessageView.tsx new file mode 100644 index 0000000..daab01e --- /dev/null +++ b/frontend/src/components/chat/ChatMessageView.tsx @@ -0,0 +1,119 @@ +import React, { useEffect, useRef, useState } from 'react'; +import Box from '@mui/material/Box'; +import TextField from '@mui/material/TextField'; +import IconButton from '@mui/material/IconButton'; +import Typography from '@mui/material/Typography'; +import SendIcon from '@mui/icons-material/Send'; +import ArrowBackIcon from '@mui/icons-material/ArrowBack'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { nextcloudApi } from '../../services/nextcloud'; +import { useChat } from '../../contexts/ChatContext'; +import { useLayout } from '../../contexts/LayoutContext'; +import ChatMessage from './ChatMessage'; + +const ChatMessageView: React.FC = () => { + const { selectedRoomToken, selectRoom, rooms, loginName } = useChat(); + const { chatPanelOpen } = useLayout(); + const queryClient = useQueryClient(); + const messagesEndRef = useRef(null); + const [input, setInput] = useState(''); + + const room = rooms.find((r) => r.token === selectedRoomToken); + + const { data: messages } = useQuery({ + queryKey: ['nextcloud', 'messages', selectedRoomToken], + queryFn: () => nextcloudApi.getMessages(selectedRoomToken!), + enabled: !!selectedRoomToken && chatPanelOpen, + refetchInterval: 5000, + }); + + const sendMutation = useMutation({ + mutationFn: (message: string) => nextcloudApi.sendMessage(selectedRoomToken!, message), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['nextcloud', 'messages', selectedRoomToken] }); + queryClient.invalidateQueries({ queryKey: ['nextcloud', 'rooms'] }); + }, + }); + + useEffect(() => { + if (selectedRoomToken && chatPanelOpen) { + nextcloudApi.markAsRead(selectedRoomToken).then(() => { + queryClient.invalidateQueries({ queryKey: ['nextcloud', 'rooms'] }); + }).catch(() => {}); + } + }, [selectedRoomToken, chatPanelOpen, queryClient]); + + useEffect(() => { + messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); + }, [messages]); + + const handleSend = () => { + const trimmed = input.trim(); + if (!trimmed || !selectedRoomToken) return; + sendMutation.mutate(trimmed); + setInput(''); + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + handleSend(); + } + }; + + if (!selectedRoomToken) { + return ( + + + Raum auswählen + + + ); + } + + return ( + + + selectRoom(null)}> + + + + {room?.displayName ?? selectedRoomToken} + + + + + {messages?.map((msg) => ( + + ))} +
+ + + + setInput(e.target.value)} + onKeyDown={handleKeyDown} + multiline + maxRows={3} + /> + + + + + + ); +}; + +export default ChatMessageView; diff --git a/frontend/src/components/chat/ChatPanel.tsx b/frontend/src/components/chat/ChatPanel.tsx new file mode 100644 index 0000000..d67af3f --- /dev/null +++ b/frontend/src/components/chat/ChatPanel.tsx @@ -0,0 +1,93 @@ +import React from 'react'; +import Box from '@mui/material/Box'; +import Paper from '@mui/material/Paper'; +import IconButton from '@mui/material/IconButton'; +import ChatIcon from '@mui/icons-material/Chat'; +import Typography from '@mui/material/Typography'; +import { useLayout } from '../../contexts/LayoutContext'; +import { ChatProvider, useChat } from '../../contexts/ChatContext'; +import ChatRoomList from './ChatRoomList'; +import ChatMessageView from './ChatMessageView'; + +const ChatPanelInner: React.FC = () => { + const { chatPanelOpen, setChatPanelOpen } = useLayout(); + const { selectedRoomToken, connected } = useChat(); + + if (!chatPanelOpen) { + return ( + + setChatPanelOpen(true)}> + + + + ); + } + + return ( + + + + Chat + + setChatPanelOpen(false)}> + + + + + {!connected ? ( + + + Nextcloud nicht verbunden. Bitte verbinden Sie sich in den Einstellungen. + + + ) : selectedRoomToken ? ( + + ) : ( + + )} + + ); +}; + +const ChatPanel: React.FC = () => { + return ( + + + + ); +}; + +export default ChatPanel; diff --git a/frontend/src/components/chat/ChatRoomList.tsx b/frontend/src/components/chat/ChatRoomList.tsx new file mode 100644 index 0000000..22540f1 --- /dev/null +++ b/frontend/src/components/chat/ChatRoomList.tsx @@ -0,0 +1,48 @@ +import React from 'react'; +import Box from '@mui/material/Box'; +import List from '@mui/material/List'; +import ListItemButton from '@mui/material/ListItemButton'; +import Badge from '@mui/material/Badge'; +import Typography from '@mui/material/Typography'; +import { useChat } from '../../contexts/ChatContext'; + +const ChatRoomList: React.FC = () => { + const { rooms, selectedRoomToken, selectRoom } = useChat(); + + return ( + + + {rooms.map((room) => ( + selectRoom(room.token)} + sx={{ py: 1, px: 1.5 }} + > + + + + {room.displayName} + + {room.unreadMessages > 0 && ( + + )} + + {room.lastMessage && ( + + {room.lastMessage.author}: {room.lastMessage.text} + + )} + + + ))} + + + ); +}; + +export default ChatRoomList; diff --git a/frontend/src/components/dashboard/AdminStatusWidget.tsx b/frontend/src/components/dashboard/AdminStatusWidget.tsx new file mode 100644 index 0000000..3ccc86c --- /dev/null +++ b/frontend/src/components/dashboard/AdminStatusWidget.tsx @@ -0,0 +1,67 @@ +import { Card, CardContent, Typography, Box, Chip } from '@mui/material'; +import { useQuery } from '@tanstack/react-query'; +import { useNavigate } from 'react-router-dom'; +import { MonitorHeartOutlined } from '@mui/icons-material'; +import { adminApi } from '../../services/admin'; +import { useCountUp } from '../../hooks/useCountUp'; +import { useAuth } from '../../contexts/AuthContext'; + +function AdminStatusWidget() { + const { user } = useAuth(); + const navigate = useNavigate(); + + const isAdmin = user?.groups?.includes('dashboard_admin') ?? false; + + const { data } = useQuery({ + queryKey: ['admin-status-summary'], + queryFn: () => adminApi.getStatusSummary(), + refetchInterval: 30_000, + enabled: isAdmin, + }); + + const up = useCountUp(data?.up ?? 0); + const total = useCountUp(data?.total ?? 0); + + if (!isAdmin) return null; + + const allUp = data && data.up === data.total; + const majorityDown = data && data.total > 0 && data.up < data.total / 2; + const color = allUp ? 'success' : majorityDown ? 'error' : 'warning'; + + return ( + navigate('/admin')} + > + + + + + Service Status + + + + + + {up} + + + / {total} + + + Services online + + + + + + + ); +} + +export default AdminStatusWidget; diff --git a/frontend/src/components/dashboard/DashboardLayout.tsx b/frontend/src/components/dashboard/DashboardLayout.tsx index 86421ba..5a159f0 100644 --- a/frontend/src/components/dashboard/DashboardLayout.tsx +++ b/frontend/src/components/dashboard/DashboardLayout.tsx @@ -4,14 +4,17 @@ import Header from '../shared/Header'; import Sidebar from '../shared/Sidebar'; import { useAuth } from '../../contexts/AuthContext'; import Loading from '../shared/Loading'; +import { LayoutProvider, useLayout, DRAWER_WIDTH, DRAWER_WIDTH_COLLAPSED } from '../../contexts/LayoutContext'; +import ChatPanel from '../chat/ChatPanel'; interface DashboardLayoutProps { children: ReactNode; } -function DashboardLayout({ children }: DashboardLayoutProps) { +function DashboardLayoutInner({ children }: DashboardLayoutProps) { const [mobileOpen, setMobileOpen] = useState(false); const { isLoading } = useAuth(); + const { sidebarCollapsed, chatPanelOpen } = useLayout(); const handleDrawerToggle = () => { setMobileOpen(!mobileOpen); @@ -21,6 +24,9 @@ function DashboardLayout({ children }: DashboardLayoutProps) { return ; } + const sidebarWidth = sidebarCollapsed ? DRAWER_WIDTH_COLLAPSED : DRAWER_WIDTH; + const chatWidth = chatPanelOpen ? 360 : 60; + return (
@@ -31,16 +37,27 @@ function DashboardLayout({ children }: DashboardLayoutProps) { sx={{ flexGrow: 1, p: 3, - width: { sm: `calc(100% - 240px)` }, + width: { sm: `calc(100% - ${sidebarWidth}px - ${chatWidth}px)` }, minHeight: '100vh', backgroundColor: 'background.default', + transition: 'width 225ms cubic-bezier(0.4, 0, 0.6, 1)', }} > {children} + + ); } +function DashboardLayout({ children }: DashboardLayoutProps) { + return ( + + {children} + + ); +} + export default DashboardLayout; diff --git a/frontend/src/components/dashboard/index.ts b/frontend/src/components/dashboard/index.ts index 8007fb7..ec4b8ff 100644 --- a/frontend/src/components/dashboard/index.ts +++ b/frontend/src/components/dashboard/index.ts @@ -9,3 +9,4 @@ export { default as BookStackSearchWidget } from './BookStackSearchWidget'; export { default as VikunjaMyTasksWidget } from './VikunjaMyTasksWidget'; export { default as VikunjaQuickAddWidget } from './VikunjaQuickAddWidget'; export { default as VikunjaOverdueNotifier } from './VikunjaOverdueNotifier'; +export { default as AdminStatusWidget } from './AdminStatusWidget'; diff --git a/frontend/src/components/shared/Header.tsx b/frontend/src/components/shared/Header.tsx index 5aa465c..d24e813 100644 --- a/frontend/src/components/shared/Header.tsx +++ b/frontend/src/components/shared/Header.tsx @@ -10,6 +10,7 @@ import { ListItemIcon, Divider, Box, + Tooltip, } from '@mui/material'; import { LocalFireDepartment, @@ -17,10 +18,15 @@ import { Settings, Logout, Menu as MenuIcon, + Launch, + Chat, } from '@mui/icons-material'; +import { useQuery } from '@tanstack/react-query'; import { useAuth } from '../../contexts/AuthContext'; import { useNavigate } from 'react-router-dom'; import NotificationBell from './NotificationBell'; +import { configApi } from '../../services/config'; +import { useLayout } from '../../contexts/LayoutContext'; interface HeaderProps { onMenuClick: () => void; @@ -29,7 +35,16 @@ interface HeaderProps { function Header({ onMenuClick }: HeaderProps) { const { user, logout } = useAuth(); const navigate = useNavigate(); + const { toggleChatPanel } = useLayout(); const [anchorEl, setAnchorEl] = useState(null); + const [toolsAnchorEl, setToolsAnchorEl] = useState(null); + + const { data: externalLinks } = useQuery({ + queryKey: ['external-links'], + queryFn: () => configApi.getExternalLinks(), + staleTime: 10 * 60 * 1000, + enabled: !!user, + }); const handleMenuOpen = (event: React.MouseEvent) => { setAnchorEl(event.currentTarget); @@ -39,6 +54,14 @@ function Header({ onMenuClick }: HeaderProps) { setAnchorEl(null); }; + const handleToolsOpen = (event: React.MouseEvent) => { + setToolsAnchorEl(event.currentTarget); + }; + + const handleToolsClose = () => { + setToolsAnchorEl(null); + }; + const handleProfile = () => { handleMenuClose(); navigate('/profile'); @@ -54,6 +77,11 @@ function Header({ onMenuClick }: HeaderProps) { logout(); }; + const handleOpenExternal = (url: string) => { + handleToolsClose(); + window.open(url, '_blank', 'noopener,noreferrer'); + }; + // Get initials for avatar const getInitials = () => { if (!user) return '?'; @@ -61,6 +89,16 @@ function Header({ onMenuClick }: HeaderProps) { return initials || user.name?.[0] || '?'; }; + const linkEntries = externalLinks + ? Object.entries(externalLinks).filter(([, url]) => !!url) + : []; + + const linkLabels: Record = { + nextcloud: 'Nextcloud', + bookstack: 'BookStack', + vikunja: 'Vikunja', + }; + return ( + {linkEntries.length > 0 && ( + <> + + + + + + + + {linkEntries.map(([key, url]) => ( + handleOpenExternal(url)}> + + + + {linkLabels[key] || key} + + ))} + + + )} + + + + + + + , @@ -57,8 +66,19 @@ const navigationItems: NavigationItem[] = [ icon: , path: '/atemschutz', }, + { + text: 'Wissen', + icon: , + path: '/wissen', + }, ]; +const adminItem: NavigationItem = { + text: 'Admin', + icon: , + path: '/admin', +}; + interface SidebarProps { mobileOpen: boolean; onMobileClose: () => void; @@ -67,6 +87,14 @@ interface SidebarProps { function Sidebar({ mobileOpen, onMobileClose }: SidebarProps) { const navigate = useNavigate(); const location = useLocation(); + const { sidebarCollapsed, toggleSidebar } = useLayout(); + const { user } = useAuth(); + + const isAdmin = user?.groups?.includes('dashboard_admin') ?? false; + + const navigationItems = useMemo(() => { + return isAdmin ? [...baseNavigationItems, adminItem] : baseNavigationItems; + }, [isAdmin]); const handleNavigation = (path: string) => { navigate(path); @@ -74,19 +102,25 @@ function Sidebar({ mobileOpen, onMobileClose }: SidebarProps) { }; const drawerContent = ( - <> + - + {navigationItems.map((item) => { const isActive = location.pathname === item.path; return ( - + handleNavigation(item.path)} aria-label={`Zu ${item.text} navigieren`} sx={{ + justifyContent: sidebarCollapsed ? 'center' : 'initial', '&.Mui-selected': { backgroundColor: 'primary.light', color: 'primary.contrastText', @@ -102,18 +136,30 @@ function Sidebar({ mobileOpen, onMobileClose }: SidebarProps) { {item.icon} - + ); })} - + + + {sidebarCollapsed ? : } + + + ); return ( @@ -143,11 +189,13 @@ function Sidebar({ mobileOpen, onMobileClose }: SidebarProps) { variant="permanent" sx={{ display: { xs: 'none', sm: 'block' }, - width: DRAWER_WIDTH, + width: sidebarCollapsed ? DRAWER_WIDTH_COLLAPSED : DRAWER_WIDTH, flexShrink: 0, '& .MuiDrawer-paper': { - width: DRAWER_WIDTH, + width: sidebarCollapsed ? DRAWER_WIDTH_COLLAPSED : DRAWER_WIDTH, boxSizing: 'border-box', + transition: 'width 225ms cubic-bezier(0.4, 0, 0.6, 1)', + overflowX: 'hidden', }, }} open diff --git a/frontend/src/contexts/ChatContext.tsx b/frontend/src/contexts/ChatContext.tsx new file mode 100644 index 0000000..59fab83 --- /dev/null +++ b/frontend/src/contexts/ChatContext.tsx @@ -0,0 +1,57 @@ +import React, { createContext, useContext, useState, useCallback, ReactNode } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { nextcloudApi } from '../services/nextcloud'; +import { useLayout } from './LayoutContext'; +import type { NextcloudConversation } from '../types/nextcloud.types'; + +interface ChatContextType { + rooms: NextcloudConversation[]; + selectedRoomToken: string | null; + selectRoom: (token: string | null) => void; + connected: boolean; + loginName: string | null; +} + +const ChatContext = createContext(undefined); + +interface ChatProviderProps { + children: ReactNode; +} + +export const ChatProvider: React.FC = ({ children }) => { + const [selectedRoomToken, setSelectedRoomToken] = useState(null); + const { chatPanelOpen } = useLayout(); + + const { data } = useQuery({ + queryKey: ['nextcloud', 'rooms'], + queryFn: () => nextcloudApi.getRooms(), + refetchInterval: chatPanelOpen ? 30000 : false, + enabled: chatPanelOpen, + }); + + const rooms = data?.rooms ?? []; + const connected = data?.connected ?? false; + const loginName = data?.loginName ?? null; + + const selectRoom = useCallback((token: string | null) => { + setSelectedRoomToken(token); + }, []); + + const value: ChatContextType = { + rooms, + selectedRoomToken, + selectRoom, + connected, + loginName, + }; + + return {children}; +}; + +export const useChat = (): ChatContextType => { + const context = useContext(ChatContext); + if (context === undefined) { + throw new Error('useChat must be used within a ChatProvider'); + } + return context; +}; diff --git a/frontend/src/contexts/LayoutContext.tsx b/frontend/src/contexts/LayoutContext.tsx new file mode 100644 index 0000000..6b2aad0 --- /dev/null +++ b/frontend/src/contexts/LayoutContext.tsx @@ -0,0 +1,70 @@ +import React, { createContext, useContext, useState, useCallback, ReactNode } from 'react'; + +export const DRAWER_WIDTH = 240; +export const DRAWER_WIDTH_COLLAPSED = 64; + +interface LayoutContextType { + sidebarCollapsed: boolean; + chatPanelOpen: boolean; + toggleSidebar: () => void; + toggleChatPanel: () => void; + setChatPanelOpen: (open: boolean) => void; +} + +const LayoutContext = createContext(undefined); + +function getInitialCollapsed(): boolean { + try { + const stored = localStorage.getItem('sidebar-collapsed'); + return stored === 'true'; + } catch { + return false; + } +} + +interface LayoutProviderProps { + children: ReactNode; +} + +export const LayoutProvider: React.FC = ({ children }) => { + const [sidebarCollapsed, setSidebarCollapsed] = useState(getInitialCollapsed); + const [chatPanelOpen, setChatPanelOpenState] = useState(false); + + const toggleSidebar = useCallback(() => { + setSidebarCollapsed((prev) => { + const next = !prev; + try { + localStorage.setItem('sidebar-collapsed', String(next)); + } catch { + // ignore storage errors + } + return next; + }); + }, []); + + const toggleChatPanel = useCallback(() => { + setChatPanelOpenState((prev) => !prev); + }, []); + + const setChatPanelOpen = useCallback((open: boolean) => { + setChatPanelOpenState(open); + }, []); + + const value: LayoutContextType = { + sidebarCollapsed, + chatPanelOpen, + toggleSidebar, + toggleChatPanel, + setChatPanelOpen, + }; + + return {children}; +}; + +export const useLayout = (): LayoutContextType => { + const context = useContext(LayoutContext); + if (context === undefined) { + throw new Error('useLayout must be used within a LayoutProvider'); + } + return context; +}; diff --git a/frontend/src/dompurify.d.ts b/frontend/src/dompurify.d.ts new file mode 100644 index 0000000..7dd3947 --- /dev/null +++ b/frontend/src/dompurify.d.ts @@ -0,0 +1,7 @@ +declare module 'dompurify' { + interface DOMPurifyI { + sanitize(source: string | Node, config?: Record): string; + } + const DOMPurify: DOMPurifyI; + export default DOMPurify; +} diff --git a/frontend/src/hooks/useCountUp.ts b/frontend/src/hooks/useCountUp.ts new file mode 100644 index 0000000..931c1d7 --- /dev/null +++ b/frontend/src/hooks/useCountUp.ts @@ -0,0 +1,42 @@ +import { useState, useEffect, useRef } from 'react'; + +export function useCountUp(target: number, duration: number = 1000): number { + const [current, setCurrent] = useState(0); + const rafRef = useRef(0); + const currentRef = useRef(0); + + useEffect(() => { + if (target === 0) { + setCurrent(0); + currentRef.current = 0; + return; + } + + const startTime = performance.now(); + const startValue = currentRef.current; + + const animate = (now: number) => { + const elapsed = now - startTime; + const t = Math.min(elapsed / duration, 1); + // ease-out cubic: 1 - (1-t)^3 + const eased = 1 - Math.pow(1 - t, 3); + const value = Math.round(startValue + (target - startValue) * eased); + setCurrent(value); + currentRef.current = value; + + if (t < 1) { + rafRef.current = requestAnimationFrame(animate); + } + }; + + rafRef.current = requestAnimationFrame(animate); + + return () => { + if (rafRef.current) { + cancelAnimationFrame(rafRef.current); + } + }; + }, [target, duration]); + + return current; +} diff --git a/frontend/src/pages/AdminDashboard.tsx b/frontend/src/pages/AdminDashboard.tsx new file mode 100644 index 0000000..d355ad0 --- /dev/null +++ b/frontend/src/pages/AdminDashboard.tsx @@ -0,0 +1,61 @@ +import { useState } from 'react'; +import { Box, Tabs, Tab, Typography } from '@mui/material'; +import { Navigate } from 'react-router-dom'; +import DashboardLayout from '../components/dashboard/DashboardLayout'; +import ServiceManagerTab from '../components/admin/ServiceManagerTab'; +import SystemHealthTab from '../components/admin/SystemHealthTab'; +import UserOverviewTab from '../components/admin/UserOverviewTab'; +import NotificationBroadcastTab from '../components/admin/NotificationBroadcastTab'; +import { useAuth } from '../contexts/AuthContext'; + +interface TabPanelProps { + children: React.ReactNode; + index: number; + value: number; +} + +function TabPanel({ children, value, index }: TabPanelProps) { + if (value !== index) return null; + return {children}; +} + +function AdminDashboard() { + const [tab, setTab] = useState(0); + const { user } = useAuth(); + + const isAdmin = user?.groups?.includes('dashboard_admin') ?? false; + + if (!isAdmin) { + return ; + } + + return ( + + Administration + + + setTab(v)}> + + + + + + + + + + + + + + + + + + + + + ); +} + +export default AdminDashboard; diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index 38c53f4..3848845 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -18,8 +18,10 @@ import BookStackSearchWidget from '../components/dashboard/BookStackSearchWidget import VikunjaMyTasksWidget from '../components/dashboard/VikunjaMyTasksWidget'; import VikunjaQuickAddWidget from '../components/dashboard/VikunjaQuickAddWidget'; import VikunjaOverdueNotifier from '../components/dashboard/VikunjaOverdueNotifier'; +import AdminStatusWidget from '../components/dashboard/AdminStatusWidget'; function Dashboard() { const { user } = useAuth(); + const isAdmin = user?.groups?.includes('dashboard_admin') ?? false; const canViewAtemschutz = user?.groups?.some(g => ['dashboard_admin', 'dashboard_kommando', 'dashboard_atemschutz', 'dashboard_moderator'].includes(g) ) ?? false; @@ -148,6 +150,17 @@ function Dashboard() { {/* Vikunja — Overdue Notifier (invisible, polling component) */} + + {/* Admin Status Widget — only for admins */} + {isAdmin && ( + + + + + + + + )} diff --git a/frontend/src/pages/Wissen.tsx b/frontend/src/pages/Wissen.tsx new file mode 100644 index 0000000..6c4df73 --- /dev/null +++ b/frontend/src/pages/Wissen.tsx @@ -0,0 +1,240 @@ +import { useState, useEffect, useRef, useCallback } from 'react'; +import { + Box, + TextField, + Typography, + Paper, + List, + ListItem, + ListItemButton, + ListItemText, + CircularProgress, + InputAdornment, + Divider, +} from '@mui/material'; +import { Search as SearchIcon } from '@mui/icons-material'; +import { useQuery } from '@tanstack/react-query'; +import DOMPurify from 'dompurify'; +import DashboardLayout from '../components/dashboard/DashboardLayout'; +import { bookstackApi } from '../services/bookstack'; +import type { BookStackPage, BookStackSearchResult } from '../types/bookstack.types'; + +export default function Wissen() { + const [searchTerm, setSearchTerm] = useState(''); + const [debouncedSearch, setDebouncedSearch] = useState(''); + const [selectedPageId, setSelectedPageId] = useState(null); + const debounceRef = useRef | null>(null); + + useEffect(() => { + if (debounceRef.current) clearTimeout(debounceRef.current); + debounceRef.current = setTimeout(() => { + setDebouncedSearch(searchTerm.trim()); + }, 400); + return () => { + if (debounceRef.current) clearTimeout(debounceRef.current); + }; + }, [searchTerm]); + + const recentQuery = useQuery({ + queryKey: ['bookstack', 'recent'], + queryFn: () => bookstackApi.getRecent(), + }); + + const searchQuery = useQuery({ + queryKey: ['bookstack', 'search', debouncedSearch], + queryFn: () => bookstackApi.search(debouncedSearch), + enabled: debouncedSearch.length > 0, + }); + + const pageQuery = useQuery({ + queryKey: ['bookstack', 'page', selectedPageId], + queryFn: () => bookstackApi.getPage(selectedPageId!), + enabled: selectedPageId !== null, + }); + + const handleSelectPage = useCallback((id: number) => { + setSelectedPageId(id); + }, []); + + const isNotConfigured = + recentQuery.data && !recentQuery.data.configured; + + if (isNotConfigured) { + return ( + + + + Wissen + + + BookStack ist nicht konfiguriert. Bitte BOOKSTACK_URL, BOOKSTACK_TOKEN_ID und + BOOKSTACK_TOKEN_SECRET in der .env-Datei setzen. + + + + ); + } + + const isSearching = debouncedSearch.length > 0; + const listItems: (BookStackSearchResult | BookStackPage)[] = isSearching + ? searchQuery.data?.data ?? [] + : recentQuery.data?.data ?? []; + const listLoading = isSearching ? searchQuery.isLoading : recentQuery.isLoading; + + return ( + + + {/* Left panel: search + list */} + + + setSearchTerm(e.target.value)} + InputProps={{ + startAdornment: ( + + + + ), + }} + /> + + + + {listLoading ? ( + + + + ) : listItems.length === 0 ? ( + + {isSearching ? 'Keine Ergebnisse gefunden.' : 'Keine Seiten vorhanden.'} + + ) : ( + + {listItems.map((item) => ( + + handleSelectPage(item.id)} + > + + + + ))} + + )} + + + + {/* Right panel: page content */} + + {!selectedPageId ? ( + + Seite aus der Liste auswaehlen, um den Inhalt anzuzeigen. + + ) : pageQuery.isLoading ? ( + + + + ) : pageQuery.isError ? ( + + Fehler beim Laden der Seite. + + ) : pageQuery.data?.data ? ( + + + {pageQuery.data.data.name} + + {pageQuery.data.data.book && ( + + Buch: {pageQuery.data.data.book.name} + + )} + + ({ + '& h1, & h2, & h3, & h4, & h5, & h6': { + color: theme.palette.text.primary, + mt: 2, + mb: 1, + }, + '& p': { + color: theme.palette.text.primary, + lineHeight: 1.7, + mb: 1, + }, + '& a': { + color: theme.palette.primary.main, + textDecoration: 'none', + '&:hover': { textDecoration: 'underline' }, + }, + '& table': { + borderCollapse: 'collapse', + width: '100%', + mb: 2, + }, + '& th, & td': { + border: `1px solid ${theme.palette.divider}`, + padding: theme.spacing(1), + textAlign: 'left', + }, + '& th': { + backgroundColor: theme.palette.action.hover, + fontWeight: 600, + }, + '& img': { + maxWidth: '100%', + height: 'auto', + borderRadius: 1, + }, + '& code': { + backgroundColor: theme.palette.action.hover, + padding: '2px 6px', + borderRadius: 1, + fontSize: '0.875em', + }, + '& pre': { + backgroundColor: theme.palette.action.hover, + padding: theme.spacing(2), + borderRadius: 1, + overflow: 'auto', + }, + '& ul, & ol': { + pl: 3, + mb: 1, + }, + '& blockquote': { + borderLeft: `4px solid ${theme.palette.primary.main}`, + pl: 2, + ml: 0, + color: theme.palette.text.secondary, + }, + })} + dangerouslySetInnerHTML={{ + __html: DOMPurify.sanitize(pageQuery.data.data.html), + }} + /> + + ) : ( + + Seite nicht gefunden. + + )} + + + + ); +} diff --git a/frontend/src/services/admin.ts b/frontend/src/services/admin.ts new file mode 100644 index 0000000..14a81f6 --- /dev/null +++ b/frontend/src/services/admin.ts @@ -0,0 +1,19 @@ +import { api } from './api'; +import type { MonitoredService, PingResult, StatusSummary, SystemHealth, UserOverview, BroadcastPayload } from '../types/admin.types'; + +interface ApiResponse { + success: boolean; + data: T; +} + +export const adminApi = { + getServices: () => api.get>('/api/admin/services').then(r => r.data.data), + createService: (data: { name: string; url: string }) => api.post>('/api/admin/services', data).then(r => r.data.data), + updateService: (id: string, data: Partial) => api.put>(`/api/admin/services/${id}`, data).then(r => r.data.data), + deleteService: (id: string) => api.delete(`/api/admin/services/${id}`).then(() => undefined), + pingAll: () => api.get>('/api/admin/services/ping').then(r => r.data.data), + getStatusSummary: () => api.get>('/api/admin/services/status-summary').then(r => r.data.data), + getSystemHealth: () => api.get>('/api/admin/system/health').then(r => r.data.data), + getUsers: () => api.get>('/api/admin/users').then(r => r.data.data), + broadcast: (data: BroadcastPayload) => api.post>('/api/admin/notifications/broadcast', data).then(r => r.data.data), +}; diff --git a/frontend/src/services/bookstack.ts b/frontend/src/services/bookstack.ts index 15f963c..73b5403 100644 --- a/frontend/src/services/bookstack.ts +++ b/frontend/src/services/bookstack.ts @@ -1,5 +1,5 @@ import { api } from './api'; -import type { BookStackRecentResponse, BookStackSearchResponse } from '../types/bookstack.types'; +import type { BookStackRecentResponse, BookStackSearchResponse, BookStackPageDetail } from '../types/bookstack.types'; interface ApiResponse { success: boolean; @@ -14,6 +14,12 @@ export const bookstackApi = { .then((r) => ({ configured: r.data.configured, data: r.data.data })); }, + getPage(id: number): Promise<{ configured: boolean; data: BookStackPageDetail | null }> { + return api + .get>(`/api/bookstack/pages/${id}`) + .then((r) => ({ configured: r.data.configured, data: r.data.data })); + }, + search(query: string): Promise { return api .get>('/api/bookstack/search', { diff --git a/frontend/src/services/config.ts b/frontend/src/services/config.ts new file mode 100644 index 0000000..a6ea61d --- /dev/null +++ b/frontend/src/services/config.ts @@ -0,0 +1,15 @@ +import { api } from './api'; +import type { ExternalLinks } from '../types/config.types'; + +interface ApiResponse { + success: boolean; + data: T; +} + +export const configApi = { + getExternalLinks(): Promise { + return api + .get>('/api/config/external-links') + .then((r) => r.data.data); + }, +}; diff --git a/frontend/src/services/nextcloud.ts b/frontend/src/services/nextcloud.ts index f641d14..46f23a0 100644 --- a/frontend/src/services/nextcloud.ts +++ b/frontend/src/services/nextcloud.ts @@ -1,5 +1,5 @@ import { api } from './api'; -import type { NextcloudTalkData, NextcloudConnectData, NextcloudPollData } from '../types/nextcloud.types'; +import type { NextcloudTalkData, NextcloudConnectData, NextcloudPollData, NextcloudMessage, NextcloudRoomListData } from '../types/nextcloud.types'; interface ApiResponse { success: boolean; @@ -30,4 +30,28 @@ export const nextcloudApi = { .delete('/api/nextcloud/talk/connect') .then(() => undefined); }, + + getRooms(): Promise { + return api + .get>('/api/nextcloud/talk/rooms') + .then((r) => r.data.data); + }, + + getMessages(token: string): Promise { + return api + .get>(`/api/nextcloud/talk/rooms/${encodeURIComponent(token)}/messages`) + .then((r) => r.data.data); + }, + + sendMessage(token: string, message: string): Promise { + return api + .post(`/api/nextcloud/talk/rooms/${encodeURIComponent(token)}/messages`, { message }) + .then(() => undefined); + }, + + markAsRead(token: string): Promise { + return api + .post(`/api/nextcloud/talk/rooms/${encodeURIComponent(token)}/read`) + .then(() => undefined); + }, }; diff --git a/frontend/src/types/admin.types.ts b/frontend/src/types/admin.types.ts new file mode 100644 index 0000000..a3d03ea --- /dev/null +++ b/frontend/src/types/admin.types.ts @@ -0,0 +1,52 @@ +export interface MonitoredService { + id: string; + name: string; + url: string; + type: 'internal' | 'custom'; + is_active: boolean; + created_at: string; + updated_at: string; +} + +export interface PingResult { + name: string; + url: string; + status: 'up' | 'down'; + latencyMs: number; + error?: string; +} + +export interface StatusSummary { + up: number; + total: number; +} + +export interface SystemHealth { + nodeVersion: string; + uptime: number; + memoryUsage: { + heapUsed: number; + heapTotal: number; + rss: number; + external: number; + }; + dbStatus: boolean; + dbSize: string; +} + +export interface UserOverview { + id: string; + email: string; + name: string; + role: string; + groups: string[]; + is_active: boolean; + last_login_at: string | null; +} + +export interface BroadcastPayload { + titel: string; + nachricht: string; + schwere?: 'info' | 'warnung' | 'fehler'; + targetGroup?: string; +} diff --git a/frontend/src/types/bookstack.types.ts b/frontend/src/types/bookstack.types.ts index 627b9d8..adefab0 100644 --- a/frontend/src/types/bookstack.types.ts +++ b/frontend/src/types/bookstack.types.ts @@ -34,3 +34,24 @@ export interface BookStackSearchResponse { configured: boolean; data: BookStackSearchResult[]; } + +export interface BookStackPageDetail { + id: number; + name: string; + slug: string; + book_id: number; + book_slug: string; + chapter_id: number; + html: string; + created_at: string; + updated_at: string; + url: string; + book?: { name: string }; + createdBy?: { name: string }; + updatedBy?: { name: string }; +} + +export interface BookStackPageDetailResponse { + configured: boolean; + data: BookStackPageDetail | null; +} diff --git a/frontend/src/types/config.types.ts b/frontend/src/types/config.types.ts new file mode 100644 index 0000000..5a66fd7 --- /dev/null +++ b/frontend/src/types/config.types.ts @@ -0,0 +1,5 @@ +export interface ExternalLinks { + nextcloud?: string; + bookstack?: string; + vikunja?: string; +} diff --git a/frontend/src/types/nextcloud.types.ts b/frontend/src/types/nextcloud.types.ts index 13c9ecb..7e2c6d7 100644 --- a/frontend/src/types/nextcloud.types.ts +++ b/frontend/src/types/nextcloud.types.ts @@ -27,3 +27,21 @@ export interface NextcloudConnectData { export interface NextcloudPollData { completed: boolean; } + +export interface NextcloudMessage { + id: number; + token: string; + actorType: string; + actorId: string; + actorDisplayName: string; + message: string; + timestamp: number; + messageType: string; + systemMessage: string; +} + +export interface NextcloudRoomListData { + connected: boolean; + rooms: NextcloudConversation[]; + loginName?: string; +}