fix(users): repair create delete and duplicate email handling

This commit is contained in:
Schubert Ferenc 2026-07-11 17:14:41 +02:00
parent 155fdbb16a
commit 0bbcaba211
22 changed files with 773 additions and 107 deletions

View file

@ -24,7 +24,7 @@ type DashboardData = {
};
const primaryCards = [
{ label: "Neue Validierung", value: "Starten", href: "/validations/new", icon: Plus },
{ label: "Neue Validierung starten", value: "Schnellstart", href: "/validations/quick-start", icon: Plus },
{ label: "Validierungen", key: "validations", href: "/validations", icon: ClipboardList },
{ label: "Kunden", key: "customers", href: "/customers", icon: Users },
{ label: "Geraete", key: "devices", href: "/devices", icon: Stethoscope },

View file

@ -2,12 +2,13 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Check, KeyRound, RotateCcw, Search, UserPlus2 } from "lucide-react";
import { useMemo, useState } from "react";
import { Copy, RotateCcw, Search, Trash2, UserPlus2, X } from "lucide-react";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { useAuth } from "@/components/auth";
import { apiDelete, apiFetch, apiGet, Paginated, User } from "@/lib/api";
import { buildUsersQueryParams } from "@/lib/users-query";
const schema = z.object({
first_name: z.string().min(1, "Vorname erforderlich"),
@ -33,12 +34,14 @@ export default function UsersPage() {
const client = useQueryClient();
const [search, setSearch] = useState("");
const [role, setRole] = useState("");
const [active, setActive] = useState("");
const [active, setActive] = useState<"all" | "active" | "inactive">("all");
const [page, setPage] = useState(1);
const [sortBy, setSortBy] = useState("created_at");
const [sortOrder, setSortOrder] = useState<"asc" | "desc">("desc");
const [editing, setEditing] = useState<User | null>(null);
const [temporaryPassword, setTemporaryPassword] = useState("");
const [temporaryPasswordOpen, setTemporaryPasswordOpen] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<User | null>(null);
const [message, setMessage] = useState("");
const [error, setError] = useState("");
@ -55,26 +58,30 @@ export default function UsersPage() {
}
});
const queryKey = useMemo(
() => ["users", token, page, search, role, active, sortBy, sortOrder],
[token, page, search, role, active, sortBy, sortOrder]
);
const users = useQuery({
queryKey,
queryKey: ["users", page, 20, search, role, active, sortBy, sortOrder],
queryFn: () =>
apiGet<Paginated<User>>(
`/users?page=${page}&page_size=10&search=${encodeURIComponent(search)}&role=${encodeURIComponent(role)}&active=${encodeURIComponent(active)}&sort_by=${encodeURIComponent(sortBy)}&sort_order=${sortOrder}`,
`/users?${buildUsersQueryParams({
page,
pageSize: 20,
search,
role,
active: active === "all" ? undefined : active === "active",
sortBy,
sortOrder
}).toString()}`,
token ?? ""
),
enabled: Boolean(token)
});
const pages = users.data?.pages ?? Math.max(1, Math.ceil((users.data?.total ?? 0) / (users.data?.page_size ?? 20)));
const saveMutation = useMutation({
mutationFn: async (values: UserForm) => {
const payload = {
...values,
email: values.email.toLowerCase()
email: values.email.trim().toLowerCase()
};
if (editing) {
return apiFetch<User>(`/users/${editing.id}`, token ?? "", { method: "PUT", body: JSON.stringify(payload) });
@ -85,31 +92,38 @@ export default function UsersPage() {
});
},
onSuccess: async (created) => {
const isCreate = !editing;
if (!editing) {
setTemporaryPassword((created as { temporary_password?: string }).temporary_password ?? "");
} else {
setTemporaryPassword("");
}
setMessage(editing ? "Benutzer gespeichert" : "Benutzer angelegt");
setError("");
setEditing(null);
setTemporaryPasswordOpen(isCreate && Boolean((created as { temporary_password?: string }).temporary_password));
form.reset();
await client.invalidateQueries({ queryKey: ["users"] });
},
onError: (err: Error) => setError(err.message)
});
const deactivateMutation = useMutation({
mutationFn: (item: User) => apiFetch<User>(`/users/${item.id}/deactivate`, token ?? "", { method: "POST" }),
onSuccess: () => client.invalidateQueries({ queryKey: ["users"] })
});
const activateMutation = useMutation({
mutationFn: (item: User) => apiFetch<User>(`/users/${item.id}/activate`, token ?? "", { method: "POST" }),
onSuccess: () => client.invalidateQueries({ queryKey: ["users"] })
const deleteMutation = useMutation({
mutationFn: (item: User) => apiDelete(`/users/${item.id}`, token ?? ""),
onSuccess: async () => {
setMessage("Benutzer geloescht");
setError("");
setDeleteTarget(null);
await client.invalidateQueries({ queryKey: ["users"] });
},
onError: (err: Error) => setError(err.message)
});
const resetMutation = useMutation({
mutationFn: (item: User) => apiFetch<{ temporary_password: string }>(`/users/${item.id}/reset-password`, token ?? "", { method: "POST" }),
onSuccess: (result) => {
setTemporaryPassword(result.temporary_password);
setMessage("Temporäres Passwort erzeugt");
setTemporaryPasswordOpen(true);
}
});
@ -135,6 +149,11 @@ export default function UsersPage() {
});
}
function submit(values: UserForm) {
if (saveMutation.isPending) return;
saveMutation.mutate(values);
}
return (
<div className="space-y-6">
<header className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
@ -154,10 +173,10 @@ export default function UsersPage() {
<option value="">Alle Rollen</option>
{Object.entries(roleLabels).map(([value, label]) => <option key={value} value={value}>{label}</option>)}
</select>
<select value={active} onChange={(e) => setActive(e.target.value)} className="h-12 rounded-lg border border-border px-3">
<option value="">Alle Status</option>
<option value="true">Aktiv</option>
<option value="false">Inaktiv</option>
<select value={active} onChange={(e) => setActive(e.target.value as "all" | "active" | "inactive")} className="h-12 rounded-lg border border-border px-3">
<option value="all">Alle Status</option>
<option value="active">Aktiv</option>
<option value="inactive">Inaktiv</option>
</select>
<select value={sortBy} onChange={(e) => setSortBy(e.target.value)} className="h-12 rounded-lg border border-border px-3">
<option value="created_at">Erstellt am</option>
@ -173,6 +192,14 @@ export default function UsersPage() {
</div>
</section>
<section className="overflow-hidden rounded-lg border border-border bg-surface shadow-soft">
{users.isLoading ? (
<div className="flex items-center gap-3 px-5 py-6 text-sm text-text-light">
<span className="spinner" />
Benutzer werden geladen...
</div>
) : users.isError ? (
<div className="px-5 py-6 text-sm text-danger">Benutzer konnten nicht geladen werden.</div>
) : null}
<div className="overflow-x-auto">
<table className="min-w-full text-left text-sm">
<thead className="bg-background text-xs uppercase text-text-light">
@ -187,27 +214,31 @@ export default function UsersPage() {
</tr>
</thead>
<tbody className="divide-y divide-border">
{(users.data?.items ?? []).map((item) => (
<tr key={item.id}>
<td className="px-5 py-4">{item.first_name} {item.last_name}</td>
<td className="px-5 py-4">{item.email}</td>
<td className="px-5 py-4">{roleLabels[item.role]}</td>
<td className="px-5 py-4">{item.is_active ? "Aktiv" : "Inaktiv"}</td>
<td className="px-5 py-4">{item.last_login_at ?? "nicht erfasst"}</td>
<td className="px-5 py-4">{item.password_changed_at ?? "nicht erfasst"}</td>
<td className="px-5 py-4">
<div className="flex justify-end gap-2">
<button type="button" className="btn btn-secondary h-10 px-3" onClick={() => startEdit(item)}>Bearbeiten</button>
<button type="button" className="btn btn-secondary h-10 px-3" onClick={() => resetMutation.mutate(item)}>{resetMutation.isPending ? <span className="spinner" /> : <RotateCcw className="h-4 w-4" />} Temporäres Passwort setzen</button>
{item.is_active ? (
<button type="button" className="btn btn-danger h-10 px-3" onClick={() => deactivateMutation.mutate(item)}>Deaktivieren</button>
) : (
<button type="button" className="btn btn-success h-10 px-3" onClick={() => activateMutation.mutate(item)}><Check className="h-4 w-4" /> Reaktivieren</button>
)}
</div>
{!users.isLoading && !users.isError && (users.data?.items.length ?? 0) === 0 ? (
<tr>
<td className="px-5 py-6 text-sm text-text-light" colSpan={7}>
Keine Benutzer vorhanden.
</td>
</tr>
))}
) : (
(users.data?.items ?? []).map((item) => (
<tr key={item.id}>
<td className="px-5 py-4">{item.first_name} {item.last_name}</td>
<td className="px-5 py-4">{item.email}</td>
<td className="px-5 py-4">{roleLabels[item.role]}</td>
<td className="px-5 py-4">{item.is_active ? "Aktiv" : "Inaktiv"}</td>
<td className="px-5 py-4">{item.last_login_at ?? "nicht erfasst"}</td>
<td className="px-5 py-4">{item.password_changed_at ?? "nicht erfasst"}</td>
<td className="px-5 py-4">
<div className="flex justify-end gap-2">
<button type="button" className="btn btn-secondary h-10 px-3" onClick={() => startEdit(item)}>Bearbeiten</button>
<button type="button" className="btn btn-secondary h-10 px-3" onClick={() => resetMutation.mutate(item)}>{resetMutation.isPending ? <span className="spinner" /> : <RotateCcw className="h-4 w-4" />} Passwort zurücksetzen</button>
<button type="button" className="btn btn-danger h-10 px-3" onClick={() => setDeleteTarget(item)}><Trash2 className="h-4 w-4" /> Löschen</button>
</div>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
@ -216,14 +247,29 @@ export default function UsersPage() {
<span>{users.data?.total ?? 0} Datensätze</span>
<div className="flex items-center gap-3">
<button type="button" className="btn btn-secondary" disabled={page <= 1} onClick={() => setPage((v) => v - 1)}>Zurück</button>
<span>Seite {page}</span>
<button type="button" className="btn btn-secondary" disabled={(users.data?.items.length ?? 0) < 10} onClick={() => setPage((v) => v + 1)}>Weiter</button>
<span>Seite {page} von {pages}</span>
<button type="button" className="btn btn-secondary" disabled={(users.data?.items.length ?? 0) < 20 || page >= pages} onClick={() => setPage((v) => v + 1)}>Weiter</button>
</div>
</footer>
{temporaryPassword && <div className="fixed right-4 top-4 z-50 rounded-lg border border-success/30 bg-white p-4 shadow-soft"><p className="text-sm text-text-light">Temporäres Passwort</p><p className="mt-1 font-mono text-sm">{temporaryPassword}</p></div>}
{temporaryPasswordOpen && temporaryPassword && (
<div className="fixed inset-0 z-50 flex items-end bg-black/20 p-3 sm:items-center sm:justify-center">
<div className="w-full max-w-md rounded-lg border border-border bg-surface p-5 shadow-soft">
<div className="flex items-center justify-between gap-3">
<h2 className="text-lg font-semibold">Passwort zurückgesetzt</h2>
<button type="button" aria-label="Schliessen" onClick={() => setTemporaryPasswordOpen(false)} className="icon-btn"><X className="h-4 w-4" /></button>
</div>
<p className="mt-3 text-sm text-text-light">Dieses Passwort wird nur einmal angezeigt.</p>
<div className="mt-4 rounded-lg border border-border bg-background px-4 py-3 font-mono text-lg tracking-wide">{temporaryPassword}</div>
<div className="mt-4 flex flex-wrap gap-3">
<button type="button" className="btn btn-secondary h-11" onClick={async () => navigator.clipboard.writeText(temporaryPassword)}><Copy className="h-4 w-4" /> Kopieren</button>
<button type="button" className="btn btn-primary h-11" onClick={() => setTemporaryPasswordOpen(false)}>Schliessen</button>
</div>
</div>
</div>
)}
{message && <div className="fixed right-4 top-4 z-50 rounded-lg border border-success/30 bg-white p-4 text-sm text-success shadow-soft">{message}</div>}
{error && <div className="fixed right-4 top-4 z-50 rounded-lg border border-danger/30 bg-white p-4 text-sm text-danger shadow-soft">{error}</div>}
<form onSubmit={form.handleSubmit((values) => saveMutation.mutate(values))} className="rounded-lg border border-border bg-surface p-5 shadow-soft">
<form onSubmit={form.handleSubmit(submit)} className="rounded-lg border border-border bg-surface p-5 shadow-soft">
<h2 className="text-xl font-semibold">{editing ? "Benutzer bearbeiten" : "Benutzer anlegen"}</h2>
<div className="mt-4 grid gap-4 md:grid-cols-2">
{(["first_name", "last_name", "email", "temporary_password"] as const).map((field) => (
@ -243,9 +289,28 @@ export default function UsersPage() {
</div>
<div className="mt-5 flex justify-end gap-3">
<button type="button" className="btn btn-secondary" onClick={() => form.reset()}>Zurücksetzen</button>
<button type="submit" className="btn btn-primary">{saveMutation.isPending ? <span className="spinner" /> : null} Speichern</button>
<button type="submit" disabled={saveMutation.isPending} className="btn btn-primary">{saveMutation.isPending ? <span className="spinner" /> : null}{saveMutation.isPending ? "Speichern..." : "Speichern"}</button>
</div>
</form>
{deleteTarget && (
<div className="fixed inset-0 z-50 flex items-end bg-black/20 p-3 sm:items-center sm:justify-center">
<div className="w-full max-w-lg rounded-lg border border-border bg-surface p-5 shadow-soft">
<h2 className="text-xl font-semibold">Benutzer wirklich löschen?</h2>
<p className="mt-2 text-sm text-text-light">
{deleteTarget.first_name} {deleteTarget.last_name}
<br />
{deleteTarget.email}
</p>
<div className="mt-5 flex justify-end gap-3">
<button type="button" className="btn btn-secondary" onClick={() => setDeleteTarget(null)}>Abbrechen</button>
<button type="button" className="btn btn-danger" disabled={deleteMutation.isPending} onClick={() => deleteMutation.mutate(deleteTarget)}>
{deleteMutation.isPending ? <span className="spinner" /> : null}
{deleteMutation.isPending ? "Löschen..." : "Löschen"}
</button>
</div>
</div>
</div>
)}
</div>
);
}

View file

@ -0,0 +1,24 @@
"use client";
import Link from "next/link";
import ValidationEditor from "@/components/validations/validation-editor";
export default function QuickStartValidationPage() {
return (
<div className="space-y-6">
<header className="rounded-lg border border-border bg-surface p-6 shadow-soft">
<p className="text-sm font-medium uppercase tracking-wide text-primary">Schnellstart</p>
<h1 className="mt-2 text-3xl font-semibold text-text">Neue Validierung starten</h1>
<p className="mt-2 max-w-3xl text-text-light">
Wir arbeiten die Validierung direkt auf einer Seite durch und ziehen vorhandene Daten sofort nach.
</p>
<div className="mt-5">
<Link className="btn btn-secondary" href="/validations">
Zur Validierungsübersicht
</Link>
</div>
</header>
<ValidationEditor />
</div>
);
}

View file

@ -118,8 +118,22 @@ export type Paginated<T> = {
total: number;
page: number;
page_size: number;
pages?: number;
};
async function readErrorMessage(response: Response, fallback: string) {
const text = await response.text();
if (!text) {
return fallback;
}
try {
const parsed = JSON.parse(text) as { detail?: string; message?: string };
return parsed.detail ?? parsed.message ?? text;
} catch {
return text;
}
}
export async function login(email: string, password: string) {
const response = await fetch(`/api/login`, {
method: "POST",
@ -155,8 +169,7 @@ export async function apiSend<T>(path: string, token: string, method: "POST" | "
credentials: "include"
});
if (!response.ok) {
const detail = await response.text();
throw new Error(detail || `API request failed: ${response.status}`);
throw new Error(await readErrorMessage(response, `API request failed: ${response.status}`));
}
return response.json() as Promise<T>;
}
@ -167,7 +180,7 @@ export async function apiDelete(path: string, token: string): Promise<void> {
credentials: "include"
});
if (!response.ok) {
throw new Error(`API request failed: ${response.status}`);
throw new Error(await readErrorMessage(response, `API request failed: ${response.status}`));
}
}
@ -178,7 +191,7 @@ export async function apiFetch<T>(path: string, token: string, init?: RequestIni
credentials: "include"
});
if (!response.ok) {
throw new Error(await response.text());
throw new Error(await readErrorMessage(response, `API request failed: ${response.status}`));
}
return response.json() as Promise<T>;
}

View file

@ -0,0 +1,9 @@
export function buildUsersQueryParams(input: {
page: number;
pageSize: number;
search?: string;
role?: string;
active?: boolean | null;
sortBy: string;
sortOrder: "asc" | "desc";
}): URLSearchParams;

View file

@ -0,0 +1,18 @@
export function buildUsersQueryParams({ page, pageSize, search, role, active, sortBy, sortOrder }) {
const params = new URLSearchParams({
page: String(page),
page_size: String(pageSize),
sort_by: sortBy,
sort_order: sortOrder
});
if (search && search.trim()) {
params.set("search", search.trim());
}
if (role) {
params.set("role", role);
}
if (active === true || active === false) {
params.set("active", String(active));
}
return params;
}

View file

@ -7,7 +7,8 @@
"build": "next build",
"start": "next start",
"lint": "next lint",
"test:auth": "node --test tests/auth-cookie.test.mjs"
"test:auth": "node --test tests/auth-cookie.test.mjs",
"test:users-query": "node --test tests/users-query.test.mjs"
},
"dependencies": {
"@hookform/resolvers": "^3.10.0",

View file

@ -0,0 +1,42 @@
import assert from "node:assert/strict";
import test from "node:test";
import { buildUsersQueryParams } from "../lib/users-query.js";
test("builds query without empty optional filters", () => {
const params = buildUsersQueryParams({
page: 1,
pageSize: 20,
search: "",
role: "",
active: undefined,
sortBy: "created_at",
sortOrder: "desc"
});
assert.equal(params.toString(), "page=1&page_size=20&sort_by=created_at&sort_order=desc");
});
test("builds query with active filters", () => {
const activeParams = buildUsersQueryParams({
page: 1,
pageSize: 20,
search: " Admin ",
role: "admin",
active: true,
sortBy: "created_at",
sortOrder: "desc"
});
const inactiveParams = buildUsersQueryParams({
page: 1,
pageSize: 20,
search: "",
role: "",
active: false,
sortBy: "created_at",
sortOrder: "desc"
});
assert.equal(activeParams.toString(), "page=1&page_size=20&sort_by=created_at&sort_order=desc&search=Admin&role=admin&active=true");
assert.equal(inactiveParams.toString(), "page=1&page_size=20&sort_by=created_at&sort_order=desc&active=false");
});