feat: Phase 9 – Administration (Audit-UI, Systemeinstellungen, erweiterter Systemstatus)
This commit is contained in:
@@ -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`).
|
||||
@@ -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,
|
||||
|
||||
99
apps/platform-backend/src/audit/audit-query.repository.ts
Normal file
99
apps/platform-backend/src/audit/audit-query.repository.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
38
apps/platform-backend/src/audit/audit.controller.ts
Normal file
38
apps/platform-backend/src/audit/audit.controller.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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];
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
@@ -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 {}
|
||||
96
apps/platform-backend/src/health/system-status.controller.ts
Normal file
96
apps/platform-backend/src/health/system-status.controller.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
@@ -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. */
|
||||
|
||||
@@ -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
|
||||
)
|
||||
`);
|
||||
},
|
||||
};
|
||||
44
apps/platform-backend/src/settings/settings.controller.ts
Normal file
44
apps/platform-backend/src/settings/settings.controller.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
15
apps/platform-backend/src/settings/settings.module.ts
Normal file
15
apps/platform-backend/src/settings/settings.module.ts
Normal 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 {}
|
||||
78
apps/platform-backend/src/settings/settings.repository.ts
Normal file
78
apps/platform-backend/src/settings/settings.repository.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
60
apps/platform-backend/src/settings/settings.service.ts
Normal file
60
apps/platform-backend/src/settings/settings.service.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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). */
|
||||
|
||||
191
apps/platform-frontend/src/features/admin/audit-page.tsx
Normal file
191
apps/platform-frontend/src/features/admin/audit-page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
127
apps/platform-frontend/src/features/admin/settings-page.tsx
Normal file
127
apps/platform-frontend/src/features/admin/settings-page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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<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 {
|
||||
const healthQuery = useQuery({
|
||||
queryKey: ['health'],
|
||||
queryFn: async (): Promise<Health> =>
|
||||
healthSchema.parse(await apiRequest('/api/v1/health')),
|
||||
const statusQuery = useQuery({
|
||||
queryKey: ['system-status'],
|
||||
queryFn: async (): Promise<SystemStatus> =>
|
||||
systemStatusSchema.parse(await apiRequest('/api/v1/system/status')),
|
||||
refetchInterval: 30_000,
|
||||
});
|
||||
|
||||
@@ -20,69 +49,59 @@ export function SystemStatusPage(): ReactNode {
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-slate-900">Systemstatus</h1>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader title="Gesamtstatus" />
|
||||
<CardBody>
|
||||
{statusQuery.isLoading && <Spinner />}
|
||||
{statusQuery.isError && (
|
||||
<ErrorState message="Der Systemstatus ist nicht erreichbar." />
|
||||
)}
|
||||
{statusQuery.data && (
|
||||
<div className="flex items-center gap-3">
|
||||
<Badge
|
||||
variant={
|
||||
statusQuery.data.overall === 'HEALTHY'
|
||||
? 'success'
|
||||
: statusQuery.data.overall === 'DEGRADED'
|
||||
? 'warning'
|
||||
: 'danger'
|
||||
}
|
||||
>
|
||||
{statusQuery.data.overall}
|
||||
</Badge>
|
||||
<span className="text-sm text-slate-500">
|
||||
{statusQuery.data.components.length} Komponenten überwacht
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader title="Komponenten" />
|
||||
<CardBody>
|
||||
{healthQuery.isLoading && <Spinner />}
|
||||
{healthQuery.isError && (
|
||||
<ErrorState message="Der Health-Endpoint ist nicht erreichbar." />
|
||||
)}
|
||||
{healthQuery.data && (
|
||||
{statusQuery.data && (
|
||||
<div className="space-y-3">
|
||||
<StatusRow
|
||||
label="Management-Backend"
|
||||
variant={healthQuery.data.components.backend === 'healthy' ? 'success' : 'danger'}
|
||||
status={healthQuery.data.components.backend === 'healthy' ? 'Healthy' : 'Unhealthy'}
|
||||
/>
|
||||
<StatusRow
|
||||
label="PostgreSQL"
|
||||
variant={
|
||||
healthQuery.data.components.database.status === 'healthy'
|
||||
? 'success'
|
||||
: 'danger'
|
||||
}
|
||||
status={
|
||||
healthQuery.data.components.database.status === 'healthy'
|
||||
? `Healthy · ${healthQuery.data.components.database.latencyMs} ms`
|
||||
: '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">
|
||||
{Math.floor(healthQuery.data.uptimeSeconds / 60)} Minuten
|
||||
</span>
|
||||
</div>
|
||||
{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>
|
||||
)}
|
||||
</CardBody>
|
||||
</Card>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -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={<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>
|
||||
|
||||
|
||||
@@ -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)
|
||||
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
|
||||
Reference in New Issue
Block a user