diff --git a/artifacts/api-server/src/routes/audit.ts b/artifacts/api-server/src/routes/audit.ts index 975f3542..62bc7e76 100644 --- a/artifacts/api-server/src/routes/audit.ts +++ b/artifacts/api-server/src/routes/audit.ts @@ -38,6 +38,8 @@ type AuditFilters = { toDateExclusive: Date | null; targetType: string; targetId: number | null; + // #197: optional actor filter so admins can ask "what did user X do?" + actorUserId: number | null; }; function parseFilters(req: Request, res: Response): AuditFilters | null { @@ -51,6 +53,8 @@ function parseFilters(req: Request, res: Response): AuditFilters | null { typeof req.query.targetType === "string" ? req.query.targetType.trim() : ""; const targetIdStr = typeof req.query.targetId === "string" ? req.query.targetId.trim() : ""; + const actorUserIdStr = + typeof req.query.actorUserId === "string" ? req.query.actorUserId.trim() : ""; let fromDate: Date | null = null; let toDateExclusive: Date | null = null; @@ -82,7 +86,26 @@ function parseFilters(req: Request, res: Response): AuditFilters | null { } targetId = parsed; } - return { action, forcedOnly, fromDate, toDateExclusive, targetType, targetId }; + let actorUserId: number | null = null; + if (actorUserIdStr) { + const parsed = Number(actorUserIdStr); + if (!Number.isFinite(parsed) || !Number.isInteger(parsed) || parsed < 1) { + res + .status(400) + .json({ error: "Invalid 'actorUserId' (expected positive integer)" }); + return null; + } + actorUserId = parsed; + } + return { + action, + forcedOnly, + fromDate, + toDateExclusive, + targetType, + targetId, + actorUserId, + }; } function buildWhere(filters: AuditFilters): SQL | undefined { @@ -108,6 +131,8 @@ function buildWhere(filters: AuditFilters): SQL | undefined { conditions.push(eq(auditLogsTable.targetType, filters.targetType)); if (filters.targetId != null) conditions.push(eq(auditLogsTable.targetId, filters.targetId)); + if (filters.actorUserId != null) + conditions.push(eq(auditLogsTable.actorUserId, filters.actorUserId)); return conditions.length > 0 ? and(...conditions) : undefined; } diff --git a/artifacts/api-server/src/routes/services.ts b/artifacts/api-server/src/routes/services.ts index 7fc9ccbc..1dbdedb0 100644 --- a/artifacts/api-server/src/routes/services.ts +++ b/artifacts/api-server/src/routes/services.ts @@ -164,6 +164,10 @@ router.delete("/services/:id", requireAdmin, async (req, res): Promise => await tx.delete(servicesTable).where(eq(servicesTable.id, serviceId)); }); + // #195: write an audit row for every service deletion. Forced deletes keep + // the dedicated `service.force_delete` action so the existing forced-only + // filter and metadata shape (orderCount) stay backwards compatible; plain + // deletes get a `service.delete` row mirroring the user.delete pattern. if (hasDeps && force) { await db.insert(auditLogsTable).values({ actorUserId: req.session.userId ?? null, @@ -172,9 +176,21 @@ router.delete("/services/:id", requireAdmin, async (req, res): Promise => targetId: serviceId, metadata: { nameEn: existing.nameEn, + nameAr: existing.nameAr, orderCount, }, }); + } else { + await db.insert(auditLogsTable).values({ + actorUserId: req.session.userId ?? null, + action: "service.delete", + targetType: "service", + targetId: serviceId, + metadata: { + nameEn: existing.nameEn, + nameAr: existing.nameAr, + }, + }); } res.sendStatus(204); diff --git a/artifacts/api-server/tests/audit-logs-actor-filter.test.mjs b/artifacts/api-server/tests/audit-logs-actor-filter.test.mjs new file mode 100644 index 00000000..884769a8 --- /dev/null +++ b/artifacts/api-server/tests/audit-logs-actor-filter.test.mjs @@ -0,0 +1,175 @@ +// #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. +import { test, before, after } from "node:test"; +import assert from "node:assert/strict"; +import pg from "pg"; + +const API_BASE = process.env.TEST_API_BASE ?? "http://localhost:8080"; +const DATABASE_URL = process.env.DATABASE_URL; +if (!DATABASE_URL) { + throw new Error("DATABASE_URL must be set to run these tests"); +} + +const TEST_PASSWORD = "TestPass123!"; +const TEST_PASSWORD_HASH = + "$2b$10$Bs636ukPMyz01nKrsi.5m.JlDXSN22AVCvn8cgPWWDbo5yJRQX2vu"; + +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; +const insertedAuditIds = []; + +async function loginAndGetCookie(username, password) { + const res = await fetch(`${API_BASE}/api/auth/login`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ username, password }), + }); + assert.equal(res.status, 200, `login expected 200, got ${res.status}`); + const setCookie = res.headers.get("set-cookie"); + return setCookie + .split(",") + .map((c) => c.split(";")[0].trim()) + .find((c) => c.startsWith("connect.sid=")); +} + +async function insertAuditRow({ actorUserId, action, targetType, targetId }) { + 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, + action, + targetType, + targetId, + JSON.stringify({ testTag: TEST_TAG }), + ], + ); + insertedAuditIds.push(r.rows[0].id); + return r.rows[0].id; +} + +before(async () => { + const adminUsername = `audit_actor_admin_${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], + ); + adminId = a.rows[0].id; + await pool.query( + `INSERT INTO user_roles (user_id, role_id) SELECT $1, id FROM roles WHERE name = 'admin'`, + [adminId], + ); + 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 insertAuditRow({ + actorUserId: actorAId, + action: "settings.update", + targetType: "settings", + targetId: 1, + }); + await insertAuditRow({ + actorUserId: actorAId, + action: "settings.update", + targetType: "settings", + targetId: 1, + }); + await insertAuditRow({ + actorUserId: actorBId, + action: "settings.update", + targetType: "settings", + targetId: 1, + }); +}); + +after(async () => { + if (insertedAuditIds.length > 0) { + 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), + ]); + await pool.end(); +}); + +function rowMatchesTag(entry) { + return entry.metadata && entry.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 } }, + ); + 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); + } +}); + +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 } }, + ); + 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); +}); + +test("invalid actorUserId returns 400", async () => { + const res = await fetch( + `${API_BASE}/api/admin/audit-logs?actorUserId=not-a-number`, + { headers: { Cookie: adminCookie } }, + ); + assert.equal(res.status, 400); +}); + +test("CSV export honours actorUserId filter", async () => { + const res = await fetch( + `${API_BASE}/api/admin/audit-logs/export?actorUserId=${actorAId}`, + { headers: { Cookie: adminCookie } }, + ); + assert.equal(res.status, 200); + assert.match( + res.headers.get("content-type") ?? "", + /text\/csv/i, + ); + 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 + .split(/\r?\n/) + .filter((line) => line.includes(TEST_TAG)); + assert.equal(taggedLines.length, 2); +}); diff --git a/artifacts/api-server/tests/delete-force-warnings.test.mjs b/artifacts/api-server/tests/delete-force-warnings.test.mjs index d5a7203f..ff9b9ab3 100644 --- a/artifacts/api-server/tests/delete-force-warnings.test.mjs +++ b/artifacts/api-server/tests/delete-force-warnings.test.mjs @@ -355,8 +355,8 @@ test("DELETE /api/apps/:id?force=true deletes app and writes audit log", async ( test("DELETE /api/services/:id without dependents succeeds (no force needed)", async () => { const s = await pool.query( - `INSERT INTO services (name_ar, name_en, price, is_available) VALUES ('خ', $1, 1.00, true) RETURNING id`, - [`${SVC_PREFIX}Clean`], + `INSERT INTO services (name_ar, name_en, price, is_available) VALUES ($1, $2, 1.00, true) RETURNING id`, + [`${SVC_PREFIX}CleanAr`, `${SVC_PREFIX}Clean`], ); const id = s.rows[0].id; createdServiceIds.push(id); @@ -366,6 +366,43 @@ test("DELETE /api/services/:id without dependents succeeds (no force needed)", a headers: { Cookie: adminCookie }, }); assert.equal(res.status, 204); + + // #195: plain (no-deps) deletes now write a `service.delete` audit row + // carrying both nameEn and nameAr so the audit log can render the service + // name in either locale even after the row is gone. + const audit = await pool.query( + `SELECT action, metadata FROM audit_logs WHERE target_type = 'service' AND target_id = $1`, + [String(id)], + ); + assert.equal(audit.rowCount, 1); + assert.equal(audit.rows[0].action, "service.delete"); + assert.equal(audit.rows[0].metadata.nameEn, `${SVC_PREFIX}Clean`); + assert.equal(audit.rows[0].metadata.nameAr, `${SVC_PREFIX}CleanAr`); +}); + +test("DELETE /api/services/:id?force=true with no dependents writes service.delete (not service.force_delete)", async () => { + // #195: the `force` query has no effect when there are no dependents, so + // the plain `service.delete` row is correct here. `service.force_delete` + // is reserved for the path that actually had to clear dependent orders. + const s = await pool.query( + `INSERT INTO services (name_ar, name_en, price, is_available) VALUES ($1, $2, 1.50, true) RETURNING id`, + [`${SVC_PREFIX}ForceCleanAr`, `${SVC_PREFIX}ForceClean`], + ); + const id = s.rows[0].id; + createdServiceIds.push(id); + + const res = await fetch(`${API_BASE}/api/services/${id}?force=true`, { + method: "DELETE", + headers: { Cookie: adminCookie }, + }); + assert.equal(res.status, 204); + + const audit = await pool.query( + `SELECT action FROM audit_logs WHERE target_type = 'service' AND target_id = $1`, + [String(id)], + ); + assert.equal(audit.rowCount, 1); + assert.equal(audit.rows[0].action, "service.delete"); }); test("DELETE /api/services/:id with orders returns 409 with counts", async () => { diff --git a/artifacts/tx-os/public/opengraph.jpg b/artifacts/tx-os/public/opengraph.jpg index 09a08a76..71bff25a 100644 Binary files a/artifacts/tx-os/public/opengraph.jpg and b/artifacts/tx-os/public/opengraph.jpg differ diff --git a/artifacts/tx-os/src/__tests__/audit-summary.test.mjs b/artifacts/tx-os/src/__tests__/audit-summary.test.mjs index 2cba06dc..33859df2 100644 --- a/artifacts/tx-os/src/__tests__/audit-summary.test.mjs +++ b/artifacts/tx-os/src/__tests__/audit-summary.test.mjs @@ -437,3 +437,199 @@ test("unknown action returns null so callers can hide the summary line", () => { ); assert.equal(result, null); }); + +// #178: cover the user.delete branches added by #194 (display name resolution +// across EN/AR locales) and the user.force_delete branches that share the same +// shape, so the formatter doesn't silently regress when callers tweak metadata. + +test("user.delete uses id-only key when username is missing", () => { + const result = formatAuditSummary( + entry({ + action: "user.delete", + targetType: "user", + targetId: 42, + metadata: {}, + }), + makeT(), + "en", + ); + const { key, opts } = parse(result); + assert.equal(key, "admin.audit.summary.user.delete"); + assert.equal(opts.username, "#42"); +}); + +test("user.delete uses delete (no name) key when only username present", () => { + const result = formatAuditSummary( + entry({ + action: "user.delete", + targetType: "user", + targetId: 7, + metadata: { username: "alice" }, + }), + makeT(), + "en", + ); + const { key, opts } = parse(result); + assert.equal(key, "admin.audit.summary.user.delete"); + assert.equal(opts.username, "alice"); +}); + +test("user.delete picks displayNameEn in EN locale when both names present", () => { + const result = formatAuditSummary( + entry({ + action: "user.delete", + targetType: "user", + targetId: 7, + metadata: { + username: "alice", + displayNameEn: "Alice Smith", + displayNameAr: "أليس", + }, + }), + makeT(), + "en", + ); + const { key, opts } = parse(result); + assert.equal(key, "admin.audit.summary.user.deleteWithName"); + assert.equal(opts.username, "alice"); + assert.equal(opts.displayName, "Alice Smith"); +}); + +test("user.delete picks displayNameAr in AR locale when both names present", () => { + const result = formatAuditSummary( + entry({ + action: "user.delete", + targetType: "user", + targetId: 7, + metadata: { + username: "alice", + displayNameEn: "Alice Smith", + displayNameAr: "أليس", + }, + }), + makeT(), + "ar", + ); + const { key, opts } = parse(result); + assert.equal(key, "admin.audit.summary.user.deleteWithName"); + assert.equal(opts.displayName, "أليس"); +}); + +test("user.delete falls back to displayNameEn in AR locale when AR missing", () => { + const result = formatAuditSummary( + entry({ + action: "user.delete", + targetType: "user", + targetId: 7, + metadata: { username: "alice", displayNameEn: "Alice Smith" }, + }), + makeT(), + "ar", + ); + const { key, opts } = parse(result); + assert.equal(key, "admin.audit.summary.user.deleteWithName"); + assert.equal(opts.displayName, "Alice Smith"); +}); + +test("user.delete with force flag in metadata routes to forceDelete branch", () => { + const result = formatAuditSummary( + entry({ + action: "user.delete", + targetType: "user", + targetId: 7, + metadata: { username: "bob", force: true }, + }), + makeT(), + "en", + ); + const { key } = parse(result); + assert.equal(key, "admin.audit.summary.user.forceDelete"); +}); + +test("user.delete with force + display name uses forceDeleteWithName branch", () => { + const result = formatAuditSummary( + entry({ + action: "user.delete", + targetType: "user", + targetId: 7, + metadata: { + username: "bob", + displayNameEn: "Bob Jones", + force: true, + }, + }), + makeT(), + "en", + ); + const { key, opts } = parse(result); + assert.equal(key, "admin.audit.summary.user.forceDeleteWithName"); + assert.equal(opts.displayName, "Bob Jones"); +}); + +// #195: plain service.delete now writes an audit row; cover the new summary +// branch (with/without nameEn/nameAr) plus the existing service.force_delete +// path which now also reads nameAr in the AR locale. + +test("service.delete uses deleteId fallback when no name in metadata", () => { + const result = formatAuditSummary( + entry({ + action: "service.delete", + targetType: "service", + targetId: 99, + metadata: {}, + }), + makeT(), + "en", + ); + const { key, opts } = parse(result); + assert.equal(key, "admin.audit.summary.service.deleteId"); + assert.equal(opts.id, 99); +}); + +test("service.delete prefers nameEn in EN locale", () => { + const result = formatAuditSummary( + entry({ + action: "service.delete", + targetType: "service", + targetId: 99, + metadata: { nameEn: "Coffee", nameAr: "قهوة" }, + }), + makeT(), + "en", + ); + const { key, opts } = parse(result); + assert.equal(key, "admin.audit.summary.service.delete"); + assert.equal(opts.name, "Coffee"); +}); + +test("service.delete prefers nameAr in AR locale", () => { + const result = formatAuditSummary( + entry({ + action: "service.delete", + targetType: "service", + targetId: 99, + metadata: { nameEn: "Coffee", nameAr: "قهوة" }, + }), + makeT(), + "ar", + ); + const { key, opts } = parse(result); + assert.equal(key, "admin.audit.summary.service.delete"); + assert.equal(opts.name, "قهوة"); +}); + +test("service.force_delete uses nameAr in AR locale when both names present", () => { + const result = formatAuditSummary( + entry({ + action: "service.force_delete", + targetType: "service", + targetId: 99, + metadata: { nameEn: "Coffee", nameAr: "قهوة", orderCount: 3 }, + }), + makeT(), + "ar", + ); + const { key, opts } = parse(result); + assert.equal(key, "admin.audit.summary.service.forceDelete"); + assert.equal(opts.name, "قهوة"); +}); diff --git a/artifacts/tx-os/src/lib/audit-summary.ts b/artifacts/tx-os/src/lib/audit-summary.ts index 77a8e9c6..640c1f64 100644 --- a/artifacts/tx-os/src/lib/audit-summary.ts +++ b/artifacts/tx-os/src/lib/audit-summary.ts @@ -281,11 +281,27 @@ export function formatAuditSummary( asString(meta.permissionName) ?? (asNumber(meta.permissionId) != null ? `#${meta.permissionId}` : "?"), }); - case "service.force_delete": + case "service.delete": { + // #195: plain (non-force) service deletions get a friendlier summary + // mirroring user.delete. Falls back to id when nothing readable exists. + const nameEn = asString(meta.nameEn); + const nameAr = asString(meta.nameAr); + const name = lang === "ar" ? nameAr ?? nameEn : nameEn ?? nameAr; + return name + ? t("admin.audit.summary.service.delete", { name }) + : t("admin.audit.summary.service.deleteId", { + id: targetId ?? "?", + }); + } + case "service.force_delete": { + const nameEn = asString(meta.nameEn); + const nameAr = asString(meta.nameAr); + const name = lang === "ar" ? nameAr ?? nameEn : nameEn ?? nameAr; return t("admin.audit.summary.service.forceDelete", { - name: asString(meta.nameEn) ?? `#${targetId ?? "?"}`, + name: name ?? `#${targetId ?? "?"}`, orders: unitLabel(t, "order", asNumber(meta.orderCount) ?? 0), }); + } case "settings.update": { const changes = asRecord(meta.changes); const totalChanges = Object.keys(changes).length; diff --git a/artifacts/tx-os/src/locales/ar.json b/artifacts/tx-os/src/locales/ar.json index d97b8ad9..586905b9 100644 --- a/artifacts/tx-os/src/locales/ar.json +++ b/artifacts/tx-os/src/locales/ar.json @@ -658,6 +658,8 @@ "filters": { "action": "الإجراء", "allActions": "كل الإجراءات", + "actor": "المنفّذ", + "allActors": "كل المنفّذين", "from": "من", "to": "إلى", "apply": "تطبيق", @@ -803,6 +805,8 @@ "permissionRemove": "تمت إزالة الصلاحية '{{permission}}' من الدور '{{name}}'" }, "service": { + "delete": "تم حذف الخدمة '{{name}}'", + "deleteId": "تم حذف الخدمة رقم {{id}}", "forceDelete": "تم حذف الخدمة '{{name}}' قسراً (تأثرت {{orders}})" }, "settings": { diff --git a/artifacts/tx-os/src/locales/en.json b/artifacts/tx-os/src/locales/en.json index 5316eae5..ccd41fae 100644 --- a/artifacts/tx-os/src/locales/en.json +++ b/artifacts/tx-os/src/locales/en.json @@ -607,6 +607,8 @@ "filters": { "action": "Action", "allActions": "All actions", + "actor": "Actor", + "allActors": "All actors", "from": "From", "to": "To", "apply": "Apply", @@ -704,6 +706,8 @@ "permissionRemove": "Removed permission '{{permission}}' from role '{{name}}'" }, "service": { + "delete": "Deleted service '{{name}}'", + "deleteId": "Deleted service #{{id}}", "forceDelete": "Force-deleted service '{{name}}' (affected {{orders}})" }, "settings": { diff --git a/artifacts/tx-os/src/pages/admin.tsx b/artifacts/tx-os/src/pages/admin.tsx index b50edf49..256d5be5 100644 --- a/artifacts/tx-os/src/pages/admin.tsx +++ b/artifacts/tx-os/src/pages/admin.tsx @@ -5981,6 +5981,9 @@ function AuditLogPanel() { const [pageLimit, setPageLimit] = useState(50); const [exporting, setExporting] = useState(false); const [exportError, setExportError] = useState(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(""); // 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. @@ -6009,8 +6012,30 @@ function AuditLogPanel() { ...(appliedTo ? { to: appliedTo } : {}), ...(targetTypeFilter ? { targetType: targetTypeFilter } : {}), ...(targetIdFilter != null ? { targetId: targetIdFilter } : {}), + ...(actorUserId !== "" ? { actorUserId: actorUserId as number } : {}), }; + // #197: list of users for the actor dropdown. Reuses the existing admin + // users query (already cached for the rest of the admin page). + const { data: usersForActor } = useListUsers(undefined, { + query: { queryKey: getListUsersQueryKey() }, + }); + const sortedActors = useMemo(() => { + const list = (usersForActor ?? []).slice(); + list.sort((a, b) => { + const aLabel = + (lang === "ar" + ? a.displayNameAr ?? a.displayNameEn + : a.displayNameEn ?? a.displayNameAr) ?? a.username; + const bLabel = + (lang === "ar" + ? b.displayNameAr ?? b.displayNameEn + : b.displayNameEn ?? b.displayNameAr) ?? b.username; + return aLabel.localeCompare(bLabel); + }); + return list; + }, [usersForActor, lang]); + const queryKey = getListAuditLogsQueryKey(params); const { data, isLoading, isFetching } = useListAuditLogs(params, { query: { queryKey, enabled: filtersValid }, @@ -6042,6 +6067,7 @@ function AuditLogPanel() { setPageLimit(50); setTargetTypeFilter(""); setTargetIdFilter(null); + setActorUserId(""); syncAuditHashTarget({ targetType: "", targetId: null }); }; @@ -6089,6 +6115,7 @@ function AuditLogPanel() { ...(appliedTo ? { to: appliedTo } : {}), ...(targetTypeFilter ? { targetType: targetTypeFilter } : {}), ...(targetIdFilter != null ? { targetId: targetIdFilter } : {}), + ...(actorUserId !== "" ? { actorUserId: actorUserId as number } : {}), }); const res = await fetch(url, { method: "GET", @@ -6167,7 +6194,7 @@ function AuditLogPanel() { )} -
+
{ + const v = e.target.value; + setActorUserId(v === "" ? "" : Number(v)); + setPageLimit(50); + }} + className="bg-white/70 border border-slate-200 rounded-lg px-3 py-2 text-sm w-full" + data-testid="audit-actor-filter" + > + + {sortedActors.map((u) => { + const label = + (lang === "ar" + ? u.displayNameAr ?? u.displayNameEn + : u.displayNameEn ?? u.displayNameAr) ?? u.username; + return ( + + ); + })} + +