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
+205 -121

No files matched your search

@@ -8,6 +8,9 @@ import { fetchAuditLog } from "@/lib/audit-log/api";
vi.mock("@/lib/audit-log/api", () => ({ vi.mock("@/lib/audit-log/api", () => ({
fetchAuditLog: vi.fn(), fetchAuditLog: vi.fn(),
})); }));
vi.mock("@/lib/users/api", () => ({
fetchUsers: vi.fn().mockResolvedValue([{ id: "user-1", name: "Imkerin Anna" }]),
}));
function renderWithClient(ui: ReactElement) { function renderWithClient(ui: ReactElement) {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
@@ -35,9 +38,11 @@ describe("AuditLogPage", () => {
renderWithClient(<AuditLogPage />); renderWithClient(<AuditLogPage />);
expect(await screen.findByText("Bienenstand")).toBeInTheDocument(); expect(await screen.findByText(/Bienenstand/)).toBeInTheDocument();
expect(screen.getByText("apiary-1")).toBeInTheDocument();
expect(screen.getByText("Erstellt")).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 () => { 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 { History } from "lucide-react";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { fetchAuditLog } from "@/lib/audit-log/api"; 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 { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { PageHeader } from "@/components/page-header"; import { PageHeader } from "@/components/page-header";
import { EmptyState } from "@/components/empty-state";
import { QueryState } from "@/components/query-state";
import { import {
Table, Select,
TableBody, SelectContent,
TableCell, SelectItem,
TableHead, SelectTrigger,
TableHeader, SelectValue,
TableRow, } from "@/components/ui/select";
} from "@/components/ui/table";
const ALL = "ALL";
export default function AuditLogPage() { export default function AuditLogPage() {
const [entityType, setEntityType] = useState(""); const [entityType, setEntityType] = useState("");
const [entityId, setEntityId] = useState(""); const [entityId, setEntityId] = useState("");
const [appliedFilter, setAppliedFilter] = useState({ entityType: "", entityId: "" }); const [appliedFilter, setAppliedFilter] = useState({ entityType: "", entityId: "" });
const { data: entries, isLoading, isError } = useQuery({ const entriesQuery = useQuery({
queryKey: ["audit-log", appliedFilter], queryKey: ["audit-log", appliedFilter],
queryFn: () => queryFn: () =>
fetchAuditLog({ fetchAuditLog({
entityType: appliedFilter.entityType || undefined, 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 ( return (
<main className="flex flex-col gap-4 p-4 sm:p-6"> <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 <form
className="flex flex-wrap items-end gap-3" className="flex flex-wrap items-end gap-3"
@@ -42,66 +69,92 @@ export default function AuditLogPage() {
setAppliedFilter({ entityType, entityId }); setAppliedFilter({ entityType, entityId });
}} }}
> >
<div className="flex flex-col gap-1"> <div className="flex w-full flex-col gap-1 sm:w-56">
<label className="text-sm text-muted-foreground" htmlFor="entityType"> <label className="text-sm font-medium" htmlFor="audit-entity-type">
Entitätstyp Bereich
</label> </label>
<Input <Select
id="entityType" value={entityType || ALL}
placeholder="z. B. Colony" onValueChange={(value) => setEntityType(value === ALL ? "" : (value ?? ""))}
value={entityType} >
onChange={(event) => setEntityType(event.target.value)} <SelectTrigger id="audit-entity-type" className="w-full">
className="w-48" <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>
<div className="flex flex-col gap-1"> <div className="flex w-full flex-col gap-1 sm:w-72">
<label className="text-sm text-muted-foreground" htmlFor="entityId"> <label className="text-sm font-medium" htmlFor="audit-entity-id">
Entitäts-ID Bestimmter Datensatz{" "}
<span className="font-normal text-muted-foreground">optional</span>
</label> </label>
<Input <Input
id="entityId" id="audit-entity-id"
placeholder="UUID" placeholder="ID aus der Adresszeile"
value={entityId} value={entityId}
onChange={(event) => setEntityId(event.target.value)} onChange={(event) => setEntityId(event.target.value)}
className="w-64"
/> />
</div> </div>
<Button type="submit" variant="outline"> <Button type="submit" size="touch" variant="outline">
Filtern Filtern
</Button> </Button>
{isFiltered ? (
<Button type="button" size="touch" variant="ghost" onClick={resetFilter}>
Filter zurücksetzen
</Button>
) : null}
</form> </form>
{isLoading ? <p className="text-sm text-muted-foreground">Lädt…</p> : null} <QueryState
{isError ? ( query={entriesQuery}
<p role="alert" className="text-sm text-destructive"> what="Einträge"
Audit-Log konnte nicht geladen werden. empty={
</p> <EmptyState
) : null} icon={History}
title={isFiltered ? "Keine Einträge für diesen Filter" : "Noch keine Einträge"}
{entries ? ( action={
<Table> isFiltered ? (
<TableHeader> <Button type="button" variant="outline" onClick={resetFilter}>
<TableRow> Filter zurücksetzen
<TableHead>Zeitpunkt</TableHead> </Button>
<TableHead>Entitätstyp</TableHead> ) : undefined
<TableHead>Entitäts-ID</TableHead> }
<TableHead>Aktion</TableHead> />
<TableHead>Benutzer</TableHead> }
</TableRow> >
</TableHeader> {(entries) => (
<TableBody> <ul className="flex flex-col gap-2">
{entries.map((entry) => ( {entries.map((entry) => (
<TableRow key={entry.id}> <li
<TableCell>{new Date(entry.timestamp).toLocaleString("de-DE")}</TableCell> key={entry.id}
<TableCell>{auditEntityTypeLabel(entry.entityType)}</TableCell> 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"
<TableCell className="font-mono text-xs">{entry.entityId}</TableCell> >
<TableCell>{auditActionLabel(entry.action)}</TableCell> <time
<TableCell>{entry.userId ?? "—"}</TableCell> dateTime={entry.timestamp}
</TableRow> 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> </ul>
</Table> )}
) : null} </QueryState>
</main> </main>
); );
} }
@@ -1,27 +1,49 @@
"use client"; "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 { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner"; import { toast } from "sonner";
import { activateUser, deactivateUser, fetchUsers, resetUserMfa } from "@/lib/users/api"; import { activateUser, deactivateUser, fetchUsers, resetUserMfa } from "@/lib/users/api";
import { errorMessage } from "@/lib/forms/error-message";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { ConfirmDialog } from "@/components/confirm-dialog"; import { ConfirmDialog } from "@/components/confirm-dialog";
import { EmptyState } from "@/components/empty-state"; import { EmptyState } from "@/components/empty-state";
import { import { QueryState } from "@/components/query-state";
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { CreateUserDialog } from "./create-user-dialog"; import { CreateUserDialog } from "./create-user-dialog";
import { EditUserDialog } from "./edit-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() { export function UsersTab() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { data: users, isLoading, isError } = useQuery({ const usersQuery = useQuery({
queryKey: ["users"], queryKey: ["users"],
queryFn: fetchUsers, queryFn: fetchUsers,
}); });
@@ -34,7 +56,8 @@ export function UsersTab() {
description: "Kann jederzeit über „Aktivieren“ wiederhergestellt werden.", 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({ const activateMutation = useMutation({
@@ -43,7 +66,7 @@ export function UsersTab() {
void queryClient.invalidateQueries({ queryKey: ["users"] }); void queryClient.invalidateQueries({ queryKey: ["users"] });
toast.success("Benutzer aktiviert"); 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({ const resetMfaMutation = useMutation({
@@ -52,7 +75,7 @@ export function UsersTab() {
void queryClient.invalidateQueries({ queryKey: ["users"] }); void queryClient.invalidateQueries({ queryKey: ["users"] });
toast.success("MFA zurückgesetzt"); 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 ( return (
@@ -61,53 +84,51 @@ export function UsersTab() {
<CreateUserDialog /> <CreateUserDialog />
</div> </div>
{isLoading ? <p className="text-sm text-muted-foreground">Lädt…</p> : null} <QueryState
{isError ? ( query={usersQuery}
<p role="alert" className="text-sm text-destructive"> what="Benutzer"
Benutzer konnten nicht geladen werden. empty={
</p> <EmptyState
) : null} icon={Users}
title="Noch keine Benutzer"
{users && users.length === 0 ? ( description="Lege den ersten Zugang an."
<EmptyState icon={Users} title="Noch keine Benutzer" description="Lege den ersten Zugang an." /> />
) : null} }
>
{users && users.length > 0 ? ( {(users) => (
<Table> <ul className="flex flex-col gap-2">
<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>
{users.map((user) => ( {users.map((user) => (
<TableRow key={user.id}> <li
<TableCell>{user.name}</TableCell> key={user.id}
<TableCell>{user.email}</TableCell> className="flex flex-col gap-3 rounded-xl border border-border/60 bg-card p-3 sm:flex-row sm:items-center"
<TableCell>{user.roleName}</TableCell> >
<TableCell> <div className="flex min-w-0 flex-1 flex-col gap-1">
<Badge variant={user.isActive ? "default" : "secondary"}> <span className="font-medium">{user.name}</span>
{user.isActive ? "Aktiv" : "Deaktiviert"} <span className="truncate text-sm text-muted-foreground">{user.email}</span>
</Badge> <span className="flex flex-wrap items-center gap-x-3 gap-y-1">
</TableCell> <span className="text-xs">{user.roleName}</span>
<TableCell> <StatusWord
<Badge variant={user.mfaEnabled ? "default" : "outline"}> on={user.isActive}
{user.mfaEnabled ? "Aktiv" : "Inaktiv"} onLabel="Aktiv"
</Badge> offLabel="Deaktiviert"
</TableCell> onIcon={CircleCheck}
<TableCell className="text-right"> offIcon={CircleSlash}
<div className="flex justify-end gap-2"> />
<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} /> <EditUserDialog user={user} />
{user.mfaEnabled ? ( {user.mfaEnabled ? (
<ConfirmDialog <ConfirmDialog
triggerLabel="MFA zurücksetzen" triggerLabel="MFA zurücksetzen"
triggerVariant="outline" triggerVariant="outline"
triggerSize="sm"
triggerDisabled={resetMfaMutation.isPending} triggerDisabled={resetMfaMutation.isPending}
title="MFA zurücksetzen?" 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.`} 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.`}
@@ -119,7 +140,6 @@ export function UsersTab() {
{user.isActive ? ( {user.isActive ? (
<ConfirmDialog <ConfirmDialog
triggerLabel="Deaktivieren" triggerLabel="Deaktivieren"
triggerSize="sm"
triggerDisabled={deactivateMutation.isPending} triggerDisabled={deactivateMutation.isPending}
title="Benutzer deaktivieren?" title="Benutzer deaktivieren?"
description={`„${user.name}“ kann sich danach nicht mehr anmelden. Der Zugang kann jederzeit über „Aktivieren“ wiederhergestellt werden.`} description={`„${user.name}“ kann sich danach nicht mehr anmelden. Der Zugang kann jederzeit über „Aktivieren“ wiederhergestellt werden.`}
@@ -130,7 +150,6 @@ export function UsersTab() {
) : ( ) : (
<Button <Button
variant="outline" variant="outline"
size="sm"
disabled={activateMutation.isPending} disabled={activateMutation.isPending}
onClick={() => activateMutation.mutate(user.id)} onClick={() => activateMutation.mutate(user.id)}
> >
@@ -138,12 +157,11 @@ export function UsersTab() {
</Button> </Button>
)} )}
</div> </div>
</TableCell> </li>
</TableRow>
))} ))}
</TableBody> </ul>
</Table> )}
) : null} </QueryState>
</div> </div>
); );
} }
@@ -49,6 +49,14 @@ const AUDIT_ENTITY_TYPE_LABELS: Record<string, string> = {
WebhookSubscription: "Webhook", WebhookSubscription: "Webhook",
}; };
/** Filter choices: one entry per area (duplicate spellings collapsed), A-Z. */
export const AUDIT_ENTITY_TYPE_OPTIONS: { value: string; label: string }[] = Object.entries(
AUDIT_ENTITY_TYPE_LABELS,
)
.filter(([value]) => value !== value.toUpperCase())
.map(([value, label]) => ({ value, label }))
.sort((a, b) => a.label.localeCompare(b.label, "de"));
export function auditEntityTypeLabel(entityType: string): string { export function auditEntityTypeLabel(entityType: string): string {
return AUDIT_ENTITY_TYPE_LABELS[entityType] ?? entityType; return AUDIT_ENTITY_TYPE_LABELS[entityType] ?? entityType;
} }