From 832997f885d30544977995b55edf99bec8754826 Mon Sep 17 00:00:00 2001 From: Nick Szaunig Date: Thu, 1 Oct 2026 12:13:40 +0200 Subject: [PATCH] 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) --- .../(app)/settings/audit-log/page.spec.tsx | 9 +- .../src/app/(app)/settings/audit-log/page.tsx | 167 +++++++++----- .../(app)/settings/users-roles/users-tab.tsx | 208 ++++++++++-------- ApiCore-Frontend/src/lib/audit-log/labels.ts | 8 + 4 files changed, 238 insertions(+), 154 deletions(-) diff --git a/ApiCore-Frontend/src/app/(app)/settings/audit-log/page.spec.tsx b/ApiCore-Frontend/src/app/(app)/settings/audit-log/page.spec.tsx index 25c37c4..a665b22 100644 --- a/ApiCore-Frontend/src/app/(app)/settings/audit-log/page.spec.tsx +++ b/ApiCore-Frontend/src/app/(app)/settings/audit-log/page.spec.tsx @@ -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(); - 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 () => { diff --git a/ApiCore-Frontend/src/app/(app)/settings/audit-log/page.tsx b/ApiCore-Frontend/src/app/(app)/settings/audit-log/page.tsx index a59162c..0e9e0db 100644 --- a/ApiCore-Frontend/src/app/(app)/settings/audit-log/page.tsx +++ b/ApiCore-Frontend/src/app/(app)/settings/audit-log/page.tsx @@ -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 (
- +
-
-
); } diff --git a/ApiCore-Frontend/src/app/(app)/settings/users-roles/users-tab.tsx b/ApiCore-Frontend/src/app/(app)/settings/users-roles/users-tab.tsx index 500e7b0..fbabf2d 100644 --- a/ApiCore-Frontend/src/app/(app)/settings/users-roles/users-tab.tsx +++ b/ApiCore-Frontend/src/app/(app)/settings/users-roles/users-tab.tsx @@ -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 ( + + + {on ? onLabel : offLabel} + + ); +} + +/** + * 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() { - {isLoading ?

Lädt…

: null} - {isError ? ( -

- Benutzer konnten nicht geladen werden. -

- ) : null} - - {users && users.length === 0 ? ( - - ) : null} - - {users && users.length > 0 ? ( - - - - Name - E-Mail - Rolle - Status - MFA - Aktion - - - + + } + > + {(users) => ( +
    {users.map((user) => ( - - {user.name} - {user.email} - {user.roleName} - - - {user.isActive ? "Aktiv" : "Deaktiviert"} - - - - - {user.mfaEnabled ? "Aktiv" : "Inaktiv"} - - - -
    - - {user.mfaEnabled ? ( - resetMfaMutation.mutate(user.id)} - /> - ) : null} - {user.isActive ? ( - deactivateMutation.mutate(user.id)} - /> - ) : ( - - )} -
    -
    -
    +
  • +
    + {user.name} + {user.email} + + {user.roleName} + + + +
    +
    + + {user.mfaEnabled ? ( + resetMfaMutation.mutate(user.id)} + /> + ) : null} + {user.isActive ? ( + deactivateMutation.mutate(user.id)} + /> + ) : ( + + )} +
    +
  • ))} - -
- ) : null} + + )} + ); } diff --git a/ApiCore-Frontend/src/lib/audit-log/labels.ts b/ApiCore-Frontend/src/lib/audit-log/labels.ts index 2a2d292..618da37 100644 --- a/ApiCore-Frontend/src/lib/audit-log/labels.ts +++ b/ApiCore-Frontend/src/lib/audit-log/labels.ts @@ -49,6 +49,14 @@ const AUDIT_ENTITY_TYPE_LABELS: Record = { 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; }