feat: Phase 1 – Plattform-Grundgerüst (Docker, NestJS, React, Auth)

This commit is contained in:
MPM Dev
2026-10-06 14:22:11 +02:00
commit a7e1c421f2
85 changed files with 17701 additions and 0 deletions

View File

@@ -0,0 +1,118 @@
import { type ReactNode, useState } from 'react';
import { NavLink, Outlet } from 'react-router-dom';
import { useAuth } from '../../features/auth/auth-context';
import { Badge } from '../ui/badge';
/** Ein Navigationspunkt der Sidebar. */
interface NavItem {
to: string;
label: string;
icon: string;
adminOnly?: boolean;
}
const NAV_ITEMS: NavItem[] = [
{ to: '/', label: 'Dashboard', icon: '⌂' },
{ to: '/admin/users', label: 'Benutzer', icon: '👥', adminOnly: true },
{ to: '/admin/system', label: 'Systemstatus', icon: '⚙', adminOnly: true },
];
/** Responsive App-Shell: Sidebar (Desktop) / Overlay-Menü (Mobil). */
export function AppLayout(): ReactNode {
const { user, logout } = useAuth();
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
const visibleItems = NAV_ITEMS.filter(
(item) => !item.adminOnly || user?.role === 'ADMIN',
);
return (
<div className="flex h-full">
{/* Mobile: Overlay-Hintergrund */}
{mobileMenuOpen && (
<div
className="fixed inset-0 z-30 bg-slate-900/50 lg:hidden"
onClick={() => setMobileMenuOpen(false)}
aria-hidden="true"
/>
)}
{/* Sidebar */}
<aside
className={`fixed inset-y-0 left-0 z-40 flex w-64 flex-col border-r border-slate-200 bg-white transition-transform lg:static lg:translate-x-0
${mobileMenuOpen ? 'translate-x-0' : '-translate-x-full'}`}
aria-label="Hauptnavigation"
>
<div className="flex h-16 items-center gap-2 border-b border-slate-200 px-6">
<span className="flex h-8 w-8 items-center justify-center rounded-lg bg-brand-600 text-sm font-bold text-white">
M
</span>
<span className="text-base font-semibold text-slate-900">MPM</span>
</div>
<nav className="flex-1 space-y-1 overflow-y-auto px-3 py-4">
{visibleItems.map((item) => (
<NavLink
key={item.to}
to={item.to}
end={item.to === '/'}
onClick={() => setMobileMenuOpen(false)}
className={({ isActive }) =>
`flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors
${
isActive
? 'bg-brand-50 text-brand-700'
: 'text-slate-600 hover:bg-slate-100 hover:text-slate-900'
}`
}
>
<span aria-hidden="true">{item.icon}</span>
{item.label}
</NavLink>
))}
</nav>
<div className="border-t border-slate-200 p-4">
<div className="flex items-center justify-between">
<div className="min-w-0">
<p className="truncate text-sm font-medium text-slate-900">
{user?.displayName}
</p>
<p className="truncate text-xs text-slate-500">@{user?.username}</p>
</div>
{user?.role === 'ADMIN' && <Badge variant="info">Admin</Badge>}
</div>
<button
onClick={() => void logout()}
className="mt-3 w-full rounded-lg px-3 py-2 text-sm font-medium text-slate-600 transition-colors hover:bg-slate-100 hover:text-slate-900"
>
Abmelden
</button>
</div>
</aside>
{/* Hauptbereich */}
<div className="flex min-w-0 flex-1 flex-col">
{/* Topbar (mobil: Menü-Button) */}
<header className="flex h-16 items-center justify-between border-b border-slate-200 bg-white px-4 lg:px-6">
<button
className="rounded-lg p-2 text-slate-600 hover:bg-slate-100 lg:hidden"
onClick={() => setMobileMenuOpen((open) => !open)}
aria-label="Menü öffnen"
aria-expanded={mobileMenuOpen}
>
<span aria-hidden="true" className="text-xl">☰</span>
</button>
<span className="hidden text-sm text-slate-500 lg:block">
Management-Plattform
</span>
<span className="text-sm text-slate-500 lg:hidden">MPM</span>
</header>
<main className="flex-1 overflow-y-auto p-4 lg:p-8">
<Outlet />
</main>
</div>
</div>
);
}

View File

