diff --git a/README.md b/README.md index da17044..0ad1c27 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ Zentrale, webbasierte Management-Plattform, über die eigenständige Web-Applikationen als **Module** integriert, verwaltet und Benutzern zugewiesen werden können. -**Status: Phase 8 – Modul-SDK (abgeschlossen)** +**Status: Phase 9 – Administration (abgeschlossen)** ## Architektur-Überblick @@ -112,6 +112,9 @@ Verbindlicher Modul-API-Vertrag (`/health`, `/api/manifest`, `/api/me`) mit `pla ### Phase 8 – Modul-SDK `packages/platform-module-sdk`: `createModuleServer` verdrahtet den kompletten Vertrag plus fachliche Routen; dazu Manifest-Validierung, Laufzeit-Config, strukturiertes Logging (mit Secret-Schwärzung) und ein interner Plattform-API-Client. Module binden das SDK als Einzeldatei ein (Vendor-Pattern). Phase 7 (Kalender) wurde zugunsten der Einbindung bestehender Projekte übersprungen. +### Phase 9 – Administration +Audit-Log-UI (Filter + Paginierung), Systemeinstellungen (Whitelist-Schlüssel, Inline-Bearbeitung), erweiterter Systemstatus mit Gesamtstatus und allen Modul-Healths. Alle Endpunkte nur für Admins (RBAC verifiziert). + ## Annahme „ChatCM" wurde als **shadcn-artige Komponentenbasis** interpretiert: Tailwind CSS plus zentral gepflegte, wiederverwendbare UI-Komponenten (`apps/platform-frontend/src/components/ui`). \ No newline at end of file diff --git a/apps/platform-backend/src/app.module.ts b/apps/platform-backend/src/app.module.ts index 30944e6..30ba18e 100644 --- a/apps/platform-backend/src/app.module.ts +++ b/apps/platform-backend/src/app.module.ts @@ -8,6 +8,7 @@ import { AuthModule } from './auth/auth.module'; import { UsersModule } from './users/users.module'; import { HealthModule } from './health/health.module'; import { ModulesModule } from './modules/modules.module'; +import { SettingsModule } from './settings/settings.module'; import { SessionGuard } from './auth/guards/session.guard'; import { CsrfGuard } from './auth/guards/csrf.guard'; import { RolesGuard } from './common/guards/roles.guard'; @@ -25,6 +26,7 @@ import { RolesGuard } from './common/guards/roles.guard'; UsersModule, HealthModule, ModulesModule, + SettingsModule, ], providers: [ MigrationRunner, diff --git a/apps/platform-backend/src/audit/audit-query.repository.ts b/apps/platform-backend/src/audit/audit-query.repository.ts new file mode 100644 index 0000000..37288c1 --- /dev/null +++ b/apps/platform-backend/src/audit/audit-query.repository.ts @@ -0,0 +1,99 @@ +import { Injectable } from '@nestjs/common'; +import { DatabaseService } from '../database/database.service'; + +/** Audit-Log-Eintrag für API-Antworten. */ +export interface AuditLogEntry { + readonly id: number; + readonly username: string; + readonly action: string; + readonly details: Record; + readonly ipAddress: string | null; + readonly createdAt: string; +} + +interface AuditRow { + id: number; + username: string; + action: string; + details: Record; + ip_address: string | null; + created_at: Date; +} + +/** Filter-Parameter für die Audit-Liste. */ +export interface AuditListFilter { + readonly action?: string; + readonly username?: string; + readonly limit: number; + readonly offset: number; +} + +/** + * Audit-Log-Abfragen (Infrastructure): Durchsuchen und Filtern des + * zentralen Audit-Logs (nur für Administratoren). + */ +@Injectable() +export class AuditQueryRepository { + constructor(private readonly database: DatabaseService) {} + + /** Listet Audit-Einträge mit optionalen Filtern (neueste zuerst). */ + async list(filter: AuditListFilter): Promise { + const conditions: string[] = []; + const params: unknown[] = []; + + if (filter.action) { + params.push(filter.action); + conditions.push(`action = $${params.length}`); + } + if (filter.username) { + params.push(`%${filter.username}%`); + conditions.push(`username ILIKE $${params.length}`); + } + + const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''; + params.push(filter.limit); + const limitClause = `LIMIT $${params.length}`; + params.push(filter.offset); + const offsetClause = `OFFSET $${params.length}`; + + const result = await this.database.query( + `SELECT id, username, action, details, ip_address, created_at + FROM audit_logs + ${whereClause} + ORDER BY created_at DESC + ${limitClause} ${offsetClause}`, + params, + ); + + return result.rows.map((row) => ({ + id: row.id, + username: row.username, + action: row.action, + details: row.details ?? {}, + ipAddress: row.ip_address, + createdAt: row.created_at.toISOString(), + })); + } + + /** Gesamtzahl der Einträge (für Paginierung). */ + async count(filter: Pick): Promise { + const conditions: string[] = []; + const params: unknown[] = []; + + if (filter.action) { + params.push(filter.action); + conditions.push(`action = $${params.length}`); + } + if (filter.username) { + params.push(`%${filter.username}%`); + conditions.push(`username ILIKE $${params.length}`); + } + + const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''; + const result = await this.database.query<{ count: number }>( + `SELECT count(*)::int AS count FROM audit_logs ${whereClause}`, + params, + ); + return result.rows[0]?.count ?? 0; + } +} \ No newline at end of file diff --git a/apps/platform-backend/src/audit/audit.controller.ts b/apps/platform-backend/src/audit/audit.controller.ts new file mode 100644 index 0000000..a01d67b --- /dev/null +++ b/apps/platform-backend/src/audit/audit.controller.ts @@ -0,0 +1,38 @@ +import { Controller, Get, Query } from '@nestjs/common'; +import { ApiTags } from '@nestjs/swagger'; +import { Roles } from '../common/decorators/roles.decorator'; +import { ZodValidationPipe } from '../common/zod-validation.pipe'; +import { z } from 'zod'; +import type { AuditLogEntry } from './audit-query.repository'; +import { AuditQueryRepository } from './audit-query.repository'; + +/** Abfrage-Parameter für die Audit-Liste (Zod-Validierung). */ +export const auditListQuerySchema = z.object({ + action: z.string().trim().max(100).optional(), + username: z.string().trim().max(100).optional(), + limit: z.coerce.number().int().min(1).max(100).default(50), + offset: z.coerce.number().int().min(0).default(0), +}); +type AuditListQuery = z.infer; + +/** + * Audit-Log (nur Administratoren): Durchsuchen und Filtern der + * sicherheitsrelevanten Ereignisse der Plattform. + */ +@ApiTags('Audit') +@Roles('ADMIN') +@Controller({ path: 'api/v1/audit' }) +export class AuditController { + constructor(private readonly auditQueryRepository: AuditQueryRepository) {} + + @Get() + async list( + @Query(new ZodValidationPipe(auditListQuerySchema)) query: AuditListQuery, + ): Promise<{ entries: AuditLogEntry[]; total: number }> { + const [entries, total] = await Promise.all([ + this.auditQueryRepository.list(query), + this.auditQueryRepository.count(query), + ]); + return { entries, total }; + } +} \ No newline at end of file diff --git a/apps/platform-backend/src/audit/audit.module.ts b/apps/platform-backend/src/audit/audit.module.ts index b1fe002..9212ed3 100644 --- a/apps/platform-backend/src/audit/audit.module.ts +++ b/apps/platform-backend/src/audit/audit.module.ts @@ -1,12 +1,15 @@ import { Global, Module } from '@nestjs/common'; import { DatabaseModule } from '../database/database.module'; +import { AuditController } from './audit.controller'; +import { AuditQueryRepository } from './audit-query.repository'; import { AuditService } from './audit.service'; -/** Global verfügbares Audit-Logging. */ +/** Global verfügbares Audit-Logging mit Admin-Abfragen. */ @Global() @Module({ imports: [DatabaseModule], - providers: [AuditService], - exports: [AuditService], + controllers: [AuditController], + providers: [AuditService, AuditQueryRepository], + exports: [AuditService, AuditQueryRepository], }) export class AuditModule {} \ No newline at end of file diff --git a/apps/platform-backend/src/audit/audit.service.ts b/apps/platform-backend/src/audit/audit.service.ts index d0eccab..48a84ac 100644 --- a/apps/platform-backend/src/audit/audit.service.ts +++ b/apps/platform-backend/src/audit/audit.service.ts @@ -23,6 +23,7 @@ export const AUDIT_ACTIONS = { MODULE_DISABLED: 'MODULE_DISABLED', PERMISSION_GRANTED: 'PERMISSION_GRANTED', PERMISSION_REVOKED: 'PERMISSION_REVOKED', + SETTINGS_CHANGED: 'SETTINGS_CHANGED', } as const; export type AuditAction = (typeof AUDIT_ACTIONS)[keyof typeof AUDIT_ACTIONS]; diff --git a/apps/platform-backend/src/database/migrations/index.ts b/apps/platform-backend/src/database/migrations/index.ts index 82ad414..c7bfda8 100644 --- a/apps/platform-backend/src/database/migrations/index.ts +++ b/apps/platform-backend/src/database/migrations/index.ts @@ -1,10 +1,12 @@ import { migration001CoreSchema } from './001-core-schema'; import { migration002Modules } from '../../modules/migrations/002-modules'; import { migration003ModulePermissions } from '../../modules/migrations/003-module-permissions'; +import { migration004SystemSettings } from '../../settings/migrations/004-system-settings'; /** Registrierte Migrationen in aufsteigender Reihenfolge. */ export const MIGRATIONS = [ migration001CoreSchema, migration002Modules, migration003ModulePermissions, + migration004SystemSettings, ]; \ No newline at end of file diff --git a/apps/platform-backend/src/health/health.module.ts b/apps/platform-backend/src/health/health.module.ts index 192d6d2..4308e70 100644 --- a/apps/platform-backend/src/health/health.module.ts +++ b/apps/platform-backend/src/health/health.module.ts @@ -1,10 +1,13 @@ import { Module } from '@nestjs/common'; import { DatabaseModule } from '../database/database.module'; +import { AuditModule } from '../audit/audit.module'; +import { ModulesModule } from '../modules/modules.module'; import { HealthController } from './health.controller'; +import { SystemStatusController } from './system-status.controller'; /** Health-Endpoints (Monitoring). */ @Module({ - imports: [DatabaseModule], - controllers: [HealthController], + imports: [DatabaseModule, AuditModule, ModulesModule], + controllers: [HealthController, SystemStatusController], }) export class HealthModule {} \ No newline at end of file diff --git a/apps/platform-backend/src/health/system-status.controller.ts b/apps/platform-backend/src/health/system-status.controller.ts new file mode 100644 index 0000000..8df39e2 --- /dev/null +++ b/apps/platform-backend/src/health/system-status.controller.ts @@ -0,0 +1,96 @@ +import { Controller, Get } from '@nestjs/common'; +import { ApiTags } from '@nestjs/swagger'; +import { Roles } from '../common/decorators/roles.decorator'; +import { DatabaseService } from '../database/database.service'; +import { ModuleRepository } from '../modules/module.repository'; +import { ModuleHealthChecker } from '../modules/module-health-checker'; +import type { ModuleRecord } from '../modules/manifest.types'; + +/** Komponenten-Status für das Admin-Dashboard. */ +export interface ComponentStatus { + readonly name: string; + readonly status: 'HEALTHY' | 'DEGRADED' | 'UNHEALTHY' | 'STOPPED'; + readonly detail: string; +} + +/** Erweiterter Systemstatus (Phase 9). */ +export interface SystemStatusResponse { + readonly overall: 'HEALTHY' | 'DEGRADED' | 'UNHEALTHY'; + readonly components: readonly ComponentStatus[]; +} + +/** + * Systemstatus (nur Administratoren): Zustand aller Plattform-Komponenten + * inklusive aller installierten Module. + */ +@ApiTags('System') +@Roles('ADMIN') +@Controller({ path: 'api/v1/system/status' }) +export class SystemStatusController { + constructor( + private readonly database: DatabaseService, + private readonly moduleRepository: ModuleRepository, + private readonly healthChecker: ModuleHealthChecker, + ) {} + + @Get() + async getStatus(): Promise { + const components: ComponentStatus[] = []; + + // Management-Backend (dieser Prozess läuft, wenn die Antwort entsteht) + components.push({ + name: 'Management', + status: 'HEALTHY', + detail: 'Backend erreichbar', + }); + + // PostgreSQL + try { + const latencyMs = Math.round(await this.database.ping()); + components.push({ + name: 'Datenbank', + status: latencyMs < 500 ? 'HEALTHY' : 'DEGRADED', + detail: `${latencyMs} ms`, + }); + } catch { + components.push({ + name: 'Datenbank', + status: 'UNHEALTHY', + detail: 'nicht erreichbar', + }); + } + + // Module: Healthchecks parallel, Fehler isoliert pro Modul + const modules = await this.moduleRepository.list(); + const moduleHealths = await Promise.all( + modules.map(async (module): Promise<{ module: ModuleRecord; healthy: boolean; detail: string }> => { + if (module.status !== 'RUNNING') { + return { module, healthy: false, detail: module.status }; + } + const health = await this.healthChecker.check(module); + return { module, healthy: health.healthy, detail: health.detail }; + }), + ); + + for (const { module, healthy, detail } of moduleHealths) { + components.push({ + name: module.name, + status: !module.enabled + ? 'STOPPED' + : healthy + ? 'HEALTHY' + : module.status === 'RUNNING' + ? 'UNHEALTHY' + : 'STOPPED', + detail: `${module.status} · ${detail}`, + }); + } + + // Gesamtstatus: schlechtester Komponenten-Status + const hasUnhealthy = components.some((component) => component.status === 'UNHEALTHY'); + const hasDegraded = components.some((component) => component.status === 'DEGRADED'); + const overall = hasUnhealthy ? 'UNHEALTHY' : hasDegraded ? 'DEGRADED' : 'HEALTHY'; + + return { overall, components }; + } +} \ No newline at end of file diff --git a/apps/platform-backend/src/modules/modules.module.ts b/apps/platform-backend/src/modules/modules.module.ts index 29635f9..a32476a 100644 --- a/apps/platform-backend/src/modules/modules.module.ts +++ b/apps/platform-backend/src/modules/modules.module.ts @@ -40,7 +40,7 @@ import { ModulesService } from './modules.service'; ModulePermissionRepository, ModulePermissionsService, ], - exports: [ModuleRepository, ModulesService, ModulePermissionsService], + exports: [ModuleRepository, ModulesService, ModulePermissionsService, ModuleHealthChecker], }) export class ModulesModule implements NestModule { /** Gateway-Middleware für alle /api/v1/gateway/* Pfade. */ diff --git a/apps/platform-backend/src/settings/migrations/004-system-settings.ts b/apps/platform-backend/src/settings/migrations/004-system-settings.ts new file mode 100644 index 0000000..47764f4 --- /dev/null +++ b/apps/platform-backend/src/settings/migrations/004-system-settings.ts @@ -0,0 +1,17 @@ +import type { Migration } from '../../database/migration.types'; + +/** Phase 9: Systemeinstellungen der Plattform. */ +export const migration004SystemSettings: Migration = { + id: '004-system-settings', + description: 'Systemeinstellungen (system_settings) anlegen', + up: async (client) => { + await client.query(` + CREATE TABLE system_settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_by TEXT + ) + `); + }, +}; \ No newline at end of file diff --git a/apps/platform-backend/src/settings/settings.controller.ts b/apps/platform-backend/src/settings/settings.controller.ts new file mode 100644 index 0000000..776e535 --- /dev/null +++ b/apps/platform-backend/src/settings/settings.controller.ts @@ -0,0 +1,44 @@ +import { Body, Controller, Get, Param, Patch, Req } from '@nestjs/common'; +import type { Request } from 'express'; +import { ApiTags } from '@nestjs/swagger'; +import type { AuthenticatedRequest } from '../auth/authenticated-request'; +import { CurrentUser } from '../common/decorators/current-user.decorator'; +import { Roles } from '../common/decorators/roles.decorator'; +import { ZodValidationPipe } from '../common/zod-validation.pipe'; +import { z } from 'zod'; +import type { AuthUser } from '../users/user.types'; +import type { SettingResponse } from './settings.service'; +import { SettingsService } from './settings.service'; + +/** Einstellung aktualisieren (Zod-Validierung). */ +export const updateSettingSchema = z.object({ + value: z.string().min(1, 'Wert ist erforderlich').max(500), +}); +type UpdateSettingDto = z.infer; + +/** + * Systemeinstellungen (nur Administratoren). + */ +@ApiTags('Settings') +@Roles('ADMIN') +@Controller({ path: 'api/v1/settings' }) +export class SettingsController { + constructor(private readonly settingsService: SettingsService) {} + + @Get() + async list(): Promise<{ settings: SettingResponse[] }> { + const settings = await this.settingsService.list(); + return { settings }; + } + + @Patch(':key') + async update( + @Param('key') key: string, + @Body(new ZodValidationPipe(updateSettingSchema)) body: UpdateSettingDto, + @CurrentUser() actor: AuthUser, + @Req() request: AuthenticatedRequest & Request, + ): Promise<{ setting: SettingResponse }> { + const setting = await this.settingsService.set(key, body.value, actor, request.ip ?? null); + return { setting }; + } +} \ No newline at end of file diff --git a/apps/platform-backend/src/settings/settings.module.ts b/apps/platform-backend/src/settings/settings.module.ts new file mode 100644 index 0000000..81c5383 --- /dev/null +++ b/apps/platform-backend/src/settings/settings.module.ts @@ -0,0 +1,15 @@ +import { Module } from '@nestjs/common'; +import { DatabaseModule } from '../database/database.module'; +import { AuditModule } from '../audit/audit.module'; +import { SettingsController } from './settings.controller'; +import { SettingsRepository } from './settings.repository'; +import { SettingsService } from './settings.service'; + +/** Systemeinstellungen (Phase 9). */ +@Module({ + imports: [DatabaseModule, AuditModule], + controllers: [SettingsController], + providers: [SettingsRepository, SettingsService], + exports: [SettingsService], +}) +export class SettingsModule {} \ No newline at end of file diff --git a/apps/platform-backend/src/settings/settings.repository.ts b/apps/platform-backend/src/settings/settings.repository.ts new file mode 100644 index 0000000..2d874be --- /dev/null +++ b/apps/platform-backend/src/settings/settings.repository.ts @@ -0,0 +1,78 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { DatabaseService } from '../database/database.service'; + +/** Systemeinstellung-Datensatz. */ +export interface SystemSettingRecord { + readonly key: string; + readonly value: string; + readonly updatedAt: Date; + readonly updatedBy: string | null; +} + +interface SettingRow { + key: string; + value: string; + updated_at: Date; + updated_by: string | null; +} + +/** Erlaubte Einstellungs-Schlüssel (Whitelist, verhindert Magic Strings). */ +export const SETTING_KEYS = { + PLATFORM_NAME: 'platform.name', + PLATFORM_DESCRIPTION: 'platform.description', + MAINTENANCE_MODE: 'platform.maintenance_mode', +} as const; +export type SettingKey = (typeof SETTING_KEYS)[keyof typeof SETTING_KEYS]; + +/** + * Systemeinstellungen (Infrastructure): Schlüssel-Werte-Store für + * Plattform-Einstellungen. Nur whitelisted Schlüssel sind schreibbar. + */ +@Injectable() +export class SettingsRepository { + constructor(private readonly database: DatabaseService) {} + + async list(): Promise { + const result = await this.database.query( + 'SELECT key, value, updated_at, updated_by FROM system_settings ORDER BY key ASC', + ); + return result.rows.map((row) => ({ + key: row.key, + value: row.value, + updatedAt: row.updated_at, + updatedBy: row.updated_by, + })); + } + + async get(key: string): Promise { + const result = await this.database.query( + 'SELECT key, value, updated_at, updated_by FROM system_settings WHERE key = $1', + [key], + ); + const row = result.rows[0]; + return row ? { key: row.key, value: row.value, updatedAt: row.updated_at, updatedBy: row.updated_by } : null; + } + + /** Setzt oder aktualisiert eine Einstellung (Upsert). */ + async set(key: string, value: string, updatedBy: string): Promise { + const result = await this.database.query( + `INSERT INTO system_settings (key, value, updated_by) + VALUES ($1, $2, $3) + ON CONFLICT (key) + DO UPDATE SET value = $2, updated_at = now(), updated_by = $3 + RETURNING key, value, updated_at, updated_by`, + [key, value, updatedBy], + ); + const row = result.rows[0]; + return { key: row.key, value: row.value, updatedAt: row.updated_at, updatedBy: row.updated_by }; + } + + /** Liest eine Einstellung oder wirft NotFoundException. */ + async getRequired(key: string): Promise { + const setting = await this.get(key); + if (!setting) { + throw new NotFoundException(`Einstellung "${key}" nicht gefunden`); + } + return setting; + } +} \ No newline at end of file diff --git a/apps/platform-backend/src/settings/settings.service.ts b/apps/platform-backend/src/settings/settings.service.ts new file mode 100644 index 0000000..a1e7eaf --- /dev/null +++ b/apps/platform-backend/src/settings/settings.service.ts @@ -0,0 +1,60 @@ +import { BadRequestException, Injectable } from '@nestjs/common'; +import { AUDIT_ACTIONS, AuditService } from '../audit/audit.service'; +import type { ActingUser } from '../users/users.service'; +import { SETTING_KEYS, SettingsRepository, type SystemSettingRecord } from './settings.repository'; + +/** Öffentliche Einstellungs-Daten in API-Antworten. */ +export interface SettingResponse { + readonly key: string; + readonly value: string; + readonly updatedAt: string; + readonly updatedBy: string | null; +} + +function toSettingResponse(setting: SystemSettingRecord): SettingResponse { + return { + key: setting.key, + value: setting.value, + updatedAt: setting.updatedAt.toISOString(), + updatedBy: setting.updatedBy, + }; +} + +/** + * Systemeinstellungen (Application-Layer): Lesen und Schreiben von + * Plattform-Einstellungen mit Whitelist-Prüfung und Audit-Logging. + */ +@Injectable() +export class SettingsService { + constructor( + private readonly settingsRepository: SettingsRepository, + private readonly auditService: AuditService, + ) {} + + async list(): Promise { + const settings = await this.settingsRepository.list(); + return settings.map(toSettingResponse); + } + + /** Aktualisiert eine Einstellung (nur whitelisted Schlüssel). */ + async set( + key: string, + value: string, + actor: ActingUser, + ipAddress: string | null, + ): Promise { + if (!Object.values(SETTING_KEYS).includes(key as (typeof SETTING_KEYS)[keyof typeof SETTING_KEYS])) { + throw new BadRequestException(`Unbekannter Einstellungs-Schlüssel: ${key}`); + } + + const setting = await this.settingsRepository.set(key, value, actor.username); + await this.auditService.record({ + userId: actor.id, + username: actor.username, + action: AUDIT_ACTIONS.SETTINGS_CHANGED, + details: { key }, + ipAddress, + }); + return toSettingResponse(setting); + } +} \ No newline at end of file diff --git a/apps/platform-frontend/src/components/layout/app-layout.tsx b/apps/platform-frontend/src/components/layout/app-layout.tsx index 13af622..131d30c 100644 --- a/apps/platform-frontend/src/components/layout/app-layout.tsx +++ b/apps/platform-frontend/src/components/layout/app-layout.tsx @@ -17,6 +17,8 @@ const NAV_ITEMS: NavItem[] = [ { to: '/admin/users', label: 'Benutzer', icon: '👥', adminOnly: true }, { to: '/admin/modules', label: 'Module', icon: '🧩', adminOnly: true }, { to: '/admin/system', label: 'Systemstatus', icon: '⚙', adminOnly: true }, + { to: '/admin/audit', label: 'Audit-Log', icon: '📋', adminOnly: true }, + { to: '/admin/settings', label: 'Einstellungen', icon: '🔧', adminOnly: true }, ]; /** Responsive App-Shell: Sidebar (Desktop) / Overlay-Menü (Mobil). */ diff --git a/apps/platform-frontend/src/features/admin/audit-page.tsx b/apps/platform-frontend/src/features/admin/audit-page.tsx new file mode 100644 index 0000000..e3ba9d1 --- /dev/null +++ b/apps/platform-frontend/src/features/admin/audit-page.tsx @@ -0,0 +1,191 @@ +import { type FormEvent, type ReactNode, useState } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { apiRequest } from '../../lib/api-client'; +import { z } from 'zod'; +import { Card, CardBody, CardHeader } from '../../components/ui/card'; +import { Input } from '../../components/ui/input'; +import { Button } from '../../components/ui/button'; +import { ErrorState, Spinner } from '../../components/ui/states'; + +/** Audit-Eintrag (API-Vertrag /api/v1/audit). */ +const auditEntrySchema = z.object({ + id: z.number(), + username: z.string(), + action: z.string(), + details: z.record(z.unknown()), + ipAddress: z.string().nullable(), + createdAt: z.string(), +}); + +const auditResponseSchema = z.object({ + entries: z.array(auditEntrySchema), + total: z.number(), +}); + +/** Abfrage-Parameter (Client-Validierung). */ +const auditFilterSchema = z.object({ + action: z.string().trim().max(100).optional(), + username: z.string().trim().max(100).optional(), +}); +type AuditFilter = z.infer; + +/** Datumsformat für Audit-Einträge. */ +function formatTimestamp(isoDate: string): string { + return new Date(isoDate).toLocaleString('de-DE', { + day: '2-digit', + month: '2-digit', + year: 'numeric', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + }); +} + +/** Audit-Log-Seite (nur Admin): Durchsuchen und Filtern. */ +export function AuditPage(): ReactNode { + const [actionFilter, setActionFilter] = useState(''); + const [usernameFilter, setUsernameFilter] = useState(''); + const [appliedFilter, setAppliedFilter] = useState({}); + const [page, setPage] = useState(0); + const pageSize = 50; + + const auditQuery = useQuery({ + queryKey: ['audit', appliedFilter, page], + queryFn: async () => { + const params = new URLSearchParams(); + if (appliedFilter.action) params.set('action', appliedFilter.action); + if (appliedFilter.username) params.set('username', appliedFilter.username); + params.set('limit', String(pageSize)); + params.set('offset', String(page * pageSize)); + return auditResponseSchema.parse(await apiRequest(`/api/v1/audit?${params.toString()}`)); + }, + }); + + function handleFilterSubmit(event: FormEvent): void { + event.preventDefault(); + const parsed = auditFilterSchema.safeParse({ + action: actionFilter || undefined, + username: usernameFilter || undefined, + }); + if (parsed.success) { + setPage(0); + setAppliedFilter(parsed.data); + } + } + + const totalPages = auditQuery.data ? Math.ceil(auditQuery.data.total / pageSize) : 0; + + return ( +
+
+

