feat: Phase 1 – Plattform-Grundgerüst (Docker, NestJS, React, Auth)
This commit is contained in:
118
apps/platform-frontend/src/components/layout/app-layout.tsx
Normal file
118
apps/platform-frontend/src/components/layout/app-layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
35
apps/platform-frontend/src/components/route-guards.tsx
Normal file
35
apps/platform-frontend/src/components/route-guards.tsx
Normal 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;
|
||||
}
|
||||
29
apps/platform-frontend/src/components/ui/badge.tsx
Normal file
29
apps/platform-frontend/src/components/ui/badge.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
58
apps/platform-frontend/src/components/ui/button.tsx
Normal file
58
apps/platform-frontend/src/components/ui/button.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
46
apps/platform-frontend/src/components/ui/card.tsx
Normal file
46
apps/platform-frontend/src/components/ui/card.tsx
Normal 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>;
|
||||
}
|
||||
51
apps/platform-frontend/src/components/ui/input.tsx
Normal file
51
apps/platform-frontend/src/components/ui/input.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
57
apps/platform-frontend/src/components/ui/states.tsx
Normal file
57
apps/platform-frontend/src/components/ui/states.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
28
apps/platform-frontend/src/features/admin/users-page.tsx
Normal file
28
apps/platform-frontend/src/features/admin/users-page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
92
apps/platform-frontend/src/features/auth/auth-context.tsx
Normal file
92
apps/platform-frontend/src/features/auth/auth-context.tsx
Normal 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;
|
||||
}
|
||||
104
apps/platform-frontend/src/features/auth/login-page.tsx
Normal file
104
apps/platform-frontend/src/features/auth/login-page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
110
apps/platform-frontend/src/features/dashboard/dashboard-page.tsx
Normal file
110
apps/platform-frontend/src/features/dashboard/dashboard-page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
28
apps/platform-frontend/src/index.css
Normal file
28
apps/platform-frontend/src/index.css
Normal 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;
|
||||
}
|
||||
}
|
||||
95
apps/platform-frontend/src/lib/api-client.ts
Normal file
95
apps/platform-frontend/src/lib/api-client.ts
Normal 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;
|
||||
}
|
||||
37
apps/platform-frontend/src/lib/schemas.ts
Normal file
37
apps/platform-frontend/src/lib/schemas.ts
Normal 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>;
|
||||
66
apps/platform-frontend/src/main.tsx
Normal file
66
apps/platform-frontend/src/main.tsx
Normal 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>,
|
||||
);
|
||||
27
apps/platform-frontend/src/pages/error-pages.tsx
Normal file
27
apps/platform-frontend/src/pages/error-pages.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
1
apps/platform-frontend/src/vite-env.d.ts
vendored
Normal file
1
apps/platform-frontend/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
Reference in New Issue
Block a user