feat: Phase 2 – Benutzerverwaltung (CRUD-API, Profil, UI)
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user