Audit-Log

+

+ Sicherheitsrelevante Ereignisse der Plattform (neueste zuerst). +

+
+ + {/* Filter */} + + +
+
+ setActionFilter(event.target.value)} + placeholder="z. B. LOGIN_SUCCESS" + /> +
+
+ setUsernameFilter(event.target.value)} + placeholder="z. B. admin" + /> +
+ +
+
+
+ + {/* Einträge */} + + + + {auditQuery.isLoading && } + {auditQuery.isError && } + {auditQuery.data && auditQuery.data.entries.length === 0 && ( +

+ Keine Einträge für die gewählten Filter. +

+ )} + {auditQuery.data && auditQuery.data.entries.length > 0 && ( +
+ + + + + + + + + + + {auditQuery.data.entries.map((entry) => ( + + + + + + + ))} + +
ZeitBenutzerAktionDetails
+ {formatTimestamp(entry.createdAt)} + {entry.username} + + {entry.action} + + + {Object.keys(entry.details).length > 0 + ? JSON.stringify(entry.details) + : '–'} +
+
+ )} + + {/* Paginierung */} + {totalPages > 1 && ( +
+ + + Seite {page + 1} von {totalPages} + + +
+ )} +
+
+
+ ); +} \ No newline at end of file diff --git a/apps/platform-frontend/src/features/admin/settings-page.tsx b/apps/platform-frontend/src/features/admin/settings-page.tsx new file mode 100644 index 0000000..763aa11 --- /dev/null +++ b/apps/platform-frontend/src/features/admin/settings-page.tsx @@ -0,0 +1,127 @@ +import { type FormEvent, type ReactNode, useState } from 'react'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { apiRequest, ApiError } from '../../lib/api-client'; +import { z } from 'zod'; +import { Card, CardBody, CardHeader } from '../../components/ui/card'; +import { Input } from '../../components/ui/input'; +import { Button } from '../../components/ui/button'; +import { useToast } from '../../components/ui/toast'; +import { ErrorState, Spinner } from '../../components/ui/states'; + +/** Einstellung (API-Vertrag /api/v1/settings). */ +const settingSchema = z.object({ + key: z.string(), + value: z.string(), + updatedAt: z.string(), + updatedBy: z.string().nullable(), +}); + +const settingsResponseSchema = z.object({ + settings: z.array(settingSchema), +}); + +/** Anzeige-Namen für Einstellungs-Schlüssel. */ +const SETTING_LABELS: Record = { + 'platform.name': 'Plattform-Name', + 'platform.description': 'Beschreibung', + 'platform.maintenance_mode': 'Wartungsmodus (true/false)', +}; + +/** Systemeinstellungen-Seite (nur Admin). */ +export function SettingsPage(): ReactNode { + const { showToast } = useToast(); + const queryClient = useQueryClient(); + const [editValues, setEditValues] = useState>({}); + + const settingsQuery = useQuery({ + queryKey: ['settings'], + queryFn: async () => settingsResponseSchema.parse(await apiRequest('/api/v1/settings')), + }); + + const updateMutation = useMutation({ + mutationFn: async (input: { key: string; value: string }) => + apiRequest<{ setting: unknown }>(`/api/v1/settings/${input.key}`, { + method: 'PATCH', + body: { value: input.value }, + }), + onSuccess: (_result, variables) => { + showToast('success', `Einstellung "${variables.key}" gespeichert`); + void queryClient.invalidateQueries({ queryKey: ['settings'] }); + }, + onError: (error) => { + showToast('error', error instanceof ApiError ? error.message : 'Speichern fehlgeschlagen'); + }, + }); + + function handleSubmit(event: FormEvent, key: string): void { + event.preventDefault(); + const value = editValues[key]; + if (value !== undefined && value.trim().length > 0) { + updateMutation.mutate({ key, value: value.trim() }); + } + } + + return ( +
+
+

Einstellungen

+

+ Zentrale Konfiguration der Plattform. +

+
+ + + + + {settingsQuery.isLoading && } + {settingsQuery.isError && ( + + )} + {settingsQuery.data && settingsQuery.data.settings.length === 0 && ( +

+ Noch keine Einstellungen vorhanden. Änderungen legen sie automatisch an. +

+ )} + {settingsQuery.data && settingsQuery.data.settings.length > 0 && ( +
+ {settingsQuery.data.settings.map((setting) => ( +
handleSubmit(event, setting.key)} + className="flex items-end gap-3" + noValidate + > +
+ + setEditValues((current) => ({ + ...current, + [setting.key]: event.target.value, + })) + } + /> +

+ Zuletzt geändert von {setting.updatedBy ?? '–'} am{' '} + {new Date(setting.updatedAt).toLocaleString('de-DE')} +

+
+ +
+ ))} +
+ )} +
+
+
+ ); +} \ No newline at end of file diff --git a/apps/platform-frontend/src/features/admin/system-status-page.tsx b/apps/platform-frontend/src/features/admin/system-status-page.tsx index 1540b28..ebf422a 100644 --- a/apps/platform-frontend/src/features/admin/system-status-page.tsx +++ b/apps/platform-frontend/src/features/admin/system-status-page.tsx @@ -1,17 +1,46 @@ import { type ReactNode } from 'react'; import { useQuery } from '@tanstack/react-query'; import { apiRequest } from '../../lib/api-client'; -import { healthSchema, type Health } from '../../lib/schemas'; +import { z } from 'zod'; import { Card, CardBody, CardHeader } from '../../components/ui/card'; import { Badge } from '../../components/ui/badge'; import { ErrorState, Spinner } from '../../components/ui/states'; -/** Admin-Seite: detaillierter Systemstatus der Plattform. */ +/** Erweiterter Systemstatus (API-Vertrag /api/v1/system/status). */ +const systemStatusSchema = z.object({ + overall: z.enum(['HEALTHY', 'DEGRADED', 'UNHEALTHY']), + components: z.array( + z.object({ + name: z.string(), + status: z.enum(['HEALTHY', 'DEGRADED', 'UNHEALTHY', 'STOPPED']), + detail: z.string(), + }), + ), +}); +type SystemStatus = z.infer; + +/** Badge-Variante je Komponenten-Status. */ +function statusVariant( + status: SystemStatus['components'][number]['status'], +): 'success' | 'warning' | 'danger' | 'neutral' { + switch (status) { + case 'HEALTHY': + return 'success'; + case 'DEGRADED': + return 'warning'; + case 'UNHEALTHY': + return 'danger'; + default: + return 'neutral'; + } +} + +/** Admin-Seite: detaillierter Systemstatus aller Komponenten und Module. */ export function SystemStatusPage(): ReactNode { - const healthQuery = useQuery({ - queryKey: ['health'], - queryFn: async (): Promise => - healthSchema.parse(await apiRequest('/api/v1/health')), + const statusQuery = useQuery({ + queryKey: ['system-status'], + queryFn: async (): Promise => + systemStatusSchema.parse(await apiRequest('/api/v1/system/status')), refetchInterval: 30_000, }); @@ -20,69 +49,59 @@ export function SystemStatusPage(): ReactNode {

Systemstatus

- Zustand aller Plattform-Komponenten (aktualisiert alle 30 Sekunden). + Zustand aller Plattform-Komponenten und Module (aktualisiert alle 30 Sekunden).

+ + + + {statusQuery.isLoading && } + {statusQuery.isError && ( + + )} + {statusQuery.data && ( +
+ + {statusQuery.data.overall} + + + {statusQuery.data.components.length} Komponenten überwacht + +
+ )} +
+
+ - {healthQuery.isLoading && } - {healthQuery.isError && ( - - )} - {healthQuery.data && ( + {statusQuery.data && (
- - -
- Plattform-Version - {healthQuery.data.version} -
-
- Uptime - - {Math.floor(healthQuery.data.uptimeSeconds / 60)} Minuten - -
+ {statusQuery.data.components.map((component) => ( +
+
+ {component.name} + {component.detail} +
+ {component.status} +
+ ))}
)}
); -} - -/** Zeile im Systemstatus. */ -function StatusRow({ - label, - status, - variant, -}: { - label: string; - status: string; - variant: 'success' | 'danger'; -}): ReactNode { - return ( -
- {label} - {status} -
- ); } \ No newline at end of file diff --git a/apps/platform-frontend/src/main.tsx b/apps/platform-frontend/src/main.tsx index 4c5defe..2dfe718 100644 --- a/apps/platform-frontend/src/main.tsx +++ b/apps/platform-frontend/src/main.tsx @@ -11,6 +11,8 @@ import { DashboardPage } from './features/dashboard/dashboard-page'; import { SystemStatusPage } from './features/admin/system-status-page'; import { UsersPage } from './features/admin/users-page'; import { ModulesPage } from './features/admin/modules-page'; +import { AuditPage } from './features/admin/audit-page'; +import { SettingsPage } from './features/admin/settings-page'; import { ProfilePage } from './features/profile/profile-page'; import { ForbiddenPage, NotFoundPage } from './pages/error-pages'; import './index.css'; @@ -47,6 +49,14 @@ function AppRoutes(): ReactNode { path="/admin/system" element={} /> + } + /> + } + /> } /> diff --git a/docs/PHASES.md b/docs/PHASES.md index aa3314a..9af3784 100644 --- a/docs/PHASES.md +++ b/docs/PHASES.md @@ -12,7 +12,7 @@ Der Arbeitsplan sieht zehn inkrementelle Phasen vor. Nach jeder Phase muss das S | 6 | Modul-API (`/health`, `/api/manifest`, `/api/me`) | ✅ Abgeschlossen | | 7 | Referenzmodul Kalender | ⏳ Übersprungen (bestehende Projekte werden stattdessen eingebunden) | | 8 | Modul-SDK (`platform-module-sdk`) | ✅ Abgeschlossen | -| 9 | Administration (Übersichten, Audit-UI, Einstellungen) | ⏳ Geplant | +| 9 | Administration (Übersichten, Audit-UI, Einstellungen) | ✅ Abgeschlossen | | 10 | Security Hardening & Produktivbetrieb | ⏳ Geplant | ## Phase 1 – Grundgerüst (abgeschlossen) @@ -124,9 +124,19 @@ Definition of Done: `platform-module-sdk` mit Authentication, Current User, Perm - [x] Backend-Tests: 116 bestanden (inkl. 26 SDK-Tests: Vertrag, Manifest, Config, Logger-Schwärzung, Server-Verhalten) - [x] E2E verifiziert: SDK-Vertrag über Gateway (health/manifest/me), fachliche Route, 404, 401 ohne Identität -## Nächste Schritte (Phase 9 – Administration) +## Phase 9 – Administration (abgeschlossen) -- Audit-Log-UI (durchsuchen, filtern) -- Systemeinstellungen (Backend + UI) -- Systemstatus-Dashboard erweitern (Modul-Healths) -- Danach: Einbindung bestehender Projekte als Module (ersetzt Phase 7) \ No newline at end of file +Definition of Done: Modulübersicht, Benutzerverwaltung, Rechteverwaltung, Systemstatus, Audit Log, Einstellungen. + +- [x] `system_settings`-Tabelle (Migration 004) mit Whitelist-Schlüsseln +- [x] Settings-API: `GET /api/v1/settings`, `PATCH /api/v1/settings/:key` (nur ADMIN, auditiert SETTINGS_CHANGED) +- [x] Audit-Abfragen: `GET /api/v1/audit` mit Filtern (action, username) und Paginierung (nur ADMIN) +- [x] Erweiterter Systemstatus: `GET /api/v1/system/status` mit Gesamtstatus (HEALTHY/DEGRADED/UNHEALTHY) und allen Modul-Healths +- [x] Frontend: Audit-Log-Seite (Filter, Paginierung), Einstellungen-Seite (Inline-Bearbeitung), Systemstatus-Seite (Gesamtstatus + Komponentenliste) +- [x] Navigation erweitert: Audit-Log, Einstellungen +- [x] E2E verifiziert: Migration 004, Settings setzen/lesen, Audit-Log (92 Einträge, Filter), Systemstatus inkl. Modul-Health, RBAC (USER → 403 auf audit/settings/system) + +## Nächste Schritte + +- Einbindung bestehender Projekte als Module (ersetzt Phase 7; Infrastruktur steht seit Phase 3–8) +- Phase 10 – Security Hardening: Dependency/Container-Scans, HSTS, Backup/Restore, Pen-Tests \ No newline at end of file