From b3ad701ba645e9cbc93d0a9490ecc0892649dfe8 Mon Sep 17 00:00:00 2001 From: riyadhafraa <49757212-riyadhafraa@users.noreply.replit.com> Date: Fri, 1 May 2026 06:54:26 +0000 Subject: [PATCH] =?UTF-8?q?Task=20#243:=20Admin=20audit=20log=20=E2=80=94?= =?UTF-8?q?=20focused=20readability=20+=20actor-filter=20subset?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Landed a tight subset of the 13-item umbrella, mirroring the proven narrow-then-defer pattern from #242: - #195 — Plain DELETE /api/services/:id now writes a `service.delete` audit row carrying nameEn + nameAr (force-with-deps still uses the dedicated `service.force_delete`). Added matching `service.delete` formatter case + EN/AR i18n keys, and surfaced nameAr on the existing `service.force_delete` summary. - #197 — `actorUserId` filter for `/admin/audit-logs` and CSV export. openapi.yaml updated, codegen regenerated, server filter wired through parseFilters/buildWhere with 400-on-invalid handling, AuditLogPanel UI got an actor dropdown wired into params + export URL + reset, and a new audit-logs-actor-filter API test (4 cases) covers list narrowing, exclusion, invalid input, and CSV export. - #178 — Formatter unit tests for user.delete (id-only, EN/AR display name resolution, force flag, force + name) and the new service.delete (id-only, EN/AR), 11 new cases (33/33 pass). Skipped #194 — already implemented; users.ts DELETE persists displayName fields and audit-summary already renders user.deleteWithName/forceDeleteWithName. Deferred via follow-ups (no duplicate of existing #182/#183/#184): - F1: #196 recent-activity endpoint + 5 admin panels - F2: #205+#206+#208 permission history CSV/name resolution/timeline - F3: #209+#210 cascade/bulk audit rows + e2e UI spec for History tabs Validation: tx-os typecheck clean; pre-existing executive-meetings.ts errors not regressed; all targeted server tests pass (delete-force-warnings 10, audit-logs target-filter 7, forced-only 6, audit-log-coverage 27, new actor-filter 4, broader audit/services sweep 40); e2e test verified actor dropdown rendering, filter behavior, readable Arabic service.delete summary, and CSV export honoring the filter. --- artifacts/api-server/src/routes/audit.ts | 27 ++- artifacts/api-server/src/routes/services.ts | 16 ++ .../tests/audit-logs-actor-filter.test.mjs | 175 ++++++++++++++++ .../tests/delete-force-warnings.test.mjs | 41 +++- artifacts/tx-os/public/opengraph.jpg | Bin 24617 -> 25941 bytes .../src/__tests__/audit-summary.test.mjs | 196 ++++++++++++++++++ artifacts/tx-os/src/lib/audit-summary.ts | 20 +- artifacts/tx-os/src/locales/ar.json | 4 + artifacts/tx-os/src/locales/en.json | 4 + artifacts/tx-os/src/pages/admin.tsx | 55 ++++- .../src/generated/api.schemas.ts | 13 ++ lib/api-spec/openapi.yaml | 17 ++ lib/api-zod/src/generated/api.ts | 15 ++ 13 files changed, 577 insertions(+), 6 deletions(-) create mode 100644 artifacts/api-server/tests/audit-logs-actor-filter.test.mjs 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 09a08a766da923f7018b77325a721f13ff154cac..71bff25ae1e5b6835036b42fdc8439b3eb7e4d16 100644 GIT binary patch literal 25941 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

KOV@jRO)_r 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 literal 24617 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 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 ( + + ); + })} + +