feat: Phase 9 – Administration (Audit-UI, Systemeinstellungen, erweiterter Systemstatus)

This commit is contained in:
MPM Dev
2026-10-07 16:40:00 +02:00
parent e6219e8cf9
commit 564f7def1c
21 changed files with 891 additions and 71 deletions

View File

@@ -2,7 +2,7 @@
Zentrale, webbasierte Management-Plattform, über die eigenständige Web-Applikationen als **Module** integriert, verwaltet und Benutzern zugewiesen werden können. 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 ## Architektur-Überblick
@@ -112,6 +112,9 @@ Verbindlicher Modul-API-Vertrag (`/health`, `/api/manifest`, `/api/me`) mit `pla
### Phase 8 – Modul-SDK ### 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. `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 ## Annahme
„ChatCM" wurde als **shadcn-artige Komponentenbasis** interpretiert: Tailwind CSS plus zentral gepflegte, wiederverwendbare UI-Komponenten (`apps/platform-frontend/src/components/ui`). „ChatCM" wurde als **shadcn-artige Komponentenbasis** interpretiert: Tailwind CSS plus zentral gepflegte, wiederverwendbare UI-Komponenten (`apps/platform-frontend/src/components/ui`).

View File

@@ -8,6 +8,7 @@ import { AuthModule } from './auth/auth.module';
import { UsersModule } from './users/users.module'; import { UsersModule } from './users/users.module';
import { HealthModule } from './health/health.module'; import { HealthModule } from './health/health.module';
import { ModulesModule } from './modules/modules.module'; import { ModulesModule } from './modules/modules.module';
import { SettingsModule } from './settings/settings.module';
import { SessionGuard } from './auth/guards/session.guard'; import { SessionGuard } from './auth/guards/session.guard';
import { CsrfGuard } from './auth/guards/csrf.guard'; import { CsrfGuard } from './auth/guards/csrf.guard';
import { RolesGuard } from './common/guards/roles.guard'; import { RolesGuard } from './common/guards/roles.guard';
@@ -25,6 +26,7 @@ import { RolesGuard } from './common/guards/roles.guard';
UsersModule, UsersModule,
HealthModule, HealthModule,
ModulesModule, ModulesModule,
SettingsModule,
], ],
providers: [ providers: [
MigrationRunner, MigrationRunner,

View File

@@ -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<string, unknown>;
readonly ipAddress: string | null;
readonly createdAt: string;
}
interface AuditRow {
id: number;
username: string;
action: string;
details: Record<string, unknown>;
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<AuditLogEntry[]> {
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<AuditRow>(
`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<AuditListFilter, 'action' | 'username'>): Promise<number> {
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;
}
}

View File

@@ -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<typeof auditListQuerySchema>;
/**
* 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 };
}
}

View File

@@ -1,12 +1,15 @@
import { Global, Module } from '@nestjs/common'; import { Global, Module } from '@nestjs/common';
import { DatabaseModule } from '../database/database.module'; import { DatabaseModule } from '../database/database.module';
import { AuditController } from './audit.controller';
import { AuditQueryRepository } from './audit-query.repository';
import { AuditService } from './audit.service'; import { AuditService } from './audit.service';
/** Global verfügbares Audit-Logging. */ /** Global verfügbares Audit-Logging mit Admin-Abfragen. */
@Global() @Global()
@Module({ @Module({
imports: [DatabaseModule], imports: [DatabaseModule],
providers: [AuditService], controllers: [AuditController],
exports: [AuditService], providers: [AuditService, AuditQueryRepository],
exports: [AuditService, AuditQueryRepository],
}) })
export class AuditModule {} export class AuditModule {}

View File

@@ -23,6 +23,7 @@ export const AUDIT_ACTIONS = {
MODULE_DISABLED: 'MODULE_DISABLED', MODULE_DISABLED: 'MODULE_DISABLED',
PERMISSION_GRANTED: 'PERMISSION_GRANTED', PERMISSION_GRANTED: 'PERMISSION_GRANTED',
PERMISSION_REVOKED: 'PERMISSION_REVOKED', PERMISSION_REVOKED: 'PERMISSION_REVOKED',
SETTINGS_CHANGED: 'SETTINGS_CHANGED',
} as const; } as const;
export type AuditAction = (typeof AUDIT_ACTIONS)[keyof typeof AUDIT_ACTIONS]; export type AuditAction = (typeof AUDIT_ACTIONS)[keyof typeof AUDIT_ACTIONS];

View File

@@ -1,10 +1,12 @@
import { migration001CoreSchema } from './001-core-schema'; import { migration001CoreSchema } from './001-core-schema';
import { migration002Modules } from '../../modules/migrations/002-modules'; import { migration002Modules } from '../../modules/migrations/002-modules';
import { migration003ModulePermissions } from '../../modules/migrations/003-module-permissions'; import { migration003ModulePermissions } from '../../modules/migrations/003-module-permissions';
import { migration004SystemSettings } from '../../settings/migrations/004-system-settings';
/** Registrierte Migrationen in aufsteigender Reihenfolge. */ /** Registrierte Migrationen in aufsteigender Reihenfolge. */
export const MIGRATIONS = [ export const MIGRATIONS = [
migration001CoreSchema, migration001CoreSchema,
migration002Modules, migration002Modules,
migration003ModulePermissions, migration003ModulePermissions,
migration004SystemSettings,
]; ];

View File

@@ -1,10 +1,13 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { DatabaseModule } from '../database/database.module'; import { DatabaseModule } from '../database/database.module';
import { AuditModule } from '../audit/audit.module';
import { ModulesModule } from '../modules/modules.module';
import { HealthController } from './health.controller'; import { HealthController } from './health.controller';
import { SystemStatusController } from './system-status.controller';
/** Health-Endpoints (Monitoring). */ /** Health-Endpoints (Monitoring). */
@Module({ @Module({
imports: [DatabaseModule], imports: [DatabaseModule, AuditModule, ModulesModule],
controllers: [HealthController], controllers: [HealthController, SystemStatusController],
}) })
export class HealthModule {} export class HealthModule {}

View File

@@ -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<SystemStatusResponse> {
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 };
}
}

View File

@@ -40,7 +40,7 @@ import { ModulesService } from './modules.service';
ModulePermissionRepository, ModulePermissionRepository,
ModulePermissionsService, ModulePermissionsService,
], ],
exports: [ModuleRepository, ModulesService, ModulePermissionsService], exports: [ModuleRepository, ModulesService, ModulePermissionsService, ModuleHealthChecker],
}) })
export class ModulesModule implements NestModule { export class ModulesModule implements NestModule {
/** Gateway-Middleware für alle /api/v1/gateway/* Pfade. */ /** Gateway-Middleware für alle /api/v1/gateway/* Pfade. */

View File

@@ -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
)
`);
},
};

View File

@@ -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<typeof updateSettingSchema>;
/**
* 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 };
}
}

View File

@@ -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 {}

View File

@@ -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<SystemSettingRecord[]> {
const result = await this.database.query<SettingRow>(
'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<SystemSettingRecord | null> {
const result = await this.database.query<SettingRow>(
'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<SystemSettingRecord> {
const result = await this.database.query<SettingRow>(
`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<SystemSettingRecord> {
const setting = await this.get(key);
if (!setting) {
throw new NotFoundException(`Einstellung "${key}" nicht gefunden`);
}
return setting;
}
}

View File

@@ -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<SettingResponse[]> {
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<SettingResponse> {
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);
}
}

View File

@@ -17,6 +17,8 @@ const NAV_ITEMS: NavItem[] = [
{ to: '/admin/users', label: 'Benutzer', icon: '👥', adminOnly: true }, { to: '/admin/users', label: 'Benutzer', icon: '👥', adminOnly: true },
{ to: '/admin/modules', label: 'Module', icon: '🧩', adminOnly: true }, { to: '/admin/modules', label: 'Module', icon: '🧩', adminOnly: true },
{ to: '/admin/system', label: 'Systemstatus', 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). */ /** Responsive App-Shell: Sidebar (Desktop) / Overlay-Menü (Mobil). */

View File

@@ -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<typeof auditFilterSchema>;
/** 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<AuditFilter>({});
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<HTMLFormElement>): 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 (
<div className="mx-auto max-w-6xl space-y-6">
<div>
<h1 className="text-2xl font-bold text-slate-900">Audit-Log</h1>
<p className="mt-1 text-sm text-slate-500">
Sicherheitsrelevante Ereignisse der Plattform (neueste zuerst).
</p>
</div>
{/* Filter */}
<Card>
<CardBody>
<form onSubmit={handleFilterSubmit} className="flex flex-wrap items-end gap-3" noValidate>
<div className="w-48">
<Input
label="Aktion"
value={actionFilter}
onChange={(event) => setActionFilter(event.target.value)}
placeholder="z. B. LOGIN_SUCCESS"
/>
</div>
<div className="w-48">
<Input
label="Benutzername"
value={usernameFilter}
onChange={(event) => setUsernameFilter(event.target.value)}
placeholder="z. B. admin"
/>
</div>
<Button type="submit" variant="secondary">
Filtern
</Button>
</form>
</CardBody>
</Card>
{/* Einträge */}
<Card>
<CardHeader
title="Ereignisse"
description={auditQuery.data ? `${auditQuery.data.total} Einträge insgesamt` : undefined}
/>
<CardBody>
{auditQuery.isLoading && <Spinner label="Audit-Log wird geladen…" />}
{auditQuery.isError && <ErrorState message="Audit-Log konnte nicht geladen werden." />}
{auditQuery.data && auditQuery.data.entries.length === 0 && (
<p className="py-8 text-center text-sm text-slate-500">
Keine Einträge für die gewählten Filter.
</p>
)}
{auditQuery.data && auditQuery.data.entries.length > 0 && (
<div className="overflow-x-auto">
<table className="w-full min-w-[640px] text-sm">
<thead>
<tr className="border-b border-slate-200 text-left text-xs uppercase tracking-wide text-slate-500">
<th className="px-3 py-2 font-semibold">Zeit</th>
<th className="px-3 py-2 font-semibold">Benutzer</th>
<th className="px-3 py-2 font-semibold">Aktion</th>
<th className="px-3 py-2 font-semibold">Details</th>
</tr>
</thead>
<tbody>
{auditQuery.data.entries.map((entry) => (
<tr key={entry.id} className="border-b border-slate-100 last:border-0">
<td className="whitespace-nowrap px-3 py-2 text-slate-500">
{formatTimestamp(entry.createdAt)}
</td>
<td className="px-3 py-2 font-medium text-slate-900">{entry.username}</td>
<td className="px-3 py-2">
<code className="rounded bg-slate-100 px-1.5 py-0.5 text-xs text-slate-700">
{entry.action}
</code>
</td>
<td className="px-3 py-2 text-xs text-slate-500">
{Object.keys(entry.details).length > 0
? JSON.stringify(entry.details)
: '–'}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{/* Paginierung */}
{totalPages > 1 && (
<div className="mt-4 flex items-center justify-between">
<Button
size="sm"
variant="secondary"
disabled={page === 0}
onClick={() => setPage((current) => current - 1)}
>
Zurück
</Button>
<span className="text-sm text-slate-500">
Seite {page + 1} von {totalPages}
</span>
<Button
size="sm"
variant="secondary"
disabled={page >= totalPages - 1}
onClick={() => setPage((current) => current + 1)}
>
Weiter
</Button>
</div>
)}
</CardBody>
</Card>
</div>
);
}

View File

@@ -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<string, string> = {
'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<Record<string, string>>({});
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<HTMLFormElement>, key: string): void {
event.preventDefault();
const value = editValues[key];
if (value !== undefined && value.trim().length > 0) {
updateMutation.mutate({ key, value: value.trim() });
}
}
return (
<div className="mx-auto max-w-3xl space-y-6">
<div>
<h1 className="text-2xl font-bold text-slate-900">Einstellungen</h1>
<p className="mt-1 text-sm text-slate-500">
Zentrale Konfiguration der Plattform.
</p>
</div>
<Card>
<CardHeader title="Plattform-Einstellungen" />
<CardBody>
{settingsQuery.isLoading && <Spinner label="Einstellungen werden geladen…" />}
{settingsQuery.isError && (
<ErrorState message="Einstellungen konnten nicht geladen werden." />
)}
{settingsQuery.data && settingsQuery.data.settings.length === 0 && (
<p className="py-4 text-center text-sm text-slate-500">
Noch keine Einstellungen vorhanden. Änderungen legen sie automatisch an.
</p>
)}
{settingsQuery.data && settingsQuery.data.settings.length > 0 && (
<div className="space-y-4">
{settingsQuery.data.settings.map((setting) => (
<form
key={setting.key}
onSubmit={(event) => handleSubmit(event, setting.key)}
className="flex items-end gap-3"
noValidate
>
<div className="flex-1">
<Input
label={SETTING_LABELS[setting.key] ?? setting.key}
value={editValues[setting.key] ?? setting.value}
onChange={(event) =>
setEditValues((current) => ({
...current,
[setting.key]: event.target.value,
}))
}
/>
<p className="mt-1 text-xs text-slate-500">
Zuletzt geändert von {setting.updatedBy ?? '–'} am{' '}
{new Date(setting.updatedAt).toLocaleString('de-DE')}
</p>
</div>
<Button
type="submit"
variant="secondary"
loading={
updateMutation.isPending && updateMutation.variables?.key === setting.key
}
>
Speichern
</Button>
</form>
))}
</div>
)}
</CardBody>
</Card>
</div>
);
}

View File

@@ -1,17 +1,46 @@
import { type ReactNode } from 'react'; import { type ReactNode } from 'react';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { apiRequest } from '../../lib/api-client'; 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 { Card, CardBody, CardHeader } from '../../components/ui/card';
import { Badge } from '../../components/ui/badge'; import { Badge } from '../../components/ui/badge';
import { ErrorState, Spinner } from '../../components/ui/states'; 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<typeof systemStatusSchema>;
/** 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 { export function SystemStatusPage(): ReactNode {
const healthQuery = useQuery({ const statusQuery = useQuery({
queryKey: ['health'], queryKey: ['system-status'],
queryFn: async (): Promise<Health> => queryFn: async (): Promise<SystemStatus> =>
healthSchema.parse(await apiRequest('/api/v1/health')), systemStatusSchema.parse(await apiRequest('/api/v1/system/status')),
refetchInterval: 30_000, refetchInterval: 30_000,
}); });
@@ -20,47 +49,55 @@ export function SystemStatusPage(): ReactNode {
<div> <div>
<h1 className="text-2xl font-bold text-slate-900">Systemstatus</h1> <h1 className="text-2xl font-bold text-slate-900">Systemstatus</h1>
<p className="mt-1 text-sm text-slate-500"> <p className="mt-1 text-sm text-slate-500">
Zustand aller Plattform-Komponenten (aktualisiert alle 30 Sekunden). Zustand aller Plattform-Komponenten und Module (aktualisiert alle 30 Sekunden).
</p> </p>
</div> </div>
<Card> <Card>
<CardHeader title="Komponenten" /> <CardHeader title="Gesamtstatus" />
<CardBody> <CardBody>
{healthQuery.isLoading && <Spinner />} {statusQuery.isLoading && <Spinner />}
{healthQuery.isError && ( {statusQuery.isError && (
<ErrorState message="Der Health-Endpoint ist nicht erreichbar." /> <ErrorState message="Der Systemstatus ist nicht erreichbar." />
)} )}
{healthQuery.data && ( {statusQuery.data && (
<div className="space-y-3"> <div className="flex items-center gap-3">
<StatusRow <Badge
label="Management-Backend"
variant={healthQuery.data.components.backend === 'healthy' ? 'success' : 'danger'}
status={healthQuery.data.components.backend === 'healthy' ? 'Healthy' : 'Unhealthy'}
/>
<StatusRow
label="PostgreSQL"
variant={ variant={
healthQuery.data.components.database.status === 'healthy' statusQuery.data.overall === 'HEALTHY'
? 'success' ? 'success'
: statusQuery.data.overall === 'DEGRADED'
? 'warning'
: 'danger' : 'danger'
} }
status={ >
healthQuery.data.components.database.status === 'healthy' {statusQuery.data.overall}
? `Healthy · ${healthQuery.data.components.database.latencyMs} ms` </Badge>
: 'Unhealthy'
}
/>
<div className="flex items-center justify-between rounded-lg border border-slate-200 px-4 py-3">
<span className="text-sm font-medium text-slate-700">Plattform-Version</span>
<span className="text-sm text-slate-500">{healthQuery.data.version}</span>
</div>
<div className="flex items-center justify-between rounded-lg border border-slate-200 px-4 py-3">
<span className="text-sm font-medium text-slate-700">Uptime</span>
<span className="text-sm text-slate-500"> <span className="text-sm text-slate-500">
{Math.floor(healthQuery.data.uptimeSeconds / 60)} Minuten {statusQuery.data.components.length} Komponenten überwacht
</span> </span>
</div> </div>
)}
</CardBody>
</Card>
<Card>
<CardHeader title="Komponenten" />
<CardBody>
{statusQuery.data && (
<div className="space-y-3">
{statusQuery.data.components.map((component) => (
<div
key={component.name}
className="flex items-center justify-between rounded-lg border border-slate-200 px-4 py-3"
>
<div>
<span className="text-sm font-medium text-slate-700">{component.name}</span>
<span className="ml-2 text-xs text-slate-500">{component.detail}</span>
</div>
<Badge variant={statusVariant(component.status)}>{component.status}</Badge>
</div>
))}
</div> </div>
)} )}
</CardBody> </CardBody>
@@ -68,21 +105,3 @@ export function SystemStatusPage(): ReactNode {
</div> </div>
); );
} }
/** Zeile im Systemstatus. */
function StatusRow({
label,
status,
variant,
}: {
label: string;
status: string;
variant: 'success' | 'danger';
}): ReactNode {
return (
<div className="flex items-center justify-between rounded-lg border border-slate-200 px-4 py-3">
<span className="text-sm font-medium text-slate-700">{label}</span>
<Badge variant={variant}>{status}</Badge>
</div>
);
}

View File

@@ -11,6 +11,8 @@ import { DashboardPage } from './features/dashboard/dashboard-page';
import { SystemStatusPage } from './features/admin/system-status-page'; import { SystemStatusPage } from './features/admin/system-status-page';
import { UsersPage } from './features/admin/users-page'; import { UsersPage } from './features/admin/users-page';
import { ModulesPage } from './features/admin/modules-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 { ProfilePage } from './features/profile/profile-page';
import { ForbiddenPage, NotFoundPage } from './pages/error-pages'; import { ForbiddenPage, NotFoundPage } from './pages/error-pages';
import './index.css'; import './index.css';
@@ -47,6 +49,14 @@ function AppRoutes(): ReactNode {
path="/admin/system" path="/admin/system"
element={<RequireAdmin><SystemStatusPage /></RequireAdmin>} element={<RequireAdmin><SystemStatusPage /></RequireAdmin>}
/> />
<Route
path="/admin/audit"
element={<RequireAdmin><AuditPage /></RequireAdmin>}
/>
<Route
path="/admin/settings"
element={<RequireAdmin><SettingsPage /></RequireAdmin>}
/>
<Route path="/403" element={<ForbiddenPage />} /> <Route path="/403" element={<ForbiddenPage />} />
</Route> </Route>

View File

@@ -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 | | 6 | Modul-API (`/health`, `/api/manifest`, `/api/me`) | ✅ Abgeschlossen |
| 7 | Referenzmodul Kalender | ⏳ Übersprungen (bestehende Projekte werden stattdessen eingebunden) | | 7 | Referenzmodul Kalender | ⏳ Übersprungen (bestehende Projekte werden stattdessen eingebunden) |
| 8 | Modul-SDK (`platform-module-sdk`) | ✅ Abgeschlossen | | 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 | | 10 | Security Hardening & Produktivbetrieb | ⏳ Geplant |
## Phase 1 – Grundgerüst (abgeschlossen) ## 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] 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 - [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) Definition of Done: Modulübersicht, Benutzerverwaltung, Rechteverwaltung, Systemstatus, Audit Log, Einstellungen.
- Systemeinstellungen (Backend + UI)
- Systemstatus-Dashboard erweitern (Modul-Healths) - [x] `system_settings`-Tabelle (Migration 004) mit Whitelist-Schlüsseln
- Danach: Einbindung bestehender Projekte als Module (ersetzt Phase 7) - [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