@@ -0,0 +1,35 @@
import { type ReactNode } from 'react';
import { Navigate, useLocation } from 'react-router-dom';
import { useAuth } from '../features/auth/auth-context';
import { Spinner } from './ui/states';
/** Schützt Routen: nur für authentifizierte Benutzer. */
export function RequireAuth({ children }: { children: ReactNode }): ReactNode {
const { status } = useAuth();
const location = useLocation();
if (status === 'loading') {
return <Spinner label="Sitzung wird geprüft…" className="min-h-[50vh]" />;
}
if (status === 'unauthenticated') {
return <Navigate to="/login" replace state={{ from: location.pathname }} />;
}
return children;
}
/** Schützt Admin-Routen: nur für Benutzer mit Rolle ADMIN. */
export function RequireAdmin({ children }: { children: ReactNode }): ReactNode {
const { user, status } = useAuth();
if (status === 'loading') {
return <Spinner label="Sitzung wird geprüft…" className="min-h-[50vh]" />;
}
if (!user || user.role !== 'ADMIN') {
return <Navigate to="/" replace />;
}
return children;
}

View File

@@ -0,0 +1,29 @@
import { type ReactNode } from 'react';
/** Farbschemata für Badges (Statusanzeigen). */
export type BadgeVariant = 'success' | 'warning' | 'danger' | 'neutral' | 'info';
const VARIANT_CLASSES: Record<BadgeVariant, string> = {
success: 'bg-emerald-50 text-emerald-700 ring-emerald-600/20',
warning: 'bg-amber-50 text-amber-700 ring-amber-600/20',
danger: 'bg-red-50 text-red-700 ring-red-600/20',
neutral: 'bg-slate-100 text-slate-600 ring-slate-500/20',
info: 'bg-brand-50 text-brand-700 ring-brand-600/20',
};
export interface BadgeProps {
variant?: BadgeVariant;
children: ReactNode;
}
/** Kleines Status-Label (Design-System). */
export function Badge({ variant = 'neutral', children }: BadgeProps): ReactNode {
return (
<span
className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium ring-1 ring-inset
${VARIANT_CLASSES[variant]}`}
>
{children}
</span>
);
}

View File

@@ -0,0 +1,58 @@
import { type ButtonHTMLAttributes, type ReactNode } from 'react';
/** Varianten des Buttons (Design-System). */
export type ButtonVariant = 'primary' | 'secondary' | 'danger' | 'ghost';
export type ButtonSize = 'sm' | 'md' | 'lg';
const VARIANT_CLASSES: Record<ButtonVariant, string> = {
primary:
'bg-brand-600 text-white hover:bg-brand-700 active:bg-brand-800 disabled:bg-slate-300',
secondary:
'bg-white text-slate-700 border border-slate-300 hover:bg-slate-50 active:bg-slate-100 disabled:text-slate-400',
danger:
'bg-red-600 text-white hover:bg-red-700 active:bg-red-800 disabled:bg-slate-300',
ghost:
'bg-transparent text-slate-600 hover:bg-slate-100 active:bg-slate-200 disabled:text-slate-400',
};
const SIZE_CLASSES: Record<ButtonSize, string> = {
sm: 'h-8 px-3 text-sm',
md: 'h-10 px-4 text-sm',
lg: 'h-11 px-5 text-base',
};
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: ButtonVariant;
size?: ButtonSize;
loading?: boolean;
children: ReactNode;
}
/** Standard-Button des Design-Systems. */
export function Button({
variant = 'primary',
size = 'md',
loading = false,
className = '',
disabled,
children,
...rest
}: ButtonProps): ReactNode {
return (
<button
className={`inline-flex items-center justify-center gap-2 rounded-lg font-medium transition-colors
focus-visible:outline-2 focus-visible:outline-offset-2 disabled:cursor-not-allowed
${VARIANT_CLASSES[variant]} ${SIZE_CLASSES[size]} ${className}`}
disabled={disabled || loading}
{...rest}
>
{loading && (
<span
aria-hidden="true"
className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent"
/>
)}
{children}
</button>
);
}

View File

@@ -0,0 +1,46 @@
import { type ReactNode } from 'react';
export interface CardProps {
children: ReactNode;
className?: string;
}
/** Karten-Container des Design-Systems. */
export function Card({ children, className = '' }: CardProps): ReactNode {
return (
<div
className={`rounded-xl border border-slate-200 bg-white shadow-sm ${className}`}
>
{children}
</div>
);
}
export interface CardHeaderProps {
title: string;
description?: string;
children?: ReactNode;
}
/** Karten-Kopf mit Titel, Beschreibung und optionalen Aktionen. */
export function CardHeader({ title, description, children }: CardHeaderProps): ReactNode {
return (
<div className="flex items-start justify-between gap-4 border-b border-slate-200 px-6 py-4">
<div>
<h2 className="text-base font-semibold text-slate-900">{title}</h2>
{description && <p className="mt-0.5 text-sm text-slate-500">{description}</p>}
</div>
{children}
</div>
);
}
export interface CardBodyProps {
children: ReactNode;
className?: string;
}
/** Karten-Inhalt. */
export function CardBody({ children, className = '' }: CardBodyProps): ReactNode {
return <div className={`px-6 py-4 ${className}`}>{children}</div>;
}

View File

@@ -0,0 +1,51 @@
import { type InputHTMLAttributes, type ReactNode } from 'react';
export interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
label: string;
error?: string;
hint?: string;
}
/** Text-Input mit Label, Fehler- und Hinweistext (Design-System). */
export function Input({
label,
error,
hint,
className = '',
id,
...rest
}: InputProps): ReactNode {
const inputId = id ?? `input-${label.toLowerCase().replace(/\s+/g, '-')}`;
const describedBy = error ? `${inputId}-error` : hint ? `${inputId}-hint` : undefined;
return (
<div className="flex flex-col gap-1.5">
<label htmlFor={inputId} className="text-sm font-medium text-slate-700">
{label}
</label>
<input
id={inputId}
className={`h-10 rounded-lg border px-3 text-sm transition-colors
${
error
? 'border-red-400 focus:border-red-500'
: 'border-slate-300 focus:border-brand-500'
}
${className}`}
aria-invalid={error ? true : undefined}
aria-describedby={describedBy}
{...rest}
/>
{hint && !error && (
<p id={`${inputId}-hint`} className="text-xs text-slate-500">
{hint}
</p>
)}
{error && (
<p id={`${inputId}-error`} role="alert" className="text-xs text-red-600">
{error}
</p>
)}
</div>
);
}

View File

@@ -0,0 +1,57 @@
import { type ReactNode } from 'react';
export interface SpinnerProps {
label?: string;
className?: string;
}
/** Ladeindikator (Design-System). */
export function Spinner({ label = 'Wird geladen…', className = '' }: SpinnerProps): ReactNode {
return (
<div className={`flex flex-col items-center justify-center gap-3 py-12 ${className}`} role="status">
<span
aria-hidden="true"
className="h-8 w-8 animate-spin rounded-full border-4 border-brand-200 border-t-brand-600"
/>
<span className="text-sm text-slate-500">{label}</span>
</div>
);
}
export interface EmptyStateProps {
title: string;
description?: string;
icon?: ReactNode;
}
/** Anzeige für leere Zustände (Design-System). */
export function EmptyState({ title, description, icon }: EmptyStateProps): ReactNode {
return (
<div className="flex flex-col items-center justify-center gap-2 py-12 text-center">
{icon && <div className="text-slate-300">{icon}</div>}
<h3 className="text-sm font-semibold text-slate-900">{title}</h3>
{description && <p className="max-w-sm text-sm text-slate-500">{description}</p>}
</div>
);
}
export interface ErrorStateProps {
title?: string;
message?: string;
}
/** Anzeige für Fehlerzustände (Design-System). */
export function ErrorState({
title = 'Ein Fehler ist aufgetreten',
message = 'Bitte versuchen Sie es später erneut.',
}: ErrorStateProps): ReactNode {
return (
<div className="flex flex-col items-center justify-center gap-2 py-12 text-center" role="alert">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-red-50 text-red-600">
!
</div>
<h3 className="text-sm font-semibold text-slate-900">{title}</h3>
<p className="max-w-sm text-sm text-slate-500">{message}</p>
</div>
);
}

View File

@@ -0,0 +1,88 @@
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 { 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. */
export function SystemStatusPage(): ReactNode {
const healthQuery = useQuery({
queryKey: ['health'],
queryFn: async (): Promise<Health> =>
healthSchema.parse(await apiRequest('/api/v1/health')),
refetchInterval: 30_000,
});
return (
<div className="mx-auto max-w-4xl space-y-6">
<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).
</p>
</div>
<Card>
<CardHeader title="Komponenten" />
<CardBody>
{healthQuery.isLoading && <Spinner />}
{healthQuery.isError && (
<ErrorState message="Der Health-Endpoint ist nicht erreichbar." />
)}
{healthQuery.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>
</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

@@ -0,0 +1,28 @@
import { type ReactNode } from 'react';
import { Card, CardBody, CardHeader } from '../../components/ui/card';
import { EmptyState } from '../../components/ui/states';
/** Platzhalter für die Benutzerverwaltung (Phase 2). */
export function UsersPage(): ReactNode {
return (
<div className="mx-auto max-w-6xl space-y-6">
<div>
<h1 className="text-2xl font-bold text-slate-900">Benutzerverwaltung</h1>
<p className="mt-1 text-sm text-slate-500">
Benutzer anlegen, bearbeiten und verwalten.
</p>
</div>
<Card>
<CardHeader title="Benutzer" description="Alle Benutzer der Plattform" />
<CardBody>
<EmptyState
title="Benutzerverwaltung folgt in Phase 2"
description="Die vollständige Benutzerverwaltung (Liste, Anlegen, Bearbeiten, Deaktivieren) wird in Phase 2 implementiert."
icon={<span className="text-3xl" aria-hidden="true">👥</span>}
/>
</CardBody>
</Card>
</div>
);
}

View File

@@ -0,0 +1,92 @@
import {
type ReactNode,
createContext,
useContext,
useEffect,
useMemo,
useState,
} from 'react';
import { apiRequest, setUnauthorizedHandler } from '../../lib/api-client';
import { authUserSchema, type AuthUser } from '../../lib/schemas';
/** Zustand des Auth-Contexts. */
interface AuthContextValue {
user: AuthUser | null;
status: 'loading' | 'authenticated' | 'unauthenticated';
login: (input: { username: string; password: string }) => Promise<void>;
logout: () => Promise<void>;
refresh: () => Promise<void>;
}
const AuthContext = createContext<AuthContextValue | null>(null);
/** Lädt den aktuellen Benutzer vom Backend. */
async function fetchCurrentUser(): Promise<AuthUser | null> {
try {
const response = await apiRequest<{ user: unknown }>('/api/v1/auth/me');
return authUserSchema.parse(response.user);
} catch {
return null;
}
}
/**
* Zentraler Authentifizierungs-Zustand der Anwendung.
* Beim Start wird die Session geprüft; bei 401 wird der Zustand
* automatisch auf "nicht authentifiziert" gesetzt.
*/
export function AuthProvider({ children }: { children: ReactNode }): ReactNode {
const [user, setUser] = useState<AuthUser | null>(null);
const [status, setStatus] = useState<'loading' | 'authenticated' | 'unauthenticated'>('loading');
useEffect(() => {
void (async () => {
const currentUser = await fetchCurrentUser();
setUser(currentUser);
setStatus(currentUser ? 'authenticated' : 'unauthenticated');
})();
}, []);
useEffect(() => {
setUnauthorizedHandler(() => {
setUser(null);
setStatus('unauthenticated');
});
return () => setUnauthorizedHandler(() => undefined);
}, []);
const value = useMemo<AuthContextValue>(
() => ({
user,
status,
login: async (input) => {
await apiRequest('/api/v1/auth/login', { method: 'POST', body: input });
const currentUser = await fetchCurrentUser();
setUser(currentUser);
setStatus(currentUser ? 'authenticated' : 'unauthenticated');
},
logout: async () => {
await apiRequest('/api/v1/auth/logout', { method: 'POST' }).catch(() => undefined);
setUser(null);
setStatus('unauthenticated');
},
refresh: async () => {
const currentUser = await fetchCurrentUser();
setUser(currentUser);
setStatus(currentUser ? 'authenticated' : 'unauthenticated');
},
}),
[user, status],
);
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
/** Zugriff auf den Auth-Zustand (wirft bei Verwendung außerhalb des Providers). */
export function useAuth(): AuthContextValue {
const context = useContext(AuthContext);
if (!context) {
throw new Error('useAuth muss innerhalb von AuthProvider verwendet werden');
}
return context;
}

View File

@@ -0,0 +1,104 @@
import { type FormEvent, type ReactNode, useState } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { useAuth } from './auth-context';
import { Button } from '../../components/ui/button';
import { Input } from '../../components/ui/input';
import { ApiError } from '../../lib/api-client';
import { loginSchema } from '../../lib/schemas';
/** Zentrale Anmeldeseite der Plattform. */
export function LoginPage(): ReactNode {
const { login } = useAuth();
const navigate = useNavigate();
const location = useLocation();
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState<string | null>(null);
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
const [submitting, setSubmitting] = useState(false);
const from = (location.state as { from?: string } | null)?.from ?? '/';
async function handleSubmit(event: FormEvent<HTMLFormElement>): Promise<void> {
event.preventDefault();
setError(null);
setFieldErrors({});
const parsed = loginSchema.safeParse({ username, password });
if (!parsed.success) {
const errors: Record<string, string> = {};
for (const issue of parsed.error.issues) {
const key = String(issue.path[0]);
if (!errors[key]) errors[key] = issue.message;
}
setFieldErrors(errors);
return;
}
setSubmitting(true);
try {
await login(parsed.data);
navigate(from, { replace: true });
} catch (caughtError) {
if (caughtError instanceof ApiError) {
setError(caughtError.message);
} else {
setError('Anmeldung fehlgeschlagen. Bitte später erneut versuchen.');
}
} finally {
setSubmitting(false);
}
}
return (
<div className="flex min-h-full items-center justify-center bg-slate-50 px-4">
<div className="w-full max-w-sm">
<div className="mb-8 text-center">
<span className="mx-auto flex h-12 w-12 items-center justify-center rounded-xl bg-brand-600 text-lg font-bold text-white">
M
</span>
<h1 className="mt-4 text-2xl font-bold text-slate-900">MPM</h1>
<p className="mt-1 text-sm text-slate-500">
Modulare Management-Plattform
</p>
</div>
<form
onSubmit={(event) => void handleSubmit(event)}
className="rounded-xl border border-slate-200 bg-white p-6 shadow-sm"
noValidate
>
<div className="space-y-4">
<Input
label="Benutzername"
autoComplete="username"
value={username}
onChange={(event) => setUsername(event.target.value)}
error={fieldErrors.username}
autoFocus
/>
<Input
label="Passwort"
type="password"
autoComplete="current-password"
value={password}
onChange={(event) => setPassword(event.target.value)}
error={fieldErrors.password}
/>
</div>
{error && (
<p role="alert" className="mt-4 rounded-lg bg-red-50 px-3 py-2 text-sm text-red-700">
{error}
</p>
)}
<Button type="submit" loading={submitting} className="mt-6 w-full">
Anmelden
</Button>
</form>
</div>
</div>
);
}

View File

@@ -0,0 +1,110 @@
import { type ReactNode } from 'react';
import { useQuery } from '@tanstack/react-query';
import { useAuth } from '../auth/auth-context';
import { apiRequest } from '../../lib/api-client';
import { healthSchema, type Health } from '../../lib/schemas';
import { Card, CardBody, CardHeader } from '../../components/ui/card';
import { Badge } from '../../components/ui/badge';
import { EmptyState, ErrorState, Spinner } from '../../components/ui/states';
/** Dashboard: Begrüßung, eigene Anwendungen (ab Phase 3) und Systemstatus. */
export function DashboardPage(): ReactNode {
const { user } = useAuth();
const isAdmin = user?.role === 'ADMIN';
const healthQuery = useQuery({
queryKey: ['health'],
queryFn: async (): Promise<Health> => healthSchema.parse(
await apiRequest('/api/v1/health'),
),
enabled: isAdmin,
refetchInterval: 30_000,
});
return (
<div className="mx-auto max-w-6xl space-y-6">
<div>
<h1 className="text-2xl font-bold text-slate-900">
Willkommen, {user?.displayName} 👋
</h1>
<p className="mt-1 text-sm text-slate-500">
Ihre zentrale Anlaufstelle für alle freigegebenen Anwendungen.
</p>
</div>
{/* Meine Anwendungen (Modul-Kacheln; ab Phase 3 aus Berechtigungen) */}
<Card>
<CardHeader
title="Meine Anwendungen"
description="Freigegebene Module der Plattform"
/>
<CardBody>
<EmptyState
title="Noch keine Module installiert"
description="Sobald der Administrator Module installiert und Ihnen zugewiesen hat, erscheinen diese hier als Kacheln."
icon={<span className="text-3xl" aria-hidden="true">🧩</span>}
/>
</CardBody>
</Card>
{/* Systemstatus (nur für Admins) */}
{isAdmin && (
<Card>
<CardHeader
title="Systemstatus"
description="Live-Status der Plattform-Komponenten"
/>
<CardBody>
{healthQuery.isLoading && <Spinner label="Status wird geladen…" />}
{healthQuery.isError && (
<ErrorState
title="Status nicht verfügbar"
message="Der Health-Endpoint konnte nicht erreicht werden."
/>
)}
{healthQuery.data && (
<div className="grid gap-3 sm:grid-cols-2">
<StatusRow
label="Management"
variant={healthQuery.data.components.backend === 'healthy' ? 'success' : 'danger'}
status={healthQuery.data.components.backend === 'healthy' ? 'Healthy' : 'Unhealthy'}
/>
<StatusRow
label="Datenbank"
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>
)}
</CardBody>
</Card>
)}
</div>
);
}
/** Zeile im Systemstatus-Dashboard. */
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

@@ -0,0 +1,28 @@
@import "tailwindcss";
/* =============================================================
MPM Design-System – zentrale Design-Tokens (Tailwind v4)
============================================================= */
@theme {
/* Markenfarben: professionelles Indigo als Primärfarbe */
--color-brand-50: oklch(0.97 0.014 265);
--color-brand-100: oklch(0.94 0.03 265);
--color-brand-200: oklch(0.89 0.055 265);
--color-brand-300: oklch(0.81 0.1 265);
--color-brand-400: oklch(0.7 0.16 265);
--color-brand-500: oklch(0.6 0.2 265);
--color-brand-600: oklch(0.54 0.22 265);
--color-brand-700: oklch(0.47 0.2 265);
--color-brand-800: oklch(0.4 0.17 265);
--color-brand-900: oklch(0.34 0.13 265);
--color-brand-950: oklch(0.24 0.08 265);
}
/* Fokus-Stil für Tastaturnavigation (Barrierefreiheit) */
@layer base {
:focus-visible {
outline: 2px solid var(--color-brand-500);
outline-offset: 2px;
}
}

View File

@@ -0,0 +1,95 @@
/**
* Zentraler API-Client des Frontends.
* - Sendet automatisch das CSRF-Token bei zustandsändernden Requests
* - Behandelt 401 (nicht authentifiziert) einheitlich
* - Wirft strukturierte ApiError-Objekte statt roher Response-Objekte
*/
/** Strukturierter API-Fehler mit Statuscode und Meldungen. */
export class ApiError extends Error {
constructor(
public readonly status: number,
message: string,
public readonly details?: Record<string, string[]>,
) {
super(message);
this.name = 'ApiError';
}
}
/** Liest das CSRF-Token aus dem nicht-HttpOnly-Cookie. */
function readCsrfToken(): string | null {
for (const part of document.cookie.split(';')) {
const [name, ...value] = part.trim().split('=');
if (name === 'mpm_csrf') {
return decodeURIComponent(value.join('='));
}
}
return null;
}
/** Callback, der bei einem 401-Fehler aufgerufen wird (Auth-Context setzt ihn). */
let unauthorizedHandler: (() => void) | null = null;
export function setUnauthorizedHandler(handler: () => void): void {
unauthorizedHandler = handler;
}
interface RequestOptions {
method?: 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE';
body?: unknown;
signal?: AbortSignal;
}
/** Führt einen API-Request aus und parst die JSON-Antwort. */
export async function apiRequest<TResponse>(
path: string,
options: RequestOptions = {},
): Promise<TResponse> {
const { method = 'GET', body, signal } = options;
const headers: Record<string, string> = { Accept: 'application/json' };
if (body !== undefined) {
headers['Content-Type'] = 'application/json';
}
if (method !== 'GET') {
const csrfToken = readCsrfToken();
if (csrfToken) {
headers['X-CSRF-Token'] = csrfToken;
}
}
const response = await fetch(path, {
method,
headers,
credentials: 'same-origin',
body: body !== undefined ? JSON.stringify(body) : undefined,
signal,
});
if (response.status === 401 && unauthorizedHandler) {
unauthorizedHandler();
}
if (!response.ok) {
let message = 'Ein unerwarteter Fehler ist aufgetreten.';
let details: Record<string, string[]> | undefined;
try {
const errorBody = (await response.json()) as {
message?: string | string[];
details?: Record<string, string[]>;
};
if (typeof errorBody.message === 'string') {
message = errorBody.message;
} else if (Array.isArray(errorBody.message)) {
message = errorBody.message.join(', ');
}
details = errorBody.details;
} catch {
// Antwort enthält kein JSON – Standardmeldung verwenden.
}
throw new ApiError(response.status, message, details);
}
return (await response.json()) as TResponse;
}

View File

@@ -0,0 +1,37 @@
import { z } from 'zod';
/** Globale Plattform-Rollen (muss zum Backend passen). */
export const ROLE_NAMES = ['ADMIN', 'USER'] as const;
export type RoleName = (typeof ROLE_NAMES)[number];
/** Öffentliche Benutzerdaten (API-Vertrag /api/v1/auth/me). */
export const authUserSchema = z.object({
id: z.string(),
username: z.string(),
email: z.string(),
displayName: z.string(),
role: z.enum(ROLE_NAMES),
});
export type AuthUser = z.infer<typeof authUserSchema>;
/** Login-Anfrage (Client-Validierung ist UX; Server validiert erneut). */
export const loginSchema = z.object({
username: z.string().trim().min(1, 'Benutzername ist erforderlich'),
password: z.string().min(1, 'Passwort ist erforderlich'),
});
export type LoginDto = z.infer<typeof loginSchema>;
/** Health-Antwort (API-Vertrag /api/v1/health). */
export const healthSchema = z.object({
status: z.enum(['healthy', 'unhealthy']),
version: z.string(),
uptimeSeconds: z.number(),
components: z.object({
backend: z.enum(['healthy']),
database: z.object({
status: z.enum(['healthy', 'unhealthy']),
latencyMs: z.number(),
}),
}),
});
export type Health = z.infer<typeof healthSchema>;

View File

@@ -0,0 +1,66 @@
import { StrictMode, type ReactNode } from 'react';
import { createRoot } from 'react-dom/client';
import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { AppLayout } from './components/layout/app-layout';
import { RequireAdmin, RequireAuth } from './components/route-guards';
import { AuthProvider } from './features/auth/auth-context';
import { LoginPage } from './features/auth/login-page';
import { DashboardPage } from './features/dashboard/dashboard-page';
import { SystemStatusPage } from './features/admin/system-status-page';
import { UsersPage } from './features/admin/users-page';
import { ForbiddenPage, NotFoundPage } from './pages/error-pages';
import './index.css';
/** TanStack Query: keine automatischen Refetches bei Fensterfokus. */
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: 1,
refetchOnWindowFocus: false,
staleTime: 30_000,
},
},
});
/** Routenbaum der Management-Plattform. */
function AppRoutes(): ReactNode {
return (
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route element={<RequireAuth><AppLayout /></RequireAuth>}>
<Route path="/" element={<DashboardPage />} />
<Route
path="/admin/users"
element={<RequireAdmin><UsersPage /></RequireAdmin>}
/>
<Route
path="/admin/system"
element={<RequireAdmin><SystemStatusPage /></RequireAdmin>}
/>
<Route path="/403" element={<ForbiddenPage />} />
</Route>
<Route path="*" element={<Navigate to="/404" replace />} />
<Route path="/404" element={<NotFoundPage />} />
</Routes>
);
}
const rootElement = document.getElementById('root');
if (!rootElement) {
throw new Error('Root-Element nicht gefunden');
}
createRoot(rootElement).render(
<StrictMode>
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<AuthProvider>
<AppRoutes />
</AuthProvider>
</BrowserRouter>
</QueryClientProvider>
</StrictMode>,
);

View File

@@ -0,0 +1,27 @@
import { type ReactNode } from 'react';
/** 403-Seite: Zugriff verweigert. */
export function ForbiddenPage(): ReactNode {
return (
<div className="flex min-h-[50vh] flex-col items-center justify-center gap-3 text-center">
<span className="text-5xl" aria-hidden="true">🔒</span>
<h1 className="text-2xl font-bold text-slate-900">Zugriff verweigert</h1>
<p className="max-w-sm text-sm text-slate-500">
Sie besitzen keine Berechtigung für diesen Bereich.
</p>
</div>
);
}
/** 404-Seite: Seite nicht gefunden. */
export function NotFoundPage(): ReactNode {
return (
<div className="flex min-h-[50vh] flex-col items-center justify-center gap-3 text-center">
<span className="text-5xl" aria-hidden="true">🧭</span>
<h1 className="text-2xl font-bold text-slate-900">Seite nicht gefunden</h1>
<p className="max-w-sm text-sm text-slate-500">
Die angeforderte Seite existiert nicht.
</p>
</div>
);
}

View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />