feat(ui): users and audit log as readable cards with names and retry

- Users: one card per person on every screen size, status and MFA as
  icon plus word, server reasons (e.g. "more rights than you") shown.
- Audit log: names instead of user ids, area as a choice instead of a
  free-text type, optional record id, filter reset, cards instead of a
  5-column table, empty and error states with a next step.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-01 12:13:40 +02:00
1 parent bd2070436c
commit 832997f885
4 files changed
+238 -154

No files matched your search

@@ -8,6 +8,9 @@ import { fetchAuditLog } from "@/lib/audit-log/api";
vi.mock("@/lib/audit-log/api", () => ({
fetchAuditLog: vi.fn(),
}));
vi.mock("@/lib/users/api", () => ({
fetchUsers: vi.fn().mockResolvedValue([{ id: "user-1", name: "Imkerin Anna" }]),
}));
function renderWithClient(ui: ReactElement) {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
@@ -35,9 +38,11 @@ describe("AuditLogPage", () => {
renderWithClient(<AuditLogPage />);
expect(await screen.findByText("Bienenstand")).toBeInTheDocument();
expect(screen.getByText("apiary-1")).toBeInTheDocument();
expect(await screen.findByText(/Bienenstand/)).toBeInTheDocument();
expect(screen.getByText("Erstellt")).toBeInTheDocument();
// People by name, not by id.
expect(await screen.findByText("Imkerin Anna")).toBeInTheDocument();
expect(screen.queryByText("user-1")).not.toBeInTheDocument();
});
it("shows an error message when loading the audit log fails", async () => {
@@ -4,36 +4,63 @@ import { useState } from "react";
import { History } from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import { fetchAuditLog } from "@/lib/audit-log/api";
import { auditActionLabel, auditEntityTypeLabel } from "@/lib/audit-log/labels";
import {
AUDIT_ENTITY_TYPE_OPTIONS,
auditActionLabel,
auditEntityTypeLabel,
} from "@/lib/audit-log/labels";
import { fetchUsers } from "@/lib/users/api";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { PageHeader } from "@/components/page-header";
import { EmptyState } from "@/components/empty-state";
import { QueryState } from "@/components/query-state";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
const ALL = "ALL";
export default function AuditLogPage() {
const [entityType, setEntityType] = useState("");
const [entityId, setEntityId] = useState("");
const [appliedFilter, setAppliedFilter] = useState({ entityType: "", entityId: "" });
const { data: entries, isLoading, isError } = useQuery({
const entriesQuery = useQuery({
queryKey: ["audit-log", appliedFilter],
queryFn: () =>
fetchAuditLog({
entityType: appliedFilter.entityType || undefined,
entityId: appliedFilter.entityId || undefined,
entityId: appliedFilter.entityId.trim() || undefined,
}),
});
// Names instead of ids; the log stays readable even if this fails.
const { data: users } = useQuery({ queryKey: ["users"], queryFn: fetchUsers });
const userName = (userId: string | null) =>
userId === null
? "System"
: (users?.find((user) => user.id === userId)?.name ?? "Unbekannter Benutzer");
const isFiltered = Boolean(appliedFilter.entityType || appliedFilter.entityId);
function resetFilter() {
setEntityType("");
setEntityId("");
setAppliedFilter({ entityType: "", entityId: "" });
}
return (
<main className="flex flex-col gap-4 p-4 sm:p-6">
<PageHeader icon={History} title="Audit-Log" description="Nachvollziehbare Änderungen" gradient="var(--gradient-sage)" />
<PageHeader
icon={History}
title="Audit-Log"
description="Wer hat wann was geändert – neueste Einträge zuerst."
gradient="var(--gradient-sage)"
/>
<form
className="flex flex-wrap items-end gap-3"
@@ -42,66 +69,92 @@ export default function AuditLogPage() {
setAppliedFilter({ entityType, entityId });
}}
>
<div className="flex flex-col gap-1">
<label className="text-sm text-muted-foreground" htmlFor="entityType">
Entitätstyp
<div className="flex w-full flex-col gap-1 sm:w-56">
<label className="text-sm font-medium" htmlFor="audit-entity-type">
Bereich
</label>
<Input
id="entityType"
placeholder="z. B. Colony"
value={entityType}
onChange={(event) => setEntityType(event.target.value)}
className="w-48"
/>
<Select
value={entityType || ALL}
onValueChange={(value) => setEntityType(value === ALL ? "" : (value ?? ""))}
>
<SelectTrigger id="audit-entity-type" className="w-full">
<SelectValue>
{entityType ? auditEntityTypeLabel(entityType) : "Alle Bereiche"}
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value={ALL}>Alle Bereiche</SelectItem>
{AUDIT_ENTITY_TYPE_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-1">
<label className="text-sm text-muted-foreground" htmlFor="entityId">
Entitäts-ID
<div className="flex w-full flex-col gap-1 sm:w-72">
<label className="text-sm font-medium" htmlFor="audit-entity-id">
Bestimmter Datensatz{" "}
<span className="font-normal text-muted-foreground">optional</span>
</label>
<Input
id="entityId"
placeholder="UUID"
id="audit-entity-id"
placeholder="ID aus der Adresszeile"
value={entityId}
onChange={(event) => setEntityId(event.target.value)}
className="w-64"
/>
</div>
<Button type="submit" variant="outline">
<Button type="submit" size="touch" variant="outline">
Filtern
</Button>
{isFiltered ? (
<Button type="button" size="touch" variant="ghost" onClick={resetFilter}>
Filter zurücksetzen
</Button>
) : null}
</form>
{isLoading ? <p className="text-sm text-muted-foreground">Lädt…</p> : null}
{isError ? (
<p role="alert" className="text-sm text-destructive">
Audit-Log konnte nicht geladen werden.
</p>
) : null}
{entries ? (
<Table>
<TableHeader>
<TableRow>
<TableHead>Zeitpunkt</TableHead>
<TableHead>Entitätstyp</TableHead>
<TableHead>Entitäts-ID</TableHead>
<TableHead>Aktion</TableHead>
<TableHead>Benutzer</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<QueryState
query={entriesQuery}
what="Einträge"
empty={
<EmptyState
icon={History}
title={isFiltered ? "Keine Einträge für diesen Filter" : "Noch keine Einträge"}
action={
isFiltered ? (
<Button type="button" variant="outline" onClick={resetFilter}>
Filter zurücksetzen
</Button>
) : undefined
}
/>
}
>
{(entries) => (
<ul className="flex flex-col gap-2">
{entries.map((entry) => (
<TableRow key={entry.id}>
<TableCell>{new Date(entry.timestamp).toLocaleString("de-DE")}</TableCell>
<TableCell>{auditEntityTypeLabel(entry.entityType)}</TableCell>
<TableCell className="font-mono text-xs">{entry.entityId}</TableCell>
<TableCell>{auditActionLabel(entry.action)}</TableCell>
<TableCell>{entry.userId ?? "—"}</TableCell>
</TableRow>
<li
key={entry.id}
className="flex flex-col gap-1 rounded-xl border border-border/60 bg-card p-3 sm:flex-row sm:items-center sm:gap-4"
>
<time
dateTime={entry.timestamp}
className="shrink-0 text-xs text-muted-foreground sm:w-40"
>
{new Date(entry.timestamp).toLocaleString("de-DE")}
</time>
<span className="flex-1 text-sm">
<span className="font-medium">{auditActionLabel(entry.action)}</span>
{" · "}
{auditEntityTypeLabel(entry.entityType)}
</span>
<span className="text-sm text-muted-foreground">{userName(entry.userId)}</span>
</li>
))}
</TableBody>
</Table>
) : null}
</ul>
)}
</QueryState>
</main>
);
}
@@ -1,27 +1,49 @@
"use client";
import { Users } from "lucide-react";
import { CircleCheck, CircleSlash, ShieldCheck, ShieldOff, Users, type LucideIcon } from "lucide-react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { activateUser, deactivateUser, fetchUsers, resetUserMfa } from "@/lib/users/api";
import { errorMessage } from "@/lib/forms/error-message";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { ConfirmDialog } from "@/components/confirm-dialog";
import { EmptyState } from "@/components/empty-state";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { QueryState } from "@/components/query-state";
import { CreateUserDialog } from "./create-user-dialog";
import { EditUserDialog } from "./edit-user-dialog";
interface StatusWordProps {
on: boolean;
onLabel: string;
offLabel: string;
onIcon: LucideIcon;
offIcon: LucideIcon;
}
/** State as icon AND word - never colour alone. */
function StatusWord({ on, onLabel, offLabel, onIcon: OnIcon, offIcon: OffIcon }: StatusWordProps) {
const Icon = on ? OnIcon : OffIcon;
return (
<span
className={
on
? "inline-flex items-center gap-1 text-xs font-medium"
: "inline-flex items-center gap-1 text-xs text-muted-foreground"
}
>
<Icon className="size-3.5" aria-hidden />
{on ? onLabel : offLabel}
</span>
);
}
/**
* One card per person - readable on a phone, and the same on a desktop, so
* there is only one layout to learn.
*/
export function UsersTab() {
const queryClient = useQueryClient();
const { data: users, isLoading, isError } = useQuery({
const usersQuery = useQuery({
queryKey: ["users"],
queryFn: fetchUsers,
});
@@ -34,7 +56,8 @@ export function UsersTab() {
description: "Kann jederzeit über „Aktivieren“ wiederhergestellt werden.",
});
},
onError: () => toast.error("Benutzer konnte nicht deaktiviert werden"),
onError: (error) =>
toast.error(errorMessage(error, "Benutzer konnte nicht deaktiviert werden")),
});
const activateMutation = useMutation({
@@ -43,7 +66,7 @@ export function UsersTab() {
void queryClient.invalidateQueries({ queryKey: ["users"] });
toast.success("Benutzer aktiviert");
},
onError: () => toast.error("Benutzer konnte nicht aktiviert werden"),
onError: (error) => toast.error(errorMessage(error, "Benutzer konnte nicht aktiviert werden")),
});
const resetMfaMutation = useMutation({
@@ -52,7 +75,7 @@ export function UsersTab() {
void queryClient.invalidateQueries({ queryKey: ["users"] });
toast.success("MFA zurückgesetzt");
},
onError: () => toast.error("MFA konnte nicht zurückgesetzt werden"),
onError: (error) => toast.error(errorMessage(error, "MFA konnte nicht zurückgesetzt werden")),
});
return (
@@ -61,89 +84,84 @@ export function UsersTab() {
<CreateUserDialog />
</div>
{isLoading ? <p className="text-sm text-muted-foreground">Lädt…</p> : null}
{isError ? (
<p role="alert" className="text-sm text-destructive">
Benutzer konnten nicht geladen werden.
</p>
) : null}
{users && users.length === 0 ? (
<EmptyState icon={Users} title="Noch keine Benutzer" description="Lege den ersten Zugang an." />
) : null}
{users && users.length > 0 ? (
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>E-Mail</TableHead>
<TableHead>Rolle</TableHead>
<TableHead>Status</TableHead>
<TableHead>MFA</TableHead>
<TableHead className="text-right">Aktion</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<QueryState
query={usersQuery}
what="Benutzer"
empty={
<EmptyState
icon={Users}
title="Noch keine Benutzer"
description="Lege den ersten Zugang an."
/>
}
>
{(users) => (
<ul className="flex flex-col gap-2">
{users.map((user) => (
<TableRow key={user.id}>
<TableCell>{user.name}</TableCell>
<TableCell>{user.email}</TableCell>
<TableCell>{user.roleName}</TableCell>
<TableCell>
<Badge variant={user.isActive ? "default" : "secondary"}>
{user.isActive ? "Aktiv" : "Deaktiviert"}
</Badge>
</TableCell>
<TableCell>
<Badge variant={user.mfaEnabled ? "default" : "outline"}>
{user.mfaEnabled ? "Aktiv" : "Inaktiv"}
</Badge>
</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-2">
<EditUserDialog user={user} />
{user.mfaEnabled ? (
<ConfirmDialog
triggerLabel="MFA zurücksetzen"
triggerVariant="outline"
triggerSize="sm"
triggerDisabled={resetMfaMutation.isPending}
title="MFA zurücksetzen?"
description={`„${user.name}“ muss sich beim nächsten Login ohne Zwei-Faktor-Code anmelden und kann MFA im eigenen Profil neu einrichten. Nutze das nur, wenn das Gerät verloren ging.`}
confirmLabel="Zurücksetzen"
isPending={resetMfaMutation.isPending}
onConfirm={() => resetMfaMutation.mutate(user.id)}
/>
) : null}
{user.isActive ? (
<ConfirmDialog
triggerLabel="Deaktivieren"
triggerSize="sm"
triggerDisabled={deactivateMutation.isPending}
title="Benutzer deaktivieren?"
description={`„${user.name}“ kann sich danach nicht mehr anmelden. Der Zugang kann jederzeit über „Aktivieren“ wiederhergestellt werden.`}
confirmLabel="Deaktivieren"
isPending={deactivateMutation.isPending}
onConfirm={() => deactivateMutation.mutate(user.id)}
/>
) : (
<Button
variant="outline"
size="sm"
disabled={activateMutation.isPending}
onClick={() => activateMutation.mutate(user.id)}
>
Aktivieren
</Button>
)}
</div>
</TableCell>
</TableRow>
<li
key={user.id}
className="flex flex-col gap-3 rounded-xl border border-border/60 bg-card p-3 sm:flex-row sm:items-center"
>
<div className="flex min-w-0 flex-1 flex-col gap-1">
<span className="font-medium">{user.name}</span>
<span className="truncate text-sm text-muted-foreground">{user.email}</span>
<span className="flex flex-wrap items-center gap-x-3 gap-y-1">
<span className="text-xs">{user.roleName}</span>
<StatusWord
on={user.isActive}
onLabel="Aktiv"
offLabel="Deaktiviert"
onIcon={CircleCheck}
offIcon={CircleSlash}
/>
<StatusWord
on={user.mfaEnabled}
onLabel="MFA an"
offLabel="MFA aus"
onIcon={ShieldCheck}
offIcon={ShieldOff}
/>
</span>
</div>
<div className="flex flex-wrap gap-2">
<EditUserDialog user={user} />
{user.mfaEnabled ? (
<ConfirmDialog
triggerLabel="MFA zurücksetzen"
triggerVariant="outline"
triggerDisabled={resetMfaMutation.isPending}
title="MFA zurücksetzen?"
description={`„${user.name}“ muss sich beim nächsten Login ohne Zwei-Faktor-Code anmelden und kann MFA im eigenen Profil neu einrichten. Nutze das nur, wenn das Gerät verloren ging.`}
confirmLabel="Zurücksetzen"
isPending={resetMfaMutation.isPending}
onConfirm={() => resetMfaMutation.mutate(user.id)}
/>
) : null}
{user.isActive ? (
<ConfirmDialog
triggerLabel="Deaktivieren"
triggerDisabled={deactivateMutation.isPending}
title="Benutzer deaktivieren?"
description={`„${user.name}“ kann sich danach nicht mehr anmelden. Der Zugang kann jederzeit über „Aktivieren“ wiederhergestellt werden.`}
confirmLabel="Deaktivieren"
isPending={deactivateMutation.isPending}
onConfirm={() => deactivateMutation.mutate(user.id)}
/>
) : (
<Button
variant="outline"
disabled={activateMutation.isPending}
onClick={() => activateMutation.mutate(user.id)}
>
Aktivieren
</Button>
)}
</div>
</li>
))}
</TableBody>
</Table>
) : null}
</ul>
)}
</QueryState>
</div>
);
}