feat: Phase 9 – Administration (Audit-UI, Systemeinstellungen, erweiterter Systemstatus)
This commit is contained in:
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user