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

@@ -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>
);
}
}