feat: Phase 2 – Benutzerverwaltung (CRUD-API, Profil, UI)

This commit is contained in:
MPM Dev
2026-10-06 14:58:36 +02:00
parent a7e1c421f2
commit e87dc1f836
25 changed files with 2056 additions and 75 deletions

View File

@@ -13,6 +13,7 @@ interface NavItem {
const NAV_ITEMS: NavItem[] = [
{ to: '/', label: 'Dashboard', icon: '⌂' },
{ to: '/profile', label: 'Mein Profil', icon: '👤' },
{ to: '/admin/users', label: 'Benutzer', icon: '👥', adminOnly: true },
{ to: '/admin/system', label: 'Systemstatus', icon: '⚙', adminOnly: true },
];

View File

@@ -0,0 +1,60 @@
import { type ReactNode, useEffect } from 'react';
export interface ModalProps {
open: boolean;
title: string;
description?: string;
onClose: () => void;
children: ReactNode;
footer?: ReactNode;
}
/** Zugängliches Modal (Design-System): Fokus-Falle, ESC schließt. */
export function Modal({
open,
title,
description,
onClose,
children,
footer,
}: ModalProps): ReactNode {
useEffect(() => {
if (!open) {
return undefined;
}
const handleKeyDown = (event: KeyboardEvent): void => {
if (event.key === 'Escape') {
onClose();
}
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [open, onClose]);
if (!open) {
return null;
}
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-slate-900/50 p-4"
onClick={onClose}
role="presentation"
>
<div
role="dialog"
aria-modal="true"
aria-label={title}
className="w-full max-w-md rounded-xl bg-white shadow-xl"
onClick={(event) => event.stopPropagation()}
>
<div className="border-b border-slate-200 px-6 py-4">
<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>
<div className="px-6 py-4">{children}</div>
{footer && <div className="border-t border-slate-200 px-6 py-4">{footer}</div>}
</div>
</div>
);
}

View File

@@ -0,0 +1,50 @@
import { type ReactNode, type SelectHTMLAttributes } from 'react';
export interface SelectProps extends SelectHTMLAttributes<HTMLSelectElement> {
label: string;
error?: string;
options: ReadonlyArray<{ value: string; label: string }>;
}
/** Select-Dropdown mit Label (Design-System). */
export function Select({
label,
error,
options,
className = '',
id,
...rest
}: SelectProps): ReactNode {
const selectId = id ?? `select-${label.toLowerCase().replace(/\s+/g, '-')}`;
return (
<div className="flex flex-col gap-1.5">
<label htmlFor={selectId} className="text-sm font-medium text-slate-700">
{label}
</label>
<select
id={selectId}
className={`h-10 rounded-lg border bg-white 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}
{...rest}
>
{options.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
{error && (
<p role="alert" className="text-xs text-red-600">
{error}
</p>
)}
</div>
);
}

View File

@@ -0,0 +1,84 @@
import {
type ReactNode,
createContext,
useCallback,
useContext,
useMemo,
useRef,
useState,
} from 'react';
/** Toast-Varianten. */
export type ToastVariant = 'success' | 'error';
interface ToastEntry {
readonly id: number;
readonly variant: ToastVariant;
readonly message: string;
}
interface ToastContextValue {
showToast: (variant: ToastVariant, message: string) => void;
}
const ToastContext = createContext<ToastContextValue | null>(null);
const VARIANT_CLASSES: Record<ToastVariant, string> = {
success: 'bg-emerald-600 text-white',
error: 'bg-red-600 text-white',
};
const VARIANT_ICONS: Record<ToastVariant, string> = {
success: '✓',
error: '✕',
};
/**
* Toast-Benachrichtigungen (Design-System).
* Meldungen verschwinden automatisch nach 4 Sekunden.
*/
export function ToastProvider({ children }: { children: ReactNode }): ReactNode {
const [toasts, setToasts] = useState<ToastEntry[]>([]);
const nextId = useRef(1);
const showToast = useCallback((variant: ToastVariant, message: string) => {
const id = nextId.current;
nextId.current += 1;
setToasts((current) => [...current, { id, variant, message }]);
window.setTimeout(() => {
setToasts((current) => current.filter((toast) => toast.id !== id));
}, 4_000);
}, []);
const value = useMemo<ToastContextValue>(() => ({ showToast }), [showToast]);
return (
<ToastContext.Provider value={value}>
{children}
<div
className="pointer-events-none fixed bottom-4 right-4 z-50 flex flex-col gap-2"
aria-live="polite"
>
{toasts.map((toast) => (
<div
key={toast.id}
className={`pointer-events-auto flex items-center gap-2 rounded-lg px-4 py-3 text-sm font-medium shadow-lg
${VARIANT_CLASSES[toast.variant]}`}
>
<span aria-hidden="true">{VARIANT_ICONS[toast.variant]}</span>
{toast.message}
</div>
))}
</div>
</ToastContext.Provider>
);
}
/** Zeigt eine Toast-Meldung an (wirft außerhalb des Providers). */
export function useToast(): ToastContextValue {
const context = useContext(ToastContext);
if (!context) {
throw new Error('useToast muss innerhalb von ToastProvider verwendet werden');
}
return context;
}

View File

@@ -1,28 +1,516 @@
import { type ReactNode } from 'react';
import { Card, CardBody, CardHeader } from '../../components/ui/card';
import { EmptyState } from '../../components/ui/states';
import { type FormEvent, type ReactNode, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useAuth } from '../auth/auth-context';
import { Button } from '../../components/ui/button';
import { Input } from '../../components/ui/input';
import { Modal } from '../../components/ui/modal';
import { Select } from '../../components/ui/select';
import { useToast } from '../../components/ui/toast';
import { ApiError } from '../../lib/api-client';
import {
createUser,
deleteUser,
fetchUsers,
resetUserPassword,
updateUser,
} from '../../lib/users-api';
import {
createUserSchema,
resetPasswordSchema,
ROLE_NAMES,
type RoleName,
type User,
} from '../../lib/schemas';
/** Platzhalter für die Benutzerverwaltung (Phase 2). */
/** Formular-Fehler aus Zod-Issues (nur erste Meldung pro Feld). */
function fieldErrorsFromZod(
issues: Array<{ path: (string | number | symbol)[]; message: string }>,
): Record<string, string> {
const errors: Record<string, string> = {};
for (const issue of issues) {
const key = String(issue.path[0] ?? 'form');
if (!errors[key]) {
errors[key] = issue.message;
}
}
return errors;
}
/** API-Fehler-Details in einfache Feldmeldungen umwandeln. */
function fieldErrorsFromApi(details: Record<string, string | string[]> | undefined): Record<string, string> {
if (!details) {
return {};
}
const errors: Record<string, string> = {};
for (const [key, value] of Object.entries(details)) {
errors[key] = Array.isArray(value) ? value[0] : value;
}
return errors;
}
/** Datumsformat für Tabellenanzeigen. */
function formatDate(isoDate: string | null): string {
if (!isoDate) {
return '–';
}
return new Date(isoDate).toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
}
/** Formular: Neuen Benutzer anlegen. */
function CreateUserModal({
open,
onClose,
onCreated,
}: {
open: boolean;
onClose: () => void;
onCreated: () => void;
}): ReactNode {
const { showToast } = useToast();
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
const [formError, setFormError] = useState<string | null>(null);
const createMutation = useMutation({
mutationFn: createUser,
onSuccess: (user) => {
showToast('success', `Benutzer "${user.username}" wurde angelegt`);
onCreated();
onClose();
},
onError: (error) => {
if (error instanceof ApiError) {
setFormError(error.message);
setFieldErrors(fieldErrorsFromApi(error.details));
} else {
setFormError('Benutzer konnte nicht angelegt werden');
}
},
});
function handleSubmit(event: FormEvent<HTMLFormElement>): void {
event.preventDefault();
setFieldErrors({});
setFormError(null);
const formData = new FormData(event.currentTarget);
const parsed = createUserSchema.safeParse({
username: formData.get('username'),
email: formData.get('email'),
displayName: formData.get('displayName'),
password: formData.get('password'),
role: formData.get('role'),
});
if (!parsed.success) {
setFieldErrors(fieldErrorsFromZod(parsed.error.issues));
return;
}
createMutation.mutate(parsed.data);
}
return (
<Modal
open={open}
title="Benutzer anlegen"
description="Neuen Benutzer für die Plattform registrieren"
onClose={onClose}
>
<form onSubmit={handleSubmit} className="space-y-4" noValidate>
<Input label="Benutzername" name="username" error={fieldErrors.username} autoFocus />
<Input label="Anzeigename" name="displayName" error={fieldErrors.displayName} />
<Input label="E-Mail-Adresse" name="email" type="email" error={fieldErrors.email} />
<Input
label="Passwort"
name="password"
type="password"
hint="Mindestens 10 Zeichen"
error={fieldErrors.password}
/>
<Select
label="Rolle"
name="role"
defaultValue="USER"
error={fieldErrors.role}
options={ROLE_NAMES.map((role) => ({
value: role,
label: role === 'ADMIN' ? 'Administrator' : 'Benutzer',
}))}
/>
{formError && (
<p role="alert" className="rounded-lg bg-red-50 px-3 py-2 text-sm text-red-700">
{formError}
</p>
)}
<div className="flex justify-end gap-2 pt-2">
<Button type="button" variant="secondary" onClick={onClose}>
Abbrechen
</Button>
<Button type="submit" loading={createMutation.isPending}>
Anlegen
</Button>
</div>
</form>
</Modal>
);
}
/** Formular: Benutzer bearbeiten. */
function EditUserModal({ user, onClose }: { user: User; onClose: () => void }): ReactNode {
const { showToast } = useToast();
const queryClient = useQueryClient();
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
const [formError, setFormError] = useState<string | null>(null);
const updateMutation = useMutation({
mutationFn: (input: { email: string; displayName: string; role: RoleName }) =>
updateUser(user.id, input),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ['users'] });
showToast('success', 'Benutzer wurde gespeichert');
onClose();
},
onError: (error) => {
if (error instanceof ApiError) {
setFormError(error.message);
setFieldErrors(fieldErrorsFromApi(error.details));
} else {
setFormError('Benutzer konnte nicht gespeichert werden');
}
},
});
function handleSubmit(event: FormEvent<HTMLFormElement>): void {
event.preventDefault();
setFieldErrors({});
setFormError(null);
const formData = new FormData(event.currentTarget);
const role = String(formData.get('role'));
updateMutation.mutate({
email: String(formData.get('email')),
displayName: String(formData.get('displayName')),
role: role === 'ADMIN' ? 'ADMIN' : 'USER',
});
}
return (
<Modal open title="Benutzer bearbeiten" description={`@${user.username}`} onClose={onClose}>
<form onSubmit={handleSubmit} className="space-y-4" noValidate>
<Input
label="Anzeigename"
name="displayName"
defaultValue={user.displayName}
error={fieldErrors.displayName}
autoFocus
/>
<Input
label="E-Mail-Adresse"
name="email"
type="email"
defaultValue={user.email}
error={fieldErrors.email}
/>
<Select
label="Rolle"
name="role"
defaultValue={user.role}
error={fieldErrors.role}
options={ROLE_NAMES.map((role) => ({
value: role,
label: role === 'ADMIN' ? 'Administrator' : 'Benutzer',
}))}
/>
{formError && (
<p role="alert" className="rounded-lg bg-red-50 px-3 py-2 text-sm text-red-700">
{formError}
</p>
)}
<div className="flex justify-end gap-2 pt-2">
<Button type="button" variant="secondary" onClick={onClose}>
Abbrechen
</Button>
<Button type="submit" loading={updateMutation.isPending}>
Speichern
</Button>
</div>
</form>
</Modal>
);
}
/** Formular: Passwort zurücksetzen. */
function ResetPasswordModal({ user, onClose }: { user: User; onClose: () => void }): ReactNode {
const { showToast } = useToast();
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
const [formError, setFormError] = useState<string | null>(null);
const resetMutation = useMutation({
mutationFn: (input: { newPassword: string }) => resetUserPassword(user.id, input),
onSuccess: () => {
showToast('success', `Passwort für "${user.username}" wurde zurückgesetzt`);
onClose();
},
onError: (error) => {
if (error instanceof ApiError) {
setFormError(error.message);
setFieldErrors(fieldErrorsFromApi(error.details));
} else {
setFormError('Passwort konnte nicht zurückgesetzt werden');
}
},
});
function handleSubmit(event: FormEvent<HTMLFormElement>): void {
event.preventDefault();
setFieldErrors({});
setFormError(null);
const formData = new FormData(event.currentTarget);
const parsed = resetPasswordSchema.safeParse({
newPassword: formData.get('newPassword'),
});
if (!parsed.success) {
setFieldErrors(fieldErrorsFromZod(parsed.error.issues));
return;
}
resetMutation.mutate(parsed.data);
}
return (
<Modal
open
title="Passwort zurücksetzen"
description={`Neues Passwort für @${user.username} festlegen. Der Benutzer wird von allen Sitzungen abgemeldet.`}
onClose={onClose}
>
<form onSubmit={handleSubmit} className="space-y-4" noValidate>
<Input
label="Neues Passwort"
name="newPassword"
type="password"
hint="Mindestens 10 Zeichen"
error={fieldErrors.newPassword}
autoFocus
/>
{formError && (
<p role="alert" className="rounded-lg bg-red-50 px-3 py-2 text-sm text-red-700">
{formError}
</p>
)}
<div className="flex justify-end gap-2 pt-2">
<Button type="button" variant="secondary" onClick={onClose}>
Abbrechen
</Button>
<Button type="submit" variant="danger" loading={resetMutation.isPending}>
Zurücksetzen
</Button>
</div>
</form>
</Modal>
);
}
/** Bestätigungsdialog: Benutzer löschen. */
function DeleteUserModal({ user, onClose }: { user: User; onClose: () => void }): ReactNode {
const { showToast } = useToast();
const queryClient = useQueryClient();
const [formError, setFormError] = useState<string | null>(null);
const deleteMutation = useMutation({
mutationFn: () => deleteUser(user.id),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ['users'] });
showToast('success', `Benutzer "${user.username}" wurde gelöscht`);
onClose();
},
onError: (error) => {
setFormError(error instanceof ApiError ? error.message : 'Löschen fehlgeschlagen');
},
});
return (
<Modal
open
title="Benutzer löschen"
description={`Möchten Sie "${user.displayName}" (@${user.username}) wirklich löschen? Dieser Vorgang kann nicht rückgängig gemacht werden.`}
onClose={onClose}
>
{formError && (
<p role="alert" className="mb-4 rounded-lg bg-red-50 px-3 py-2 text-sm text-red-700">
{formError}
</p>
)}
<div className="flex justify-end gap-2">
<Button variant="secondary" onClick={onClose}>
Abbrechen
</Button>
<Button
variant="danger"
loading={deleteMutation.isPending}
onClick={() => deleteMutation.mutate()}
>
Endgültig lÃschen
</Button>
</div>
</Modal>
);
}
/** Benutzerverwaltung (nur Admin): Liste, Anlegen, Bearbeiten, Passwort, Löschen. */
export function UsersPage(): ReactNode {
const { user: currentUser } = useAuth();
const queryClient = useQueryClient();
const { showToast } = useToast();
const [createOpen, setCreateOpen] = useState(false);
const [editUser, setEditUser] = useState<User | null>(null);
const [resetPasswordUser, setResetPasswordUser] = useState<User | null>(null);
const [deleteTarget, setDeleteTarget] = useState<User | null>(null);
const usersQuery = useQuery({
queryKey: ['users'],
queryFn: fetchUsers,
});
const toggleActiveMutation = useMutation({
mutationFn: (target: User) => updateUser(target.id, { isActive: !target.isActive }),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ['users'] });
showToast('success', 'Status wurde aktualisiert');
},
onError: (error) => {
showToast('error', error instanceof ApiError ? error.message : 'Aktualisierung fehlgeschlagen');
},
});
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 className="flex flex-wrap items-center justify-between gap-3">
<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>
<Button onClick={() => setCreateOpen(true)}>+ Benutzer anlegen</Button>
</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 className="overflow-x-auto rounded-xl border border-slate-200 bg-white shadow-sm">
<table className="w-full min-w-[720px] text-sm">
<thead>
<tr className="border-b border-slate-200 bg-slate-50 text-left text-xs uppercase tracking-wide text-slate-500">
<th className="px-4 py-3 font-semibold">Benutzer</th>
<th className="px-4 py-3 font-semibold">Rolle</th>
<th className="px-4 py-3 font-semibold">Status</th>
<th className="px-4 py-3 font-semibold">Letzter Login</th>
<th className="px-4 py-3 font-semibold">Aktionen</th>
</tr>
</thead>
<tbody>
{usersQuery.isLoading && (
<tr>
<td colSpan={5} className="px-4 py-8 text-center text-slate-500">
Benutzer werden geladen…
</td>
</tr>
)}
{usersQuery.isError && (
<tr>
<td colSpan={5} className="px-4 py-8 text-center text-red-600">
Benutzer konnten nicht geladen werden.
</td>
</tr>
)}
{usersQuery.data?.map((user) => (
<tr key={user.id} className="border-b border-slate-100 last:border-0">
<td className="px-4 py-3">
<div className="font-medium text-slate-900">{user.displayName}</div>
<div className="text-xs text-slate-500">
@{user.username} · {user.email}
</div>
</td>
<td className="px-4 py-3">
<span
className={`inline-flex rounded-full px-2.5 py-0.5 text-xs font-medium ring-1 ring-inset
${
user.role === 'ADMIN'
? 'bg-brand-50 text-brand-700 ring-brand-600/20'
: 'bg-slate-100 text-slate-600 ring-slate-500/20'
}`}
>
{user.role === 'ADMIN' ? 'Administrator' : 'Benutzer'}
</span>
</td>
<td className="px-4 py-3">
<span
className={`inline-flex rounded-full px-2.5 py-0.5 text-xs font-medium ring-1 ring-inset
${
user.isActive
? 'bg-emerald-50 text-emerald-700 ring-emerald-600/20'
: 'bg-red-50 text-red-700 ring-red-600/20'
}`}
>
{user.isActive ? 'Aktiv' : 'Deaktiviert'}
</span>
</td>
<td className="px-4 py-3 text-slate-500">{formatDate(user.lastLoginAt)}</td>
<td className="px-4 py-3">
<div className="flex flex-wrap gap-1">
<Button size="sm" variant="ghost" onClick={() => setEditUser(user)}>
Bearbeiten
</Button>
<Button size="sm" variant="ghost" onClick={() => setResetPasswordUser(user)}>
Passwort
</Button>
{user.id !== currentUser?.id && (
<>
<Button
size="sm"
variant="ghost"
loading={
toggleActiveMutation.isPending &&
toggleActiveMutation.variables?.id === user.id
}
onClick={() => toggleActiveMutation.mutate(user)}
>
{user.isActive ? 'Deaktivieren' : 'Aktivieren'}
</Button>
<Button size="sm" variant="ghost" onClick={() => setDeleteTarget(user)}>
LÃschen
</Button>
</>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
{usersQuery.data?.length === 0 && (
<p className="px-4 py-8 text-center text-slate-500">
Noch keine Benutzer vorhanden.
</p>
)}
</div>
{createOpen && (
<CreateUserModal
open={createOpen}
onClose={() => setCreateOpen(false)}
onCreated={() => void queryClient.invalidateQueries({ queryKey: ['users'] })}
/>
)}
{editUser && <EditUserModal user={editUser} onClose={() => setEditUser(null)} />}
{resetPasswordUser && (
<ResetPasswordModal user={resetPasswordUser} onClose={() => setResetPasswordUser(null)} />
)}
{deleteTarget && (
<DeleteUserModal user={deleteTarget} onClose={() => setDeleteTarget(null)} />
)}
</div>
);
}
}

View File

@@ -0,0 +1,184 @@
import { type FormEvent, type ReactNode, useState } from 'react';
import { useMutation, useQuery } from '@tanstack/react-query';
import { Button } from '../../components/ui/button';
import { Card, CardBody, CardHeader } from '../../components/ui/card';
import { Input } from '../../components/ui/input';
import { useToast } from '../../components/ui/toast';
import { ApiError } from '../../lib/api-client';
import { changePassword, fetchProfile } from '../../lib/users-api';
import { changePasswordSchema } from '../../lib/schemas';
/** Datumsformat für Profilanzeigen. */
function formatDate(isoDate: string | null): string {
if (!isoDate) {
return '–';
}
return new Date(isoDate).toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
}
/** Profil des angemeldeten Benutzers mit Passwortänderung. */
export function ProfilePage(): ReactNode {
const { showToast } = useToast();
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
const [formError, setFormError] = useState<string | null>(null);
const profileQuery = useQuery({
queryKey: ['profile'],
queryFn: fetchProfile,
});
const changePasswordMutation = useMutation({
mutationFn: changePassword,
onSuccess: () => {
showToast('success', 'Passwort wurde geändert');
setFieldErrors({});
setFormError(null);
},
onError: (error) => {
if (error instanceof ApiError) {
setFormError(error.message);
const details = error.details ?? {};
const errors: Record<string, string> = {};
for (const [key, value] of Object.entries(details)) {
errors[key] = Array.isArray(value) ? value[0] : value;
}
setFieldErrors(errors);
} else {
setFormError('Passwort konnte nicht geändert werden');
}
},
});
function handleSubmit(event: FormEvent<HTMLFormElement>): void {
event.preventDefault();
setFieldErrors({});
setFormError(null);
const formData = new FormData(event.currentTarget);
const parsed = changePasswordSchema.safeParse({
currentPassword: formData.get('currentPassword'),
newPassword: formData.get('newPassword'),
confirmPassword: formData.get('confirmPassword'),
});
if (!parsed.success) {
const errors: Record<string, string> = {};
for (const issue of parsed.error.issues) {
const key = String(issue.path[0] ?? 'form');
if (!errors[key]) {
errors[key] = issue.message;
}
}
setFieldErrors(errors);
return;
}
changePasswordMutation.mutate(parsed.data);
}
return (
<div className="mx-auto max-w-2xl space-y-6">
<div>
<h1 className="text-2xl font-bold text-slate-900">Mein Profil</h1>
<p className="mt-1 text-sm text-slate-500">
Ihre persönlichen Daten und Passwort-Einstellungen.
</p>
</div>
<Card>
<CardHeader title="Persönliche Daten" />
<CardBody>
{profileQuery.isLoading && <p className="text-sm text-slate-500">Wird geladen…</p>}
{profileQuery.isError && (
<p className="text-sm text-red-600">Profil konnte nicht geladen werden.</p>
)}
{profileQuery.data && (
<dl className="grid gap-4 sm:grid-cols-2">
<div>
<dt className="text-xs uppercase tracking-wide text-slate-500">Anzeigename</dt>
<dd className="mt-1 text-sm font-medium text-slate-900">
{profileQuery.data.displayName}
</dd>
</div>
<div>
<dt className="text-xs uppercase tracking-wide text-slate-500">Benutzername</dt>
<dd className="mt-1 text-sm font-medium text-slate-900">
@{profileQuery.data.username}
</dd>
</div>
<div>
<dt className="text-xs uppercase tracking-wide text-slate-500">E-Mail</dt>
<dd className="mt-1 text-sm font-medium text-slate-900">
{profileQuery.data.email}
</dd>
</div>
<div>
<dt className="text-xs uppercase tracking-wide text-slate-500">Rolle</dt>
<dd className="mt-1 text-sm font-medium text-slate-900">
{profileQuery.data.role === 'ADMIN' ? 'Administrator' : 'Benutzer'}
</dd>
</div>
<div>
<dt className="text-xs uppercase tracking-wide text-slate-500">Letzter Login</dt>
<dd className="mt-1 text-sm font-medium text-slate-900">
{formatDate(profileQuery.data.lastLoginAt)}
</dd>
</div>
<div>
<dt className="text-xs uppercase tracking-wide text-slate-500">Mitglied seit</dt>
<dd className="mt-1 text-sm font-medium text-slate-900">
{formatDate(profileQuery.data.createdAt)}
</dd>
</div>
</dl>
)}
</CardBody>
</Card>
<Card>
<CardHeader
title="Passwort ändern"
description="Nach der Änderung werden alle anderen Sitzungen abgemeldet."
/>
<CardBody>
<form onSubmit={handleSubmit} className="max-w-sm space-y-4" noValidate>
<Input
label="Aktuelles Passwort"
name="currentPassword"
type="password"
autoComplete="current-password"
error={fieldErrors.currentPassword}
/>
<Input
label="Neues Passwort"
name="newPassword"
type="password"
autoComplete="new-password"
hint="Mindestens 10 Zeichen"
error={fieldErrors.newPassword}
/>
<Input
label="Neues Passwort bestätigen"
name="confirmPassword"
type="password"
autoComplete="new-password"
error={fieldErrors.confirmPassword}
/>
{formError && (
<p role="alert" className="rounded-lg bg-red-50 px-3 py-2 text-sm text-red-700">
{formError}
</p>
)}
<Button type="submit" loading={changePasswordMutation.isPending}>
Passwort ändern
</Button>
</form>
</CardBody>
</Card>
</div>
);
}

View File

@@ -10,7 +10,7 @@ export class ApiError extends Error {
constructor(
public readonly status: number,
message: string,
public readonly details?: Record<string, string[]>,
public readonly details?: Record<string, string | string[]>,
) {
super(message);
this.name = 'ApiError';

View File

@@ -34,4 +34,76 @@ export const healthSchema = z.object({
}),
}),
});
export type Health = z.infer<typeof healthSchema>;
export type Health = z.infer<typeof healthSchema>;
/** Benutzer-Datensatz der Admin-API (/api/v1/users). */
export const userSchema = z.object({
id: z.string(),
username: z.string(),
email: z.string(),
displayName: z.string(),
role: z.enum(ROLE_NAMES),
isActive: z.boolean(),
lastLoginAt: z.string().nullable(),
createdAt: z.string(),
});
export type User = z.infer<typeof userSchema>;
/** Profil des angemeldeten Benutzers (/api/v1/profile). */
export const profileSchema = z.object({
id: z.string(),
username: z.string(),
email: z.string(),
displayName: z.string(),
role: z.enum(ROLE_NAMES),
lastLoginAt: z.string().nullable(),
createdAt: z.string(),
});
export type Profile = z.infer<typeof profileSchema>;
/** Benutzer anlegen (Client-Validierung ist UX; Server validiert erneut). */
export const createUserSchema = z.object({
username: z
.string()
.trim()
.min(3, 'Benutzername muss mindestens 3 Zeichen lang sein')
.max(100)
.regex(/^[A-Za-z0-9._-]+$/, 'Nur Buchstaben, Zahlen sowie . _ - erlaubt'),
email: z.string().trim().email('Ungültige E-Mail-Adresse'),
displayName: z.string().trim().min(1, 'Anzeigename ist erforderlich'),
password: z.string().min(10, 'Passwort muss mindestens 10 Zeichen lang sein'),
role: z.enum(ROLE_NAMES),
});
export type CreateUserDto = z.infer<typeof createUserSchema>;
/** Benutzer bearbeiten. */
export const updateUserSchema = z
.object({
email: z.string().trim().email('Ungültige E-Mail-Adresse').optional(),
displayName: z.string().trim().min(1, 'Anzeigename ist erforderlich').optional(),
role: z.enum(ROLE_NAMES).optional(),
isActive: z.boolean().optional(),
})
.refine((data) => Object.values(data).some((value) => value !== undefined), {
message: 'Mindestens ein Feld ist erforderlich',
});
export type UpdateUserDto = z.infer<typeof updateUserSchema>;
/** Passwort zurücksetzen (Admin). */
export const resetPasswordSchema = z.object({
newPassword: z.string().min(10, 'Passwort muss mindestens 10 Zeichen lang sein'),
});
export type ResetPasswordDto = z.infer<typeof resetPasswordSchema>;
/** Eigenes Passwort ändern. */
export const changePasswordSchema = z
.object({
currentPassword: z.string().min(1, 'Aktuelles Passwort ist erforderlich'),
newPassword: z.string().min(10, 'Passwort muss mindestens 10 Zeichen lang sein'),
confirmPassword: z.string().min(1, 'Passwortbestätigung ist erforderlich'),
})
.refine((data) => data.newPassword === data.confirmPassword, {
message: 'Passwörter stimmen nicht überein',
path: ['confirmPassword'],
});
export type ChangePasswordDto = z.infer<typeof changePasswordSchema>;

View File

@@ -0,0 +1,58 @@
import { apiRequest } from './api-client';
import {
profileSchema,
userSchema,
type ChangePasswordDto,
type CreateUserDto,
type Profile,
type ResetPasswordDto,
type UpdateUserDto,
type User,
} from './schemas';
/** Typsichere API-Funktionen für die Benutzerverwaltung. */
export async function fetchUsers(): Promise<User[]> {
const response = await apiRequest<{ users: unknown[] }>('/api/v1/users');
return response.users.map((user) => userSchema.parse(user));
}
export async function createUser(input: CreateUserDto): Promise<User> {
const response = await apiRequest<{ user: unknown }>('/api/v1/users', {
method: 'POST',
body: input,
});
return userSchema.parse(response.user);
}
export async function updateUser(id: string, input: UpdateUserDto): Promise<User> {
const response = await apiRequest<{ user: unknown }>(`/api/v1/users/${id}`, {
method: 'PATCH',
body: input,
});
return userSchema.parse(response.user);
}
export async function resetUserPassword(id: string, input: ResetPasswordDto): Promise<void> {
await apiRequest(`/api/v1/users/${id}/password`, { method: 'PATCH', body: input });
}
export async function deleteUser(id: string): Promise<void> {
await apiRequest(`/api/v1/users/${id}`, { method: 'DELETE' });
}
export async function fetchProfile(): Promise<Profile> {
const response = await apiRequest<{ profile: unknown }>('/api/v1/profile');
return profileSchema.parse(response.profile);
}
export async function changePassword(input: ChangePasswordDto): Promise<void> {
// confirmPassword ist nur eine Client-Prüfung und wird nicht gesendet.
await apiRequest('/api/v1/profile/password', {
method: 'PATCH',
body: {
currentPassword: input.currentPassword,
newPassword: input.newPassword,
},
});
}

View File

@@ -4,11 +4,13 @@ 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 { ToastProvider } from './components/ui/toast';
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 { ProfilePage } from './features/profile/profile-page';
import { ForbiddenPage, NotFoundPage } from './pages/error-pages';
import './index.css';
@@ -31,6 +33,7 @@ function AppRoutes(): ReactNode {
<Route element={<RequireAuth><AppLayout /></RequireAuth>}>
<Route path="/" element={<DashboardPage />} />
<Route path="/profile" element={<ProfilePage />} />
<Route
path="/admin/users"
element={<RequireAdmin><UsersPage /></RequireAdmin>}
@@ -58,7 +61,9 @@ createRoot(rootElement).render(
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<AuthProvider>
<AppRoutes />
<ToastProvider>
<AppRoutes />
</ToastProvider>
</AuthProvider>
</BrowserRouter>
</QueryClientProvider>