feat: Phase 2 – Benutzerverwaltung (CRUD-API, Profil, UI)
This commit is contained in:
@@ -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 },
|
||||
];
|
||||
|
||||
60
apps/platform-frontend/src/components/ui/modal.tsx
Normal file
60
apps/platform-frontend/src/components/ui/modal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
50
apps/platform-frontend/src/components/ui/select.tsx
Normal file
50
apps/platform-frontend/src/components/ui/select.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
84
apps/platform-frontend/src/components/ui/toast.tsx
Normal file
84
apps/platform-frontend/src/components/ui/toast.tsx
Normal 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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
184
apps/platform-frontend/src/features/profile/profile-page.tsx
Normal file
184
apps/platform-frontend/src/features/profile/profile-page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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>;
|
||||
58
apps/platform-frontend/src/lib/users-api.ts
Normal file
58
apps/platform-frontend/src/lib/users-api.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user