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:
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>
|
||||
);
|
||||
}
|
||||
@@ -49,6 +49,14 @@ const AUDIT_ENTITY_TYPE_LABELS: Record<string, string> = {
|
||||
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 {
|
||||
return AUDIT_ENTITY_TYPE_LABELS[entityType] ?? entityType;
|
||||
}
|
||||
Reference in new issue
Block a user