Audit log: filter by actor (#197)

Admins can now filter the audit log by who acted, not just by what was
acted on.

User-facing changes
- Replaced the flat actor <select> with an autocomplete combobox
  (Popover + cmdk Command) that searches by username AND display name,
  in English or Arabic.
- Added an `actorId` URL hash param that survives full reload — picks
  up alongside the existing target filter on initial mount and is
  cleared when the admin navigates to a different section.
- Each audit row's actor avatar + name is now clickable, mirroring the
  existing target chip pivot, so admins can jump from "this row" to
  "everything by this person" in one click. Rows with a null actor
  (system / deleted user) render the same avatar/name without the
  click affordance, since the API can only filter by a concrete id.
- Active actor pill renders in sky (target pill remains emerald) and
  has a clear button.
- CSV export already accepted `actorUserId` on the backend; the
  frontend export call now forwards the filter and the OpenAPI
  description has been updated to document it.

Files
- artifacts/tx-os/src/pages/admin.tsx
  - New AuditActorPicker component, hash helpers
    (parseAuditHashActor / syncAuditHashActor), pivotToActor /
    clearActorFilter handlers, sky-colored active pill, clickable
    actor in AuditLogRow.
- artifacts/api-server/tests/audit-logs-actor-filter.test.mjs (new)
  - 6 tests covering: filter narrows results, excludes other actors
    on the same target_type, combines with target filter, invalid
    /zero/negative actorUserId → 400, CSV export honors filter.
- artifacts/tx-os/src/locales/{en,ar}.json
  - actorSearch / actorEmpty / actorWithName / actorWithId /
    clearActorFilter / actorPivotAria.
- lib/api-spec/openapi.yaml
  - Audit export endpoint description now mentions
    targetType / targetId / actorUserId.

Verification
- pnpm --filter @workspace/tx-os run typecheck → clean.
- All 6 new actor-filter tests + all 7 existing target-filter tests
  pass against a live API server.
- E2E run via runTest() succeeded: opened the picker, selected an
  actor, verified the pill + reload-safe hash, cleared, pivoted via
  a row click, and confirmed CSV export honored the filter.

Notes / drift
- URL key is `actorId` (per task spec), but the React state and API
  param remain `actorUserId` to match the existing backend.
- The broader `test` workflow has pre-existing failures in
  executive-meetings.test.mjs and service-orders.test.mjs unrelated
  to this change (admin 401 / HTML-404 fallback). Captured as
  follow-up #291.

Replit-Task-Id: 0f02b232-eda3-46db-8235-98ecce2ebdb7
This commit is contained in:
riyadhafraa
2026-05-01 15:26:36 +00:00
parent ab5ec2e2e2
commit 6876a83bbb
8 changed files with 432 additions and 118 deletions
@@ -1,7 +1,7 @@
// #197: Coverage for the actorUserId filter on GET /admin/audit-logs and the
// CSV export. Two distinct actor rows are seeded, then we assert the filter
// narrows to a single actor and that the export endpoint honours the same
// param. Tagging metadata so we ignore unrelated rows in the same database.
// #197: end-to-end coverage for the audit log's actorUserId filter and its
// CSV-export counterpart. The buildWhere clause already supported the
// filter; these tests pin down the wiring (param parsing, filter
// composition, and CSV export honouring it).
import { test, before, after } from "node:test";
import assert from "node:assert/strict";
import pg from "pg";
@@ -21,12 +21,18 @@ const pool = new pg.Pool({ connectionString: DATABASE_URL });
const STAMP = `${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 6)}`;
const TEST_TAG = `actor_filter_test_${STAMP}`;
let adminId;
let adminCookie;
let actorAId;
let actorBId;
let adminAId;
let adminAUsername;
let adminACookie;
let adminBId;
let adminBUsername;
const insertedAuditIds = [];
const TARGET_ID_A = 800400001;
const TARGET_ID_B = 800400002;
const TARGET_ID_C = 800400003;
async function loginAndGetCookie(username, password) {
const res = await fetch(`${API_BASE}/api/auth/login`, {
method: "POST",
@@ -41,16 +47,16 @@ async function loginAndGetCookie(username, password) {
.find((c) => c.startsWith("connect.sid="));
}
async function insertAuditRow({ actorUserId, action, targetType, targetId }) {
async function insertAuditRow({ actorId, action, targetType, targetId, metadata }) {
const r = await pool.query(
`INSERT INTO audit_logs (actor_user_id, action, target_type, target_id, metadata)
VALUES ($1, $2, $3, $4, $5::jsonb) RETURNING id`,
[
actorUserId,
actorId,
action,
targetType,
targetId,
JSON.stringify({ testTag: TEST_TAG }),
JSON.stringify({ ...metadata, testTag: TEST_TAG }),
],
);
insertedAuditIds.push(r.rows[0].id);
@@ -58,118 +64,165 @@ async function insertAuditRow({ actorUserId, action, targetType, targetId }) {
}
before(async () => {
const adminUsername = `audit_actor_admin_${STAMP}`;
adminAUsername = `audit_actor_admin_a_${STAMP}`;
adminBUsername = `audit_actor_admin_b_${STAMP}`;
const a = await pool.query(
`INSERT INTO users (username, email, password_hash, display_name_en, preferred_language, is_active)
VALUES ($1, $2, $3, 'Actor Admin', 'en', true) RETURNING id`,
[adminUsername, `${adminUsername}@example.com`, TEST_PASSWORD_HASH],
VALUES ($1, $2, $3, 'Audit Actor A', 'en', true) RETURNING id`,
[adminAUsername, `${adminAUsername}@example.com`, TEST_PASSWORD_HASH],
);
adminId = a.rows[0].id;
adminAId = a.rows[0].id;
const b = await pool.query(
`INSERT INTO users (username, email, password_hash, display_name_en, preferred_language, is_active)
VALUES ($1, $2, $3, 'Audit Actor B', 'en', true) RETURNING id`,
[adminBUsername, `${adminBUsername}@example.com`, TEST_PASSWORD_HASH],
);
adminBId = b.rows[0].id;
await pool.query(
`INSERT INTO user_roles (user_id, role_id) SELECT $1, id FROM roles WHERE name = 'admin'`,
[adminId],
[adminAId],
);
adminCookie = await loginAndGetCookie(adminUsername, TEST_PASSWORD);
// Two distinct actors who will each leave audit rows.
const aa = await pool.query(
`INSERT INTO users (username, email, password_hash, display_name_en, preferred_language, is_active)
VALUES ($1, $2, $3, 'Actor A', 'en', true) RETURNING id`,
[`actor_a_${STAMP}`, `actor_a_${STAMP}@example.com`, TEST_PASSWORD_HASH],
);
actorAId = aa.rows[0].id;
const bb = await pool.query(
`INSERT INTO users (username, email, password_hash, display_name_en, preferred_language, is_active)
VALUES ($1, $2, $3, 'Actor B', 'en', true) RETURNING id`,
[`actor_b_${STAMP}`, `actor_b_${STAMP}@example.com`, TEST_PASSWORD_HASH],
);
actorBId = bb.rows[0].id;
await pool.query(
`INSERT INTO user_roles (user_id, role_id) SELECT $1, id FROM roles WHERE name = 'admin'`,
[adminBId],
);
adminACookie = await loginAndGetCookie(adminAUsername, TEST_PASSWORD);
// Two rows by Admin A.
await insertAuditRow({
actorUserId: actorAId,
action: "settings.update",
targetType: "settings",
targetId: 1,
actorId: adminAId,
action: "service.create",
targetType: "service",
targetId: TARGET_ID_A,
metadata: { nameEn: "ASvc1" },
});
await insertAuditRow({
actorUserId: actorAId,
action: "settings.update",
targetType: "settings",
targetId: 1,
actorId: adminAId,
action: "service.update",
targetType: "service",
targetId: TARGET_ID_B,
metadata: { changes: 3 },
});
// One row by Admin B on a third target so we can prove the filter
// narrows by *who acted*, not by what was acted on.
await insertAuditRow({
actorUserId: actorBId,
action: "settings.update",
targetType: "settings",
targetId: 1,
actorId: adminBId,
action: "service.create",
targetType: "service",
targetId: TARGET_ID_C,
metadata: { nameEn: "BSvc" },
});
});
after(async () => {
if (insertedAuditIds.length > 0) {
if (insertedAuditIds.length) {
await pool.query(`DELETE FROM audit_logs WHERE id = ANY($1::int[])`, [
insertedAuditIds,
]);
}
await pool.query(`DELETE FROM users WHERE id = ANY($1::int[])`, [
[adminId, actorAId, actorBId].filter(Boolean),
]);
for (const id of [adminAId, adminBId]) {
if (id !== undefined) {
await pool.query(`DELETE FROM audit_logs WHERE actor_user_id = $1`, [id]);
await pool.query(`DELETE FROM user_roles WHERE user_id = $1`, [id]);
await pool.query(`DELETE FROM users WHERE id = $1`, [id]);
}
}
await pool.end();
});
function rowMatchesTag(entry) {
return entry.metadata && entry.metadata.testTag === TEST_TAG;
function ownEntries(body) {
return body.entries.filter(
(e) =>
e.metadata &&
typeof e.metadata === "object" &&
e.metadata.testTag === TEST_TAG,
);
}
test("actorUserId filter narrows results to the chosen actor", async () => {
const res = await fetch(
`${API_BASE}/api/admin/audit-logs?actorUserId=${actorAId}&limit=200`,
{ headers: { Cookie: adminCookie } },
);
async function listWithParams(qs) {
const res = await fetch(`${API_BASE}/api/admin/audit-logs?${qs}`, {
headers: { Cookie: adminACookie },
});
assert.equal(res.status, 200);
const body = await res.json();
const tagged = body.entries.filter(rowMatchesTag);
assert.equal(tagged.length, 2);
for (const entry of tagged) {
assert.equal(entry.actor && entry.actor.id, actorAId);
return res.json();
}
test("actorUserId filter narrows results to that acting user", async () => {
const body = await listWithParams(`actorUserId=${adminAId}&limit=200`);
const ours = ownEntries(body);
assert.equal(ours.length, 2, "expected exactly the two seeded admin-A rows");
for (const e of ours) {
assert.equal(
e.actor?.id,
adminAId,
`unexpected actor ${e.actor?.id ?? "null"}`,
);
}
const targetIds = ours.map((e) => e.targetId).sort();
assert.deepEqual(targetIds, [TARGET_ID_A, TARGET_ID_B]);
});
test("actorUserId filter excludes other actors' rows", async () => {
const res = await fetch(
`${API_BASE}/api/admin/audit-logs?actorUserId=${actorBId}&limit=200`,
{ headers: { Cookie: adminCookie } },
test("actorUserId filter excludes other actors on the same target_type", async () => {
const body = await listWithParams(`actorUserId=${adminAId}&limit=200`);
const ours = ownEntries(body);
assert.ok(
!ours.some((e) => e.actor?.id === adminBId),
"admin B's row must not appear in admin A's filtered list",
);
assert.equal(res.status, 200);
const body = await res.json();
const tagged = body.entries.filter(rowMatchesTag);
assert.equal(tagged.length, 1);
assert.equal(tagged[0].actor.id, actorBId);
assert.ok(!ours.some((e) => e.targetId === TARGET_ID_C));
});
test("actorUserId combines with targetId to scope to one (actor, target)", async () => {
const body = await listWithParams(
`actorUserId=${adminAId}&targetType=service&targetId=${TARGET_ID_A}&limit=200`,
);
const ours = ownEntries(body);
assert.equal(ours.length, 1);
assert.equal(ours[0].actor?.id, adminAId);
assert.equal(ours[0].targetId, TARGET_ID_A);
});
test("invalid actorUserId returns 400", async () => {
const res = await fetch(
`${API_BASE}/api/admin/audit-logs?actorUserId=not-a-number`,
{ headers: { Cookie: adminCookie } },
{ headers: { Cookie: adminACookie } },
);
assert.equal(res.status, 400);
const body = await res.json();
assert.match(body.error ?? "", /actorUserId/);
});
test("CSV export honours actorUserId filter", async () => {
test("zero / negative actorUserId returns 400", async () => {
for (const bad of ["0", "-1"]) {
const res = await fetch(
`${API_BASE}/api/admin/audit-logs?actorUserId=${bad}`,
{ headers: { Cookie: adminACookie } },
);
assert.equal(res.status, 400, `expected 400 for actorUserId=${bad}`);
}
});
test("CSV export honors the actorUserId filter", async () => {
const res = await fetch(
`${API_BASE}/api/admin/audit-logs/export?actorUserId=${actorAId}`,
{ headers: { Cookie: adminCookie } },
`${API_BASE}/api/admin/audit-logs/export?actorUserId=${adminAId}`,
{ headers: { Cookie: adminACookie, Accept: "text/csv" } },
);
assert.equal(res.status, 200);
assert.match(
res.headers.get("content-type") ?? "",
/text\/csv/i,
);
assert.match(res.headers.get("content-type") ?? "", /text\/csv/);
const text = await res.text();
// Filter to rows with our test tag in the metadata column. Two rows from
// Actor A, none from Actor B.
const taggedLines = text
const ourLines = text
.split(/\r?\n/)
.filter((line) => line.includes(TEST_TAG));
assert.equal(taggedLines.length, 2);
assert.equal(ourLines.length, 2, "expected exactly the two admin-A rows");
for (const line of ourLines) {
assert.match(
line,
new RegExp(`,${adminAUsername},`),
`expected every CSV row to be authored by admin A: ${line}`,
);
assert.ok(
!line.includes(adminBUsername),
`admin B must not appear in the filtered export: ${line}`,
);
}
});
Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 25 KiB

+6
View File
@@ -658,6 +658,11 @@
"allActions": "كل الإجراءات",
"actor": "المنفّذ",
"allActors": "كل المنفّذين",
"actorSearch": "ابحث بالاسم أو @المعرّف",
"actorEmpty": "لا يوجد مستخدمون مطابقون.",
"actorWithName": "المنفّذ: {{name}}",
"actorWithId": "المنفّذ: #{{id}}",
"clearActorFilter": "مسح فلتر المنفّذ",
"from": "من",
"to": "إلى",
"apply": "تطبيق",
@@ -668,6 +673,7 @@
"targetWithId": "الهدف: {{type}} #{{id}}",
"clearTargetFilter": "مسح فلتر الهدف"
},
"actorPivotAria": "تصفية سجل التدقيق على الإجراءات التي قام بها {{name}}",
"target": "الهدف: {{type}} #{{id}}",
"targetWithName": "الهدف: {{type}} #{{id}} · {{name}}",
"showing": "عرض {{shown}} من أصل {{total}}",
+6
View File
@@ -607,6 +607,11 @@
"allActions": "All actions",
"actor": "Actor",
"allActors": "All actors",
"actorSearch": "Search by name or @username",
"actorEmpty": "No matching users.",
"actorWithName": "Actor: {{name}}",
"actorWithId": "Actor: #{{id}}",
"clearActorFilter": "Clear actor filter",
"from": "From",
"to": "To",
"apply": "Apply",
@@ -617,6 +622,7 @@
"targetWithId": "Target: {{type}} #{{id}}",
"clearTargetFilter": "Clear target filter"
},
"actorPivotAria": "Filter audit log to actions by {{name}}",
"target": "Target: {{type}} #{{id}}",
"targetWithName": "Target: {{type}} #{{id}} · {{name}}",
"showing": "Showing {{shown}} of {{total}}",
+278 -32
View File
@@ -152,6 +152,15 @@ import {
DialogTitle,
} from "@/components/ui/dialog";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import { useToast } from "@/hooks/use-toast";
import { cn } from "@/lib/utils";
import { formatDate as formatDateUtil, formatWeekday as formatWeekdayUtil } from "@/lib/i18n-format";
@@ -938,12 +947,13 @@ export default function AdminPage() {
const params = new URLSearchParams(raw);
const previousSection = params.get("section");
params.set("section", section);
// Section-scoped params (the audit log's targetType / targetId deep
// links) only make sense within their owning section. Switching to a
// Section-scoped params (the audit log's targetType / targetId / actorId
// deep links) only make sense within their owning section. Switching to a
// different section drops them so the next visit starts fresh.
if (previousSection !== section) {
params.delete("targetType");
params.delete("targetId");
params.delete("actorId");
}
const newHash = `#${params.toString()}`;
if (window.location.hash !== newHash) {
@@ -6572,10 +6582,14 @@ function AuditLogRow({
entry,
lang,
onPivotToTarget,
onPivotToActor,
}: {
entry: AuditLogEntry;
lang: string;
onPivotToTarget: (pivot: AuditTargetPivot) => void;
// #197: clicking the actor name/avatar pivots the panel to "everything that
// person did" — symmetrical with target-chip pivots above.
onPivotToActor: (actorUserId: number) => void;
}) {
const { t } = useTranslation();
const [expanded, setExpanded] = useState(false);
@@ -6588,22 +6602,55 @@ function AuditLogRow({
const chips = isForced ? dependencyChips(entry, t) : [];
const deletedName = deletedEntityName(entry, lang);
const showDeletedTarget = deletedName != null && entry.targetId != null;
// #197: rows whose actor is null (system / deleted user) cannot pivot — the
// server filter requires a concrete actorUserId, so we render the same
// avatar + name without the click affordance.
const canPivotActor = actor != null;
const actorPivotAria = actor
? t("admin.audit.actorPivotAria", { name: actorLabel(actor, lang) })
: undefined;
return (
<div
className="glass-panel rounded-2xl p-3 space-y-2"
data-testid={`audit-row-${entry.id}`}
>
<div className="flex items-start gap-3">
<LeaderboardAvatar
src={actor?.avatarUrl ?? null}
initial={initial}
alt={actor?.username ?? ""}
/>
{canPivotActor ? (
<button
type="button"
onClick={() => onPivotToActor(actor!.id)}
aria-label={actorPivotAria}
title={actorPivotAria}
className="rounded-full focus:outline-none focus-visible:ring-2 focus-visible:ring-sky-400 hover:opacity-80 transition-opacity"
data-testid={`audit-row-actor-avatar-${entry.id}`}
>
<LeaderboardAvatar
src={actor?.avatarUrl ?? null}
initial={initial}
alt={actor?.username ?? ""}
/>
</button>
) : (
<LeaderboardAvatar src={null} initial={initial} alt="" />
)}
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-baseline gap-x-2 gap-y-0.5">
<span className="font-medium text-sm text-foreground truncate">
{actorLabel(actor, lang)}
</span>
{canPivotActor ? (
<button
type="button"
onClick={() => onPivotToActor(actor!.id)}
aria-label={actorPivotAria}
title={actorPivotAria}
className="font-medium text-sm text-foreground truncate text-start hover:text-sky-700 hover:underline focus:outline-none focus-visible:ring-2 focus-visible:ring-sky-400 rounded-sm"
data-testid={`audit-row-actor-name-${entry.id}`}
>
{actorLabel(actor, lang)}
</button>
) : (
<span className="font-medium text-sm text-foreground truncate">
{actorLabel(actor, lang)}
</span>
)}
{actor && (
<span className="text-xs text-muted-foreground">
@{actor.username}
@@ -6880,6 +6927,152 @@ function syncAuditHashTarget(pivot: AuditTargetPivot) {
}
}
// #197: same idea as parseAuditHashTarget but for the actor filter. Uses the
// shorter `actorId` URL key (the deep-link / hash param called out in the
// task spec) even though the API param + state are named `actorUserId`.
function parseAuditHashActor(): number | null {
if (typeof window === "undefined") return null;
const raw = window.location.hash.replace(/^#/, "");
const params = new URLSearchParams(raw);
const actorIdRaw = params.get("actorId")?.trim();
if (!actorIdRaw) return null;
const n = Number(actorIdRaw);
if (!Number.isFinite(n) || !Number.isInteger(n) || n < 1) return null;
return n;
}
function syncAuditHashActor(actorUserId: number | null) {
if (typeof window === "undefined") return;
const raw = window.location.hash.replace(/^#/, "");
const params = new URLSearchParams(raw);
if (actorUserId != null) {
params.set("actorId", String(actorUserId));
} else {
params.delete("actorId");
}
if (!params.has("section")) {
params.set("section", "audit-log");
}
const newHash = `#${params.toString()}`;
if (window.location.hash !== newHash) {
window.history.replaceState(null, "", newHash);
}
}
// #197: Combobox-style actor picker with autocomplete by username and
// display name (English + Arabic). Replaces the previous flat <select> so
// admins on a busy site (hundreds of users) can find a teammate by typing
// a few characters instead of scrolling. The "Anyone" item clears the
// filter; the rest pass back the user's id to the parent.
function AuditActorPicker({
actors,
lang,
actorUserId,
onChange,
placeholder,
searchPlaceholder,
emptyMessage,
clearLabel,
}: {
actors: UserProfile[];
lang: string;
actorUserId: number | "";
onChange: (next: number | "") => void;
placeholder: string;
searchPlaceholder: string;
emptyMessage: string;
clearLabel: string;
}) {
const [open, setOpen] = useState(false);
const selected =
actorUserId !== "" ? actors.find((u) => u.id === actorUserId) ?? null : null;
const triggerLabel = selected
? (lang === "ar"
? selected.displayNameAr ?? selected.displayNameEn
: selected.displayNameEn ?? selected.displayNameAr) ?? selected.username
: placeholder;
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<button
type="button"
aria-haspopup="listbox"
aria-expanded={open}
data-testid="audit-actor-filter"
className="bg-white/70 border border-slate-200 rounded-lg px-3 py-2 text-sm w-full text-start flex items-center justify-between gap-2 hover:bg-white focus:outline-none focus-visible:ring-2 focus-visible:ring-sky-400"
>
<span
className={cn(
"truncate",
selected ? "text-foreground" : "text-muted-foreground",
)}
>
{triggerLabel}
</span>
<ChevronDown size={14} className="shrink-0 opacity-60" />
</button>
</PopoverTrigger>
<PopoverContent className="w-[260px] p-0" align="start">
<Command
// cmdk's default search ranks against a single string. We feed it
// a joined "displayName · username · @username" so a query matches
// either the display name or the handle (with or without @).
filter={(value, search) => {
if (!search) return 1;
return value.toLowerCase().includes(search.toLowerCase()) ? 1 : 0;
}}
>
<CommandInput
placeholder={searchPlaceholder}
data-testid="audit-actor-filter-search"
/>
<CommandList>
<CommandEmpty>{emptyMessage}</CommandEmpty>
<CommandGroup>
<CommandItem
value={`__all__ ${placeholder} ${clearLabel}`}
onSelect={() => {
onChange("");
setOpen(false);
}}
data-testid="audit-actor-filter-option-any"
>
<span className="text-muted-foreground">{placeholder}</span>
</CommandItem>
{actors.map((u) => {
const display =
(lang === "ar"
? u.displayNameAr ?? u.displayNameEn
: u.displayNameEn ?? u.displayNameAr) ?? u.username;
return (
<CommandItem
key={u.id}
value={`${display} ${u.username} @${u.username}`}
onSelect={() => {
onChange(u.id);
setOpen(false);
}}
data-testid={`audit-actor-filter-option-${u.id}`}
>
<div className="min-w-0 flex-1">
<div className="text-sm text-foreground truncate">
{display}
</div>
<div className="text-[11px] text-muted-foreground truncate">
@{u.username}
</div>
</div>
</CommandItem>
);
})}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}
function AuditLogPanel() {
const { t, i18n } = useTranslation();
const lang = i18n.language;
@@ -6894,7 +7087,13 @@ function AuditLogPanel() {
const [exportError, setExportError] = useState<string | null>(null);
// #197: actor filter — applies immediately like the History tabs do, since
// picking a different person is the natural way to commit the choice.
const [actorUserId, setActorUserId] = useState<number | "">("");
// Initialised from the URL hash so a deep link like
// `/admin#section=audit-log&actorId=42` lands directly on Alice's audit
// history, and so chip pivots survive reload / back-forward navigation.
const [actorUserId, setActorUserId] = useState<number | "">(() => {
const fromHash = parseAuditHashActor();
return fromHash != null ? fromHash : "";
});
// Deep-link target filters. Initialised from the URL hash so a user who
// pastes /admin#section=audit-log&targetType=group lands directly on a
// pre-filtered view. Updated by chip clicks and the explicit clear button.
@@ -6980,6 +7179,7 @@ function AuditLogPanel() {
setTargetIdFilter(null);
setActorUserId("");
syncAuditHashTarget({ targetType: "", targetId: null });
syncAuditHashActor(null);
};
const toggleForcedOnly = () => {
@@ -7009,6 +7209,23 @@ function AuditLogPanel() {
syncAuditHashTarget({ targetType: "", targetId: null });
};
// #197: actor name/avatar click on any audit row pivots the panel to that
// actor's filtered view. Mirrors the dependency-chip → target pivot above
// so admins can hop between "what was acted on" and "who acted" with one
// click. Resets pagination so the new result set starts fresh and updates
// the URL hash so the filter survives reload / back-forward navigation.
const pivotToActor = (newActorUserId: number) => {
setActorUserId(newActorUserId);
setPageLimit(50);
syncAuditHashActor(newActorUserId);
};
const clearActorFilter = () => {
setActorUserId("");
setPageLimit(50);
syncAuditHashActor(null);
};
const loadMore = () => setPageLimit((n) => Math.min(n + 50, 200));
const handleExportCsv = async () => {
@@ -7066,6 +7283,25 @@ function AuditLogPanel() {
: t("admin.audit.filters.targetType", { type: targetTypeFilter })
: null;
// #197: Resolve the currently selected actor so the picker's trigger and
// the filter pill can render their name/username (rather than just an ID).
const selectedActor =
actorUserId !== ""
? sortedActors.find((u) => u.id === actorUserId) ?? null
: null;
const selectedActorLabel = selectedActor
? (lang === "ar"
? selectedActor.displayNameAr ?? selectedActor.displayNameEn
: selectedActor.displayNameEn ?? selectedActor.displayNameAr) ??
selectedActor.username
: null;
const activeActorPillLabel =
actorUserId !== ""
? selectedActorLabel
? t("admin.audit.filters.actorWithName", { name: selectedActorLabel })
: t("admin.audit.filters.actorWithId", { id: actorUserId })
: null;
return (
<div className="space-y-3" data-testid="audit-log-panel">
<div className="glass-panel rounded-2xl p-3 space-y-3">
@@ -7104,6 +7340,24 @@ function AuditLogPanel() {
</button>
</span>
)}
{activeActorPillLabel && (
<span
className="inline-flex items-center gap-1.5 text-xs px-3 py-1.5 rounded-full border bg-sky-100 text-sky-800 border-sky-200"
data-testid="audit-actor-filter-pill"
>
<span className="truncate">{activeActorPillLabel}</span>
<button
type="button"
onClick={clearActorFilter}
aria-label={t("admin.audit.filters.clearActorFilter")}
title={t("admin.audit.filters.clearActorFilter")}
className="ms-0.5 inline-flex items-center justify-center w-4 h-4 rounded-full hover:bg-sky-200/70 focus:outline-none focus-visible:ring-2 focus-visible:ring-sky-400"
data-testid="audit-clear-actor-filter"
>
×
</button>
</span>
)}
</div>
<div className="grid gap-2 sm:grid-cols-2 md:grid-cols-5">
<div className="space-y-1">
@@ -7128,29 +7382,20 @@ function AuditLogPanel() {
</div>
<div className="space-y-1">
<Label className="text-xs">{t("admin.audit.filters.actor")}</Label>
<select
value={actorUserId === "" ? "" : String(actorUserId)}
onChange={(e) => {
const v = e.target.value;
setActorUserId(v === "" ? "" : Number(v));
<AuditActorPicker
actors={sortedActors}
lang={lang}
actorUserId={actorUserId}
onChange={(next) => {
setActorUserId(next);
setPageLimit(50);
syncAuditHashActor(next === "" ? null : next);
}}
className="bg-white/70 border border-slate-200 rounded-lg px-3 py-2 text-sm w-full"
data-testid="audit-actor-filter"
>
<option value="">{t("admin.audit.filters.allActors")}</option>
{sortedActors.map((u) => {
const label =
(lang === "ar"
? u.displayNameAr ?? u.displayNameEn
: u.displayNameEn ?? u.displayNameAr) ?? u.username;
return (
<option key={u.id} value={u.id}>
{label}
</option>
);
})}
</select>
placeholder={t("admin.audit.filters.allActors")}
searchPlaceholder={t("admin.audit.filters.actorSearch")}
emptyMessage={t("admin.audit.filters.actorEmpty")}
clearLabel={t("admin.audit.filters.clearActorFilter")}
/>
</div>
<div className="space-y-1">
<Label className="text-xs">{t("admin.audit.filters.from")}</Label>
@@ -7244,6 +7489,7 @@ function AuditLogPanel() {
entry={entry}
lang={lang}
onPivotToTarget={pivotToTarget}
onPivotToActor={pivotToActor}
/>
))}
</div>
+3 -2
View File
@@ -7339,8 +7339,9 @@ export function useListAuditLogs<
/**
* Streams the filtered audit log as a CSV download.
Honors the same `action`, `forcedOnly`, `from`, and `to` filters as the
list endpoint.
Honors the same `action`, `forcedOnly`, `from`, `to`, `targetType`,
`targetId`, and `actorUserId` filters as the list endpoint, so an
export always matches whatever the admin is currently viewing.
Columns: action, actor_username, target_type, target_id, created_at, metadata.
Capped at 50,000 rows per export to bound response size.
+3 -2
View File
@@ -2391,8 +2391,9 @@ paths:
summary: Export filtered audit log as CSV (admin)
description: |
Streams the filtered audit log as a CSV download.
Honors the same `action`, `forcedOnly`, `from`, and `to` filters as the
list endpoint.
Honors the same `action`, `forcedOnly`, `from`, `to`, `targetType`,
`targetId`, and `actorUserId` filters as the list endpoint, so an
export always matches whatever the admin is currently viewing.
Columns: action, actor_username, target_type, target_id, created_at, metadata.
Capped at 50,000 rows per export to bound response size.
parameters:
+3 -2
View File
@@ -2838,8 +2838,9 @@ export const ListAuditLogsResponse = zod.object({
/**
* Streams the filtered audit log as a CSV download.
Honors the same `action`, `forcedOnly`, `from`, and `to` filters as the
list endpoint.
Honors the same `action`, `forcedOnly`, `from`, `to`, `targetType`,
`targetId`, and `actorUserId` filters as the list endpoint, so an
export always matches whatever the admin is currently viewing.
Columns: action, actor_username, target_type, target_id, created_at, metadata.
Capped at 50,000 rows per export to bound response size.