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:
@@ -1,7 +1,7 @@
|
|||||||
// #197: Coverage for the actorUserId filter on GET /admin/audit-logs and the
|
// #197: end-to-end coverage for the audit log's actorUserId filter and its
|
||||||
// CSV export. Two distinct actor rows are seeded, then we assert the filter
|
// CSV-export counterpart. The buildWhere clause already supported the
|
||||||
// narrows to a single actor and that the export endpoint honours the same
|
// filter; these tests pin down the wiring (param parsing, filter
|
||||||
// param. Tagging metadata so we ignore unrelated rows in the same database.
|
// composition, and CSV export honouring it).
|
||||||
import { test, before, after } from "node:test";
|
import { test, before, after } from "node:test";
|
||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import pg from "pg";
|
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 STAMP = `${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 6)}`;
|
||||||
const TEST_TAG = `actor_filter_test_${STAMP}`;
|
const TEST_TAG = `actor_filter_test_${STAMP}`;
|
||||||
|
|
||||||
let adminId;
|
let adminAId;
|
||||||
let adminCookie;
|
let adminAUsername;
|
||||||
let actorAId;
|
let adminACookie;
|
||||||
let actorBId;
|
let adminBId;
|
||||||
|
let adminBUsername;
|
||||||
|
|
||||||
const insertedAuditIds = [];
|
const insertedAuditIds = [];
|
||||||
|
|
||||||
|
const TARGET_ID_A = 800400001;
|
||||||
|
const TARGET_ID_B = 800400002;
|
||||||
|
const TARGET_ID_C = 800400003;
|
||||||
|
|
||||||
async function loginAndGetCookie(username, password) {
|
async function loginAndGetCookie(username, password) {
|
||||||
const res = await fetch(`${API_BASE}/api/auth/login`, {
|
const res = await fetch(`${API_BASE}/api/auth/login`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -41,16 +47,16 @@ async function loginAndGetCookie(username, password) {
|
|||||||
.find((c) => c.startsWith("connect.sid="));
|
.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(
|
const r = await pool.query(
|
||||||
`INSERT INTO audit_logs (actor_user_id, action, target_type, target_id, metadata)
|
`INSERT INTO audit_logs (actor_user_id, action, target_type, target_id, metadata)
|
||||||
VALUES ($1, $2, $3, $4, $5::jsonb) RETURNING id`,
|
VALUES ($1, $2, $3, $4, $5::jsonb) RETURNING id`,
|
||||||
[
|
[
|
||||||
actorUserId,
|
actorId,
|
||||||
action,
|
action,
|
||||||
targetType,
|
targetType,
|
||||||
targetId,
|
targetId,
|
||||||
JSON.stringify({ testTag: TEST_TAG }),
|
JSON.stringify({ ...metadata, testTag: TEST_TAG }),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
insertedAuditIds.push(r.rows[0].id);
|
insertedAuditIds.push(r.rows[0].id);
|
||||||
@@ -58,118 +64,165 @@ async function insertAuditRow({ actorUserId, action, targetType, targetId }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
before(async () => {
|
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(
|
const a = await pool.query(
|
||||||
`INSERT INTO users (username, email, password_hash, display_name_en, preferred_language, is_active)
|
`INSERT INTO users (username, email, password_hash, display_name_en, preferred_language, is_active)
|
||||||
VALUES ($1, $2, $3, 'Actor Admin', 'en', true) RETURNING id`,
|
VALUES ($1, $2, $3, 'Audit Actor A', 'en', true) RETURNING id`,
|
||||||
[adminUsername, `${adminUsername}@example.com`, TEST_PASSWORD_HASH],
|
[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(
|
await pool.query(
|
||||||
`INSERT INTO user_roles (user_id, role_id) SELECT $1, id FROM roles WHERE name = 'admin'`,
|
`INSERT INTO user_roles (user_id, role_id) SELECT $1, id FROM roles WHERE name = 'admin'`,
|
||||||
[adminId],
|
[adminAId],
|
||||||
);
|
);
|
||||||
adminCookie = await loginAndGetCookie(adminUsername, TEST_PASSWORD);
|
await pool.query(
|
||||||
|
`INSERT INTO user_roles (user_id, role_id) SELECT $1, id FROM roles WHERE name = 'admin'`,
|
||||||
// Two distinct actors who will each leave audit rows.
|
[adminBId],
|
||||||
const aa = await pool.query(
|
);
|
||||||
`INSERT INTO users (username, email, password_hash, display_name_en, preferred_language, is_active)
|
adminACookie = await loginAndGetCookie(adminAUsername, TEST_PASSWORD);
|
||||||
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;
|
|
||||||
|
|
||||||
|
// Two rows by Admin A.
|
||||||
await insertAuditRow({
|
await insertAuditRow({
|
||||||
actorUserId: actorAId,
|
actorId: adminAId,
|
||||||
action: "settings.update",
|
action: "service.create",
|
||||||
targetType: "settings",
|
targetType: "service",
|
||||||
targetId: 1,
|
targetId: TARGET_ID_A,
|
||||||
|
metadata: { nameEn: "ASvc1" },
|
||||||
});
|
});
|
||||||
await insertAuditRow({
|
await insertAuditRow({
|
||||||
actorUserId: actorAId,
|
actorId: adminAId,
|
||||||
action: "settings.update",
|
action: "service.update",
|
||||||
targetType: "settings",
|
targetType: "service",
|
||||||
targetId: 1,
|
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({
|
await insertAuditRow({
|
||||||
actorUserId: actorBId,
|
actorId: adminBId,
|
||||||
action: "settings.update",
|
action: "service.create",
|
||||||
targetType: "settings",
|
targetType: "service",
|
||||||
targetId: 1,
|
targetId: TARGET_ID_C,
|
||||||
|
metadata: { nameEn: "BSvc" },
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
after(async () => {
|
after(async () => {
|
||||||
if (insertedAuditIds.length > 0) {
|
if (insertedAuditIds.length) {
|
||||||
await pool.query(`DELETE FROM audit_logs WHERE id = ANY($1::int[])`, [
|
await pool.query(`DELETE FROM audit_logs WHERE id = ANY($1::int[])`, [
|
||||||
insertedAuditIds,
|
insertedAuditIds,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
await pool.query(`DELETE FROM users WHERE id = ANY($1::int[])`, [
|
for (const id of [adminAId, adminBId]) {
|
||||||
[adminId, actorAId, actorBId].filter(Boolean),
|
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();
|
await pool.end();
|
||||||
});
|
});
|
||||||
|
|
||||||
function rowMatchesTag(entry) {
|
function ownEntries(body) {
|
||||||
return entry.metadata && entry.metadata.testTag === TEST_TAG;
|
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 () => {
|
async function listWithParams(qs) {
|
||||||
const res = await fetch(
|
const res = await fetch(`${API_BASE}/api/admin/audit-logs?${qs}`, {
|
||||||
`${API_BASE}/api/admin/audit-logs?actorUserId=${actorAId}&limit=200`,
|
headers: { Cookie: adminACookie },
|
||||||
{ headers: { Cookie: adminCookie } },
|
});
|
||||||
);
|
|
||||||
assert.equal(res.status, 200);
|
assert.equal(res.status, 200);
|
||||||
const body = await res.json();
|
return res.json();
|
||||||
const tagged = body.entries.filter(rowMatchesTag);
|
}
|
||||||
assert.equal(tagged.length, 2);
|
|
||||||
for (const entry of tagged) {
|
test("actorUserId filter narrows results to that acting user", async () => {
|
||||||
assert.equal(entry.actor && entry.actor.id, actorAId);
|
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 () => {
|
test("actorUserId filter excludes other actors on the same target_type", async () => {
|
||||||
const res = await fetch(
|
const body = await listWithParams(`actorUserId=${adminAId}&limit=200`);
|
||||||
`${API_BASE}/api/admin/audit-logs?actorUserId=${actorBId}&limit=200`,
|
const ours = ownEntries(body);
|
||||||
{ headers: { Cookie: adminCookie } },
|
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);
|
assert.ok(!ours.some((e) => e.targetId === TARGET_ID_C));
|
||||||
const body = await res.json();
|
});
|
||||||
const tagged = body.entries.filter(rowMatchesTag);
|
|
||||||
assert.equal(tagged.length, 1);
|
test("actorUserId combines with targetId to scope to one (actor, target)", async () => {
|
||||||
assert.equal(tagged[0].actor.id, actorBId);
|
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 () => {
|
test("invalid actorUserId returns 400", async () => {
|
||||||
const res = await fetch(
|
const res = await fetch(
|
||||||
`${API_BASE}/api/admin/audit-logs?actorUserId=not-a-number`,
|
`${API_BASE}/api/admin/audit-logs?actorUserId=not-a-number`,
|
||||||
{ headers: { Cookie: adminCookie } },
|
{ headers: { Cookie: adminACookie } },
|
||||||
);
|
);
|
||||||
assert.equal(res.status, 400);
|
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(
|
const res = await fetch(
|
||||||
`${API_BASE}/api/admin/audit-logs/export?actorUserId=${actorAId}`,
|
`${API_BASE}/api/admin/audit-logs/export?actorUserId=${adminAId}`,
|
||||||
{ headers: { Cookie: adminCookie } },
|
{ headers: { Cookie: adminACookie, Accept: "text/csv" } },
|
||||||
);
|
);
|
||||||
assert.equal(res.status, 200);
|
assert.equal(res.status, 200);
|
||||||
assert.match(
|
assert.match(res.headers.get("content-type") ?? "", /text\/csv/);
|
||||||
res.headers.get("content-type") ?? "",
|
|
||||||
/text\/csv/i,
|
|
||||||
);
|
|
||||||
const text = await res.text();
|
const text = await res.text();
|
||||||
// Filter to rows with our test tag in the metadata column. Two rows from
|
const ourLines = text
|
||||||
// Actor A, none from Actor B.
|
|
||||||
const taggedLines = text
|
|
||||||
.split(/\r?\n/)
|
.split(/\r?\n/)
|
||||||
.filter((line) => line.includes(TEST_TAG));
|
.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 |
@@ -658,6 +658,11 @@
|
|||||||
"allActions": "كل الإجراءات",
|
"allActions": "كل الإجراءات",
|
||||||
"actor": "المنفّذ",
|
"actor": "المنفّذ",
|
||||||
"allActors": "كل المنفّذين",
|
"allActors": "كل المنفّذين",
|
||||||
|
"actorSearch": "ابحث بالاسم أو @المعرّف",
|
||||||
|
"actorEmpty": "لا يوجد مستخدمون مطابقون.",
|
||||||
|
"actorWithName": "المنفّذ: {{name}}",
|
||||||
|
"actorWithId": "المنفّذ: #{{id}}",
|
||||||
|
"clearActorFilter": "مسح فلتر المنفّذ",
|
||||||
"from": "من",
|
"from": "من",
|
||||||
"to": "إلى",
|
"to": "إلى",
|
||||||
"apply": "تطبيق",
|
"apply": "تطبيق",
|
||||||
@@ -668,6 +673,7 @@
|
|||||||
"targetWithId": "الهدف: {{type}} #{{id}}",
|
"targetWithId": "الهدف: {{type}} #{{id}}",
|
||||||
"clearTargetFilter": "مسح فلتر الهدف"
|
"clearTargetFilter": "مسح فلتر الهدف"
|
||||||
},
|
},
|
||||||
|
"actorPivotAria": "تصفية سجل التدقيق على الإجراءات التي قام بها {{name}}",
|
||||||
"target": "الهدف: {{type}} #{{id}}",
|
"target": "الهدف: {{type}} #{{id}}",
|
||||||
"targetWithName": "الهدف: {{type}} #{{id}} · {{name}}",
|
"targetWithName": "الهدف: {{type}} #{{id}} · {{name}}",
|
||||||
"showing": "عرض {{shown}} من أصل {{total}}",
|
"showing": "عرض {{shown}} من أصل {{total}}",
|
||||||
|
|||||||
@@ -607,6 +607,11 @@
|
|||||||
"allActions": "All actions",
|
"allActions": "All actions",
|
||||||
"actor": "Actor",
|
"actor": "Actor",
|
||||||
"allActors": "All actors",
|
"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",
|
"from": "From",
|
||||||
"to": "To",
|
"to": "To",
|
||||||
"apply": "Apply",
|
"apply": "Apply",
|
||||||
@@ -617,6 +622,7 @@
|
|||||||
"targetWithId": "Target: {{type}} #{{id}}",
|
"targetWithId": "Target: {{type}} #{{id}}",
|
||||||
"clearTargetFilter": "Clear target filter"
|
"clearTargetFilter": "Clear target filter"
|
||||||
},
|
},
|
||||||
|
"actorPivotAria": "Filter audit log to actions by {{name}}",
|
||||||
"target": "Target: {{type}} #{{id}}",
|
"target": "Target: {{type}} #{{id}}",
|
||||||
"targetWithName": "Target: {{type}} #{{id}} · {{name}}",
|
"targetWithName": "Target: {{type}} #{{id}} · {{name}}",
|
||||||
"showing": "Showing {{shown}} of {{total}}",
|
"showing": "Showing {{shown}} of {{total}}",
|
||||||
|
|||||||
@@ -152,6 +152,15 @@ import {
|
|||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
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 { useToast } from "@/hooks/use-toast";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { formatDate as formatDateUtil, formatWeekday as formatWeekdayUtil } from "@/lib/i18n-format";
|
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 params = new URLSearchParams(raw);
|
||||||
const previousSection = params.get("section");
|
const previousSection = params.get("section");
|
||||||
params.set("section", section);
|
params.set("section", section);
|
||||||
// Section-scoped params (the audit log's targetType / targetId deep
|
// Section-scoped params (the audit log's targetType / targetId / actorId
|
||||||
// links) only make sense within their owning section. Switching to a
|
// deep links) only make sense within their owning section. Switching to a
|
||||||
// different section drops them so the next visit starts fresh.
|
// different section drops them so the next visit starts fresh.
|
||||||
if (previousSection !== section) {
|
if (previousSection !== section) {
|
||||||
params.delete("targetType");
|
params.delete("targetType");
|
||||||
params.delete("targetId");
|
params.delete("targetId");
|
||||||
|
params.delete("actorId");
|
||||||
}
|
}
|
||||||
const newHash = `#${params.toString()}`;
|
const newHash = `#${params.toString()}`;
|
||||||
if (window.location.hash !== newHash) {
|
if (window.location.hash !== newHash) {
|
||||||
@@ -6572,10 +6582,14 @@ function AuditLogRow({
|
|||||||
entry,
|
entry,
|
||||||
lang,
|
lang,
|
||||||
onPivotToTarget,
|
onPivotToTarget,
|
||||||
|
onPivotToActor,
|
||||||
}: {
|
}: {
|
||||||
entry: AuditLogEntry;
|
entry: AuditLogEntry;
|
||||||
lang: string;
|
lang: string;
|
||||||
onPivotToTarget: (pivot: AuditTargetPivot) => void;
|
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 { t } = useTranslation();
|
||||||
const [expanded, setExpanded] = useState(false);
|
const [expanded, setExpanded] = useState(false);
|
||||||
@@ -6588,22 +6602,55 @@ function AuditLogRow({
|
|||||||
const chips = isForced ? dependencyChips(entry, t) : [];
|
const chips = isForced ? dependencyChips(entry, t) : [];
|
||||||
const deletedName = deletedEntityName(entry, lang);
|
const deletedName = deletedEntityName(entry, lang);
|
||||||
const showDeletedTarget = deletedName != null && entry.targetId != null;
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
className="glass-panel rounded-2xl p-3 space-y-2"
|
className="glass-panel rounded-2xl p-3 space-y-2"
|
||||||
data-testid={`audit-row-${entry.id}`}
|
data-testid={`audit-row-${entry.id}`}
|
||||||
>
|
>
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
<LeaderboardAvatar
|
{canPivotActor ? (
|
||||||
src={actor?.avatarUrl ?? null}
|
<button
|
||||||
initial={initial}
|
type="button"
|
||||||
alt={actor?.username ?? ""}
|
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="min-w-0 flex-1">
|
||||||
<div className="flex flex-wrap items-baseline gap-x-2 gap-y-0.5">
|
<div className="flex flex-wrap items-baseline gap-x-2 gap-y-0.5">
|
||||||
<span className="font-medium text-sm text-foreground truncate">
|
{canPivotActor ? (
|
||||||
{actorLabel(actor, lang)}
|
<button
|
||||||
</span>
|
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 && (
|
{actor && (
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">
|
||||||
@{actor.username}
|
@{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() {
|
function AuditLogPanel() {
|
||||||
const { t, i18n } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
const lang = i18n.language;
|
const lang = i18n.language;
|
||||||
@@ -6894,7 +7087,13 @@ function AuditLogPanel() {
|
|||||||
const [exportError, setExportError] = useState<string | null>(null);
|
const [exportError, setExportError] = useState<string | null>(null);
|
||||||
// #197: actor filter — applies immediately like the History tabs do, since
|
// #197: actor filter — applies immediately like the History tabs do, since
|
||||||
// picking a different person is the natural way to commit the choice.
|
// 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
|
// Deep-link target filters. Initialised from the URL hash so a user who
|
||||||
// pastes /admin#section=audit-log&targetType=group lands directly on a
|
// pastes /admin#section=audit-log&targetType=group lands directly on a
|
||||||
// pre-filtered view. Updated by chip clicks and the explicit clear button.
|
// pre-filtered view. Updated by chip clicks and the explicit clear button.
|
||||||
@@ -6980,6 +7179,7 @@ function AuditLogPanel() {
|
|||||||
setTargetIdFilter(null);
|
setTargetIdFilter(null);
|
||||||
setActorUserId("");
|
setActorUserId("");
|
||||||
syncAuditHashTarget({ targetType: "", targetId: null });
|
syncAuditHashTarget({ targetType: "", targetId: null });
|
||||||
|
syncAuditHashActor(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
const toggleForcedOnly = () => {
|
const toggleForcedOnly = () => {
|
||||||
@@ -7009,6 +7209,23 @@ function AuditLogPanel() {
|
|||||||
syncAuditHashTarget({ targetType: "", targetId: null });
|
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 loadMore = () => setPageLimit((n) => Math.min(n + 50, 200));
|
||||||
|
|
||||||
const handleExportCsv = async () => {
|
const handleExportCsv = async () => {
|
||||||
@@ -7066,6 +7283,25 @@ function AuditLogPanel() {
|
|||||||
: t("admin.audit.filters.targetType", { type: targetTypeFilter })
|
: t("admin.audit.filters.targetType", { type: targetTypeFilter })
|
||||||
: null;
|
: 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 (
|
return (
|
||||||
<div className="space-y-3" data-testid="audit-log-panel">
|
<div className="space-y-3" data-testid="audit-log-panel">
|
||||||
<div className="glass-panel rounded-2xl p-3 space-y-3">
|
<div className="glass-panel rounded-2xl p-3 space-y-3">
|
||||||
@@ -7104,6 +7340,24 @@ function AuditLogPanel() {
|
|||||||
</button>
|
</button>
|
||||||
</span>
|
</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>
|
||||||
<div className="grid gap-2 sm:grid-cols-2 md:grid-cols-5">
|
<div className="grid gap-2 sm:grid-cols-2 md:grid-cols-5">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
@@ -7128,29 +7382,20 @@ function AuditLogPanel() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label className="text-xs">{t("admin.audit.filters.actor")}</Label>
|
<Label className="text-xs">{t("admin.audit.filters.actor")}</Label>
|
||||||
<select
|
<AuditActorPicker
|
||||||
value={actorUserId === "" ? "" : String(actorUserId)}
|
actors={sortedActors}
|
||||||
onChange={(e) => {
|
lang={lang}
|
||||||
const v = e.target.value;
|
actorUserId={actorUserId}
|
||||||
setActorUserId(v === "" ? "" : Number(v));
|
onChange={(next) => {
|
||||||
|
setActorUserId(next);
|
||||||
setPageLimit(50);
|
setPageLimit(50);
|
||||||
|
syncAuditHashActor(next === "" ? null : next);
|
||||||
}}
|
}}
|
||||||
className="bg-white/70 border border-slate-200 rounded-lg px-3 py-2 text-sm w-full"
|
placeholder={t("admin.audit.filters.allActors")}
|
||||||
data-testid="audit-actor-filter"
|
searchPlaceholder={t("admin.audit.filters.actorSearch")}
|
||||||
>
|
emptyMessage={t("admin.audit.filters.actorEmpty")}
|
||||||
<option value="">{t("admin.audit.filters.allActors")}</option>
|
clearLabel={t("admin.audit.filters.clearActorFilter")}
|
||||||
{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>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label className="text-xs">{t("admin.audit.filters.from")}</Label>
|
<Label className="text-xs">{t("admin.audit.filters.from")}</Label>
|
||||||
@@ -7244,6 +7489,7 @@ function AuditLogPanel() {
|
|||||||
entry={entry}
|
entry={entry}
|
||||||
lang={lang}
|
lang={lang}
|
||||||
onPivotToTarget={pivotToTarget}
|
onPivotToTarget={pivotToTarget}
|
||||||
|
onPivotToActor={pivotToActor}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -7339,8 +7339,9 @@ export function useListAuditLogs<
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Streams the filtered audit log as a CSV download.
|
* Streams the filtered audit log as a CSV download.
|
||||||
Honors the same `action`, `forcedOnly`, `from`, and `to` filters as the
|
Honors the same `action`, `forcedOnly`, `from`, `to`, `targetType`,
|
||||||
list endpoint.
|
`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.
|
Columns: action, actor_username, target_type, target_id, created_at, metadata.
|
||||||
Capped at 50,000 rows per export to bound response size.
|
Capped at 50,000 rows per export to bound response size.
|
||||||
|
|
||||||
|
|||||||
@@ -2391,8 +2391,9 @@ paths:
|
|||||||
summary: Export filtered audit log as CSV (admin)
|
summary: Export filtered audit log as CSV (admin)
|
||||||
description: |
|
description: |
|
||||||
Streams the filtered audit log as a CSV download.
|
Streams the filtered audit log as a CSV download.
|
||||||
Honors the same `action`, `forcedOnly`, `from`, and `to` filters as the
|
Honors the same `action`, `forcedOnly`, `from`, `to`, `targetType`,
|
||||||
list endpoint.
|
`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.
|
Columns: action, actor_username, target_type, target_id, created_at, metadata.
|
||||||
Capped at 50,000 rows per export to bound response size.
|
Capped at 50,000 rows per export to bound response size.
|
||||||
parameters:
|
parameters:
|
||||||
|
|||||||
@@ -2838,8 +2838,9 @@ export const ListAuditLogsResponse = zod.object({
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Streams the filtered audit log as a CSV download.
|
* Streams the filtered audit log as a CSV download.
|
||||||
Honors the same `action`, `forcedOnly`, `from`, and `to` filters as the
|
Honors the same `action`, `forcedOnly`, `from`, `to`, `targetType`,
|
||||||
list endpoint.
|
`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.
|
Columns: action, actor_username, target_type, target_id, created_at, metadata.
|
||||||
Capped at 50,000 rows per export to bound response size.
|
Capped at 50,000 rows per export to bound response size.
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user