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

@@ -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). */

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 { 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>
);
}

View File

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