525 lines
18 KiB
TypeScript
525 lines
18 KiB
TypeScript
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 { UserPermissionsModal } from './user-permissions-modal';
|
|
import {
|
|
createUserSchema,
|
|
resetPasswordSchema,
|
|
ROLE_NAMES,
|
|
type RoleName,
|
|
type User,
|
|
} from '../../lib/schemas';
|
|
|
|
/** 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 [permissionsUser, setPermissionsUser] = 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 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>
|
|
|
|
<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>
|
|
<Button size="sm" variant="ghost" onClick={() => setPermissionsUser(user)}>
|
|
Module
|
|
</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)} />
|
|
)}
|
|
{permissionsUser && (
|
|
<UserPermissionsModal user={permissionsUser} onClose={() => setPermissionsUser(null)} />
|
|
)}
|
|
</div>
|
|
);
|
|
}
|