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
+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;
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user