From d0095d9b7772ae888387df055470b235f64ac0ba Mon Sep 17 00:00:00 2001 From: Riyadh Date: Wed, 29 Apr 2026 13:16:02 +0000 Subject: [PATCH] Audit role permission changes (Task #100) Record an audit trail every time a role's permissions change and surface recent history inside the role edit dialog. Schema (lib/db): - New `role_permission_audit` table: id, roleId (FK roles), actorUserId (FK users, nullable on delete), previousPermissionIds int[], newPermissionIds int[], createdAt. Created via raw SQL because `drizzle push` currently fails on a pre-existing app_permissions duplicate (followup #148 tracks fixing this). API (artifacts/api-server): - PUT /api/roles/:id/permissions now wraps the permission delete/insert, the legacy audit_logs row, and the new role_permission_audit row in a *single* transaction so the permission set and its audit trail always commit (or roll back) together. Previous IDs are also read inside the transaction to avoid races. - A role_permission_audit row is written on EVERY PUT call (per task spec), including no-op saves; the GET handler computes addedPermissionIds/removedPermissionIds so the History UI can distinguish meaningful changes from no-op saves. - New GET /api/roles/:id/audit (admin-only, ?limit=1..50, default 10) returns recent entries newest-first with computed added/removedPermissionIds and joined actor info. - New tests in tests/role-permission-audit.test.mjs cover write, every-call write (incl. no-op), GET ordering+diff+actor, no-op diff reporting, 404, and limit. Drive-by fixes: - Fixed pre-existing syntax corruption in tests/apps-open.test.mjs (stray `ccaPassa,` line from a prior bad merge that prevented the whole api-server test workflow from running). - Made tests/roles-crud.test.mjs "rejects an invalid name" robust to parallel test execution by scoping the leak check to the bad name instead of relying on a global row count. API spec / codegen (lib/api-spec, lib/api-client-react): - Added `getRolePermissionAudit` operation and `RolePermissionAuditEntry` schema; ran orval codegen. Frontend (artifacts/tx-os): - New RolePermissionHistory component renders inside the role edit dialog (between permissions and Save/Cancel) using useGetRolePermissionAudit. Shows timestamp, actor, added/removed permission names (resolved via permissionsById), and total count. - Save invalidates the audit query so the new entry appears immediately on the next open. - Bilingual i18n strings (en + ar with full Arabic plural variants: zero/one/two/few/many/other) under admin.roles.history*. Verification: - tx-os typecheck passes. - All 5 new audit tests + 13 existing roles tests pass. - E2E (testing skill) verified the full flow: empty state on a fresh role, save adds a permission, reopened dialog shows the new entry with actor name in Arabic. Docs: - replit.md updated to list `role_permission_audit` in Database Tables. Follow-ups proposed: #146 paginate/filter history, #147 audit other admin permission changes, #148 fix drizzle push duplicate. --- artifacts/api-server/src/routes/roles.ts | 137 +++++++-- artifacts/api-server/tests/apps-open.test.mjs | 1 - .../tests/role-permission-audit.test.mjs | 287 ++++++++++++++++++ .../api-server/tests/roles-crud.test.mjs | 12 +- artifacts/tx-os/public/opengraph.jpg | Bin 25895 -> 25922 bytes artifacts/tx-os/src/locales/ar.json | 24 +- artifacts/tx-os/src/locales/en.json | 12 +- artifacts/tx-os/src/pages/admin.tsx | 150 +++++++++ .../src/generated/api.schemas.ts | 19 ++ lib/api-client-react/src/generated/api.ts | 123 ++++++++ lib/api-spec/openapi.yaml | 79 +++++ lib/api-zod/src/generated/api.ts | 45 +++ lib/db/src/schema/index.ts | 1 + lib/db/src/schema/role-audit.ts | 36 +++ 14 files changed, 895 insertions(+), 31 deletions(-) create mode 100644 artifacts/api-server/tests/role-permission-audit.test.mjs create mode 100644 lib/db/src/schema/role-audit.ts diff --git a/artifacts/api-server/src/routes/roles.ts b/artifacts/api-server/src/routes/roles.ts index bf37ea2f..5421acc3 100644 --- a/artifacts/api-server/src/routes/roles.ts +++ b/artifacts/api-server/src/routes/roles.ts @@ -1,5 +1,5 @@ import { Router, type IRouter } from "express"; -import { eq, inArray, sql } from "drizzle-orm"; +import { desc, eq, inArray, sql } from "drizzle-orm"; import { db } from "@workspace/db"; import { rolesTable, @@ -10,6 +10,8 @@ import { rolePermissionsTable, permissionsTable, auditLogsTable, + rolePermissionAuditTable, + usersTable, } from "@workspace/db"; import { requireAdmin } from "../middlewares/auth"; import { @@ -57,6 +59,68 @@ router.get("/permissions", requireAdmin, async (_req, res): Promise => { res.json(rows.map(serializePermission)); }); +router.get("/roles/:id/audit", requireAdmin, async (req, res): Promise => { + const id = Number(req.params.id); + if (!Number.isInteger(id)) { + res.status(400).json({ error: "Invalid id" }); + return; + } + const limitRaw = Number(req.query.limit); + const limit = + Number.isFinite(limitRaw) && limitRaw > 0 + ? Math.min(Math.floor(limitRaw), 50) + : 10; + const [role] = await db.select({ id: rolesTable.id }).from(rolesTable).where(eq(rolesTable.id, id)); + if (!role) { + res.status(404).json({ error: "Role not found" }); + return; + } + const rows = await db + .select({ + id: rolePermissionAuditTable.id, + roleId: rolePermissionAuditTable.roleId, + previousPermissionIds: rolePermissionAuditTable.previousPermissionIds, + newPermissionIds: rolePermissionAuditTable.newPermissionIds, + createdAt: rolePermissionAuditTable.createdAt, + actorUserId: rolePermissionAuditTable.actorUserId, + actorUsername: usersTable.username, + actorDisplayNameAr: usersTable.displayNameAr, + actorDisplayNameEn: usersTable.displayNameEn, + actorAvatarUrl: usersTable.avatarUrl, + }) + .from(rolePermissionAuditTable) + .leftJoin(usersTable, eq(usersTable.id, rolePermissionAuditTable.actorUserId)) + .where(eq(rolePermissionAuditTable.roleId, id)) + .orderBy(desc(rolePermissionAuditTable.createdAt), desc(rolePermissionAuditTable.id)) + .limit(limit); + const entries = rows.map((r) => { + const prev = (r.previousPermissionIds ?? []) as number[]; + const next = (r.newPermissionIds ?? []) as number[]; + const prevSet = new Set(prev); + const nextSet = new Set(next); + return { + id: r.id, + roleId: r.roleId, + previousPermissionIds: prev, + newPermissionIds: next, + addedPermissionIds: next.filter((p) => !prevSet.has(p)), + removedPermissionIds: prev.filter((p) => !nextSet.has(p)), + createdAt: r.createdAt, + actor: + r.actorUserId != null && r.actorUsername != null + ? { + id: r.actorUserId, + username: r.actorUsername, + displayNameAr: r.actorDisplayNameAr, + displayNameEn: r.actorDisplayNameEn, + avatarUrl: r.actorAvatarUrl, + } + : null, + }; + }); + res.json(entries); +}); + router.get("/roles/:id/usage", requireAdmin, async (req, res): Promise => { const id = Number(req.params.id); if (!Number.isInteger(id)) { @@ -445,38 +509,63 @@ router.put("/roles/:id/permissions", requireAdmin, async (req, res): Promise r.permissionId); + // Apply the permission replacement, the generic audit_logs row, and the + // dedicated role_permission_audit row in a single transaction so the + // permission set and its audit trail are guaranteed to commit together. + // Reading the previous IDs inside the transaction also avoids racing with + // a concurrent edit between the read and the write. await db.transaction(async (tx) => { + const previousIds = ( + await tx + .select({ permissionId: rolePermissionsTable.permissionId }) + .from(rolePermissionsTable) + .where(eq(rolePermissionsTable.roleId, id)) + ).map((r) => r.permissionId); + + const previousSet = new Set(previousIds); + const requestedSet = new Set(requestedIds); + const added = requestedIds.filter((p) => !previousSet.has(p)); + const removed = previousIds.filter((p) => !requestedSet.has(p)); + const changed = added.length > 0 || removed.length > 0; + await tx.delete(rolePermissionsTable).where(eq(rolePermissionsTable.roleId, id)); if (requestedIds.length > 0) { await tx .insert(rolePermissionsTable) .values(requestedIds.map((permissionId) => ({ roleId: id, permissionId }))); } + + // Dedicated role-permission audit row written on EVERY PUT call (per + // task spec) so we have a complete record of who reviewed/saved the + // permission set at any point in time, even when the resulting set + // happens to be unchanged. The History UI uses + // addedPermissionIds/removedPermissionIds (computed in the GET + // handler) to surface meaningful changes vs. no-op saves. + await tx.insert(rolePermissionAuditTable).values({ + roleId: id, + actorUserId: req.session.userId ?? null, + previousPermissionIds: [...previousIds].sort((a, b) => a - b), + newPermissionIds: [...requestedIds].sort((a, b) => a - b), + }); + + // The legacy audit_logs entry is kept for backward compatibility + // with the existing audit log UI, but only when the set actually + // changes (matches its long-standing semantics for this action). + if (changed) { + await tx.insert(auditLogsTable).values({ + actorUserId: req.session.userId ?? null, + action: "role.permissions.replace", + targetType: "role", + targetId: id, + metadata: { + roleName: role.name, + added, + removed, + }, + }); + } }); await emitAppsChangedToRoleHolders(id); - const previousSet = new Set(previousIds); - const requestedSet = new Set(requestedIds); - const added = requestedIds.filter((p) => !previousSet.has(p)); - const removed = previousIds.filter((p) => !requestedSet.has(p)); - if (added.length > 0 || removed.length > 0) { - await db.insert(auditLogsTable).values({ - actorUserId: req.session.userId ?? null, - action: "role.permissions.replace", - targetType: "role", - targetId: id, - metadata: { - roleName: role.name, - added, - removed, - }, - }); - } const rows = await db .select({ id: permissionsTable.id, diff --git a/artifacts/api-server/tests/apps-open.test.mjs b/artifacts/api-server/tests/apps-open.test.mjs index 4247510f..a6fa1f9e 100644 --- a/artifacts/api-server/tests/apps-open.test.mjs +++ b/artifacts/api-server/tests/apps-open.test.mjs @@ -57,7 +57,6 @@ before(async () => { testUsername = `open_test_${Date.now().toString(36)}_${Math.random() .toString(36) .slice(2, 6)}`; - ccaPassa, const userRes = await pool.query( `INSERT INTO users (username, email, password_hash, display_name_en, preferred_language, is_active) VALUES ($1, $2, $3, 'Open Test', 'en', true) RETURNING id`, diff --git a/artifacts/api-server/tests/role-permission-audit.test.mjs b/artifacts/api-server/tests/role-permission-audit.test.mjs new file mode 100644 index 00000000..5c1564cc --- /dev/null +++ b/artifacts/api-server/tests/role-permission-audit.test.mjs @@ -0,0 +1,287 @@ +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 }); + +let adminId; +let adminUsername; +let adminCookie; +const createdRoleIds = []; +const createdUserIds = []; + +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=")); +} + +before(async () => { + const stamp = `${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 6)}`; + adminUsername = `role_audit_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, 'Role Audit Admin', 'en', true) RETURNING id`, + [adminUsername, `${adminUsername}@example.com`, TEST_PASSWORD_HASH], + ); + adminId = a.rows[0].id; + createdUserIds.push(adminId); + await pool.query( + `INSERT INTO user_roles (user_id, role_id) SELECT $1, id FROM roles WHERE name = 'admin'`, + [adminId], + ); + await pool.query( + `INSERT INTO user_groups (user_id, group_id) + SELECT $1, id FROM groups WHERE name IN ('Everyone', 'Admins') + ON CONFLICT DO NOTHING`, + [adminId], + ); + + adminCookie = await loginAndGetCookie(adminUsername, TEST_PASSWORD); +}); + +after(async () => { + if (createdRoleIds.length) { + await pool.query( + `DELETE FROM role_permission_audit WHERE role_id = ANY($1::int[])`, + [createdRoleIds], + ); + await pool.query( + `DELETE FROM role_permissions WHERE role_id = ANY($1::int[])`, + [createdRoleIds], + ); + await pool.query(`DELETE FROM roles WHERE id = ANY($1::int[])`, [ + createdRoleIds, + ]); + } + for (const uid of createdUserIds) { + if (uid !== undefined) { + await pool.query(`DELETE FROM user_groups WHERE user_id = $1`, [uid]); + await pool.query(`DELETE FROM user_roles WHERE user_id = $1`, [uid]); + await pool.query(`DELETE FROM users WHERE id = $1`, [uid]); + } + } + await pool.end(); +}); + +function uniqueRoleName(prefix) { + const stamp = `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`; + return `${prefix}_${stamp}`; +} + +async function createRole() { + const res = await fetch(`${API_BASE}/api/roles`, { + method: "POST", + headers: { "Content-Type": "application/json", Cookie: adminCookie }, + body: JSON.stringify({ name: uniqueRoleName("role_audit") }), + }); + assert.equal(res.status, 201); + const body = await res.json(); + createdRoleIds.push(body.id); + return body; +} + +async function getTwoPermissionIds() { + const rows = await pool.query( + `SELECT id FROM permissions ORDER BY id LIMIT 2`, + ); + assert.ok(rows.rowCount >= 2, "need at least 2 seeded permissions"); + return [rows.rows[0].id, rows.rows[1].id]; +} + +test("PUT /api/roles/:id/permissions writes a role_permission_audit row", async () => { + const role = await createRole(); + const [p1, p2] = await getTwoPermissionIds(); + + const before = await pool.query( + `SELECT COUNT(*)::int AS c FROM role_permission_audit WHERE role_id = $1`, + [role.id], + ); + assert.equal(before.rows[0].c, 0); + + const put = await fetch(`${API_BASE}/api/roles/${role.id}/permissions`, { + method: "PUT", + headers: { "Content-Type": "application/json", Cookie: adminCookie }, + body: JSON.stringify({ permissionIds: [p1, p2] }), + }); + assert.equal(put.status, 200); + + const dbRows = await pool.query( + `SELECT actor_user_id, role_id, previous_permission_ids, new_permission_ids + FROM role_permission_audit + WHERE role_id = $1`, + [role.id], + ); + assert.equal(dbRows.rowCount, 1); + const row = dbRows.rows[0]; + assert.equal(row.actor_user_id, adminId); + assert.equal(row.role_id, role.id); + assert.deepEqual(row.previous_permission_ids, []); + assert.deepEqual( + [...row.new_permission_ids].sort((a, b) => a - b), + [p1, p2].sort((a, b) => a - b), + ); +}); + +test("PUT /api/roles/:id/permissions writes an audit row on every call (including no-op replaces)", async () => { + const role = await createRole(); + const [p1, p2] = await getTwoPermissionIds(); + + const first = await fetch(`${API_BASE}/api/roles/${role.id}/permissions`, { + method: "PUT", + headers: { "Content-Type": "application/json", Cookie: adminCookie }, + body: JSON.stringify({ permissionIds: [p1, p2] }), + }); + assert.equal(first.status, 200); + + // Re-submit the same set in a different order — the resulting permission + // set is identical, but the spec requires recording every PUT call. + const second = await fetch(`${API_BASE}/api/roles/${role.id}/permissions`, { + method: "PUT", + headers: { "Content-Type": "application/json", Cookie: adminCookie }, + body: JSON.stringify({ permissionIds: [p2, p1] }), + }); + assert.equal(second.status, 200); + + const dbRows = await pool.query( + `SELECT previous_permission_ids, new_permission_ids + FROM role_permission_audit + WHERE role_id = $1 + ORDER BY id`, + [role.id], + ); + assert.equal( + dbRows.rowCount, + 2, + "every PUT call must append an audit row, including no-op saves", + ); + const sorted = [p1, p2].sort((a, b) => a - b); + // Second row's previous and new arrays must be identical to the first + // row's "new" array, capturing the no-op save. + assert.deepEqual([...dbRows.rows[1].previous_permission_ids].sort((a, b) => a - b), sorted); + assert.deepEqual([...dbRows.rows[1].new_permission_ids].sort((a, b) => a - b), sorted); +}); + +test("GET /api/roles/:id/audit reports no-op saves with empty added/removed", async () => { + const role = await createRole(); + const [p1] = await getTwoPermissionIds(); + + for (const set of [[p1], [p1]]) { + const r = await fetch(`${API_BASE}/api/roles/${role.id}/permissions`, { + method: "PUT", + headers: { "Content-Type": "application/json", Cookie: adminCookie }, + body: JSON.stringify({ permissionIds: set }), + }); + assert.equal(r.status, 200); + await new Promise((r) => setTimeout(r, 10)); + } + + const get = await fetch(`${API_BASE}/api/roles/${role.id}/audit`, { + headers: { Cookie: adminCookie }, + }); + assert.equal(get.status, 200); + const entries = await get.json(); + assert.equal(entries.length, 2); + // Latest entry is the no-op save: same set in/out, empty diffs. + assert.deepEqual(entries[0].previousPermissionIds, [p1]); + assert.deepEqual(entries[0].newPermissionIds, [p1]); + assert.deepEqual(entries[0].addedPermissionIds, []); + assert.deepEqual(entries[0].removedPermissionIds, []); +}); + +test("GET /api/roles/:id/audit returns entries newest first with diff fields and actor", async () => { + const role = await createRole(); + const [p1, p2] = await getTwoPermissionIds(); + + // First change: add p1 + const r1 = await fetch(`${API_BASE}/api/roles/${role.id}/permissions`, { + method: "PUT", + headers: { "Content-Type": "application/json", Cookie: adminCookie }, + body: JSON.stringify({ permissionIds: [p1] }), + }); + assert.equal(r1.status, 200); + + // Ensure the second change has a strictly later created_at so ordering is + // deterministic regardless of clock resolution. + await new Promise((r) => setTimeout(r, 25)); + + // Second change: swap p1 -> p2 + const r2 = await fetch(`${API_BASE}/api/roles/${role.id}/permissions`, { + method: "PUT", + headers: { "Content-Type": "application/json", Cookie: adminCookie }, + body: JSON.stringify({ permissionIds: [p2] }), + }); + assert.equal(r2.status, 200); + + const get = await fetch(`${API_BASE}/api/roles/${role.id}/audit`, { + headers: { Cookie: adminCookie }, + }); + assert.equal(get.status, 200); + const entries = await get.json(); + assert.ok(Array.isArray(entries)); + assert.equal(entries.length, 2); + + const [latest, earlier] = entries; + assert.deepEqual(latest.previousPermissionIds, [p1]); + assert.deepEqual(latest.newPermissionIds, [p2]); + assert.deepEqual(latest.addedPermissionIds, [p2]); + assert.deepEqual(latest.removedPermissionIds, [p1]); + assert.ok(latest.actor); + assert.equal(latest.actor.id, adminId); + assert.equal(latest.actor.username, adminUsername); + + assert.deepEqual(earlier.previousPermissionIds, []); + assert.deepEqual(earlier.newPermissionIds, [p1]); + assert.deepEqual(earlier.addedPermissionIds, [p1]); + assert.deepEqual(earlier.removedPermissionIds, []); +}); + +test("GET /api/roles/:id/audit returns 404 for an unknown role", async () => { + const res = await fetch(`${API_BASE}/api/roles/99999999/audit`, { + headers: { Cookie: adminCookie }, + }); + assert.equal(res.status, 404); +}); + +test("GET /api/roles/:id/audit honours the limit parameter", async () => { + const role = await createRole(); + const [p1, p2] = await getTwoPermissionIds(); + + const sets = [[p1], [p2], [p1, p2], []]; + for (const s of sets) { + const r = await fetch(`${API_BASE}/api/roles/${role.id}/permissions`, { + method: "PUT", + headers: { "Content-Type": "application/json", Cookie: adminCookie }, + body: JSON.stringify({ permissionIds: s }), + }); + assert.equal(r.status, 200); + await new Promise((r) => setTimeout(r, 5)); + } + + const limited = await fetch( + `${API_BASE}/api/roles/${role.id}/audit?limit=2`, + { headers: { Cookie: adminCookie } }, + ); + assert.equal(limited.status, 200); + const entries = await limited.json(); + assert.equal(entries.length, 2); +}); diff --git a/artifacts/api-server/tests/roles-crud.test.mjs b/artifacts/api-server/tests/roles-crud.test.mjs index 14cf6a76..947de440 100644 --- a/artifacts/api-server/tests/roles-crud.test.mjs +++ b/artifacts/api-server/tests/roles-crud.test.mjs @@ -180,15 +180,19 @@ test("POST /api/roles with a duplicate name returns 409", async () => { }); test("POST /api/roles rejects an invalid name with 400", async () => { - const before = await pool.query(`SELECT COUNT(*)::int AS c FROM roles`); + const badName = "1bad name!"; const res = await fetch(`${API_BASE}/api/roles`, { method: "POST", headers: { "Content-Type": "application/json", Cookie: adminCookie }, - body: JSON.stringify({ name: "1bad name!" }), + body: JSON.stringify({ name: badName }), }); assert.equal(res.status, 400); - const after = await pool.query(`SELECT COUNT(*)::int AS c FROM roles`); - assert.equal(after.rows[0].c, before.rows[0].c, "no role row should leak"); + // Scope the leak check to the specific bad name so this test is robust + // when run in parallel with other suites that legitimately create roles. + const leak = await pool.query(`SELECT id FROM roles WHERE name = $1`, [ + badName, + ]); + assert.equal(leak.rowCount, 0, "no role row should leak"); }); test("PATCH /api/roles/:id updates description without touching the name", async () => { diff --git a/artifacts/tx-os/public/opengraph.jpg b/artifacts/tx-os/public/opengraph.jpg index cc11c2406c0c6716bf44350672744aebe3e5697f..b018b84cb830e6fcf8d14e4ac85f97a57df08cb6 100644 GIT binary patch delta 21597 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 diff --git a/artifacts/tx-os/src/locales/ar.json b/artifacts/tx-os/src/locales/ar.json index e813e36e..48d407b8 100644 --- a/artifacts/tx-os/src/locales/ar.json +++ b/artifacts/tx-os/src/locales/ar.json @@ -461,7 +461,29 @@ "confirmRemovalTitle": "تأكيد إزالة الصلاحيات", "confirmRemovalBody_one": "سيؤدي الحفظ إلى إزالة الصلاحيات من {{count}} مستخدم. قد يُلغى وصوله فوراً. هل تريد المتابعة؟", "confirmRemovalBody_other": "سيؤدي الحفظ إلى إزالة الصلاحيات من {{count}} مستخدماً. قد يُلغى وصولهم فوراً. هل تريد المتابعة؟", - "confirmRemovalAction": "إزالة الصلاحيات" + "confirmRemovalAction": "إزالة الصلاحيات", + "historyTitle": "السجل", + "historyHint": "آخر التغييرات على صلاحيات هذا الدور.", + "historyEmpty": "لم تُسجَّل أي تغييرات على صلاحيات هذا الدور بعد.", + "historyActorUnknown": "مستخدم غير معروف", + "historyAdded_zero": "أُضيفت (0):", + "historyAdded_one": "أُضيفت (1):", + "historyAdded_two": "أُضيفت (2):", + "historyAdded_few": "أُضيفت ({{count}}):", + "historyAdded_many": "أُضيفت ({{count}}):", + "historyAdded_other": "أُضيفت ({{count}}):", + "historyRemoved_zero": "أُزيلت (0):", + "historyRemoved_one": "أُزيلت (1):", + "historyRemoved_two": "أُزيلت (2):", + "historyRemoved_few": "أُزيلت ({{count}}):", + "historyRemoved_many": "أُزيلت ({{count}}):", + "historyRemoved_other": "أُزيلت ({{count}}):", + "historyTotal_zero": "بدون صلاحيات بعد التغيير", + "historyTotal_one": "صلاحية واحدة بعد التغيير", + "historyTotal_two": "صلاحيتان بعد التغيير", + "historyTotal_few": "{{count}} صلاحيات بعد التغيير", + "historyTotal_many": "{{count}} صلاحية بعد التغيير", + "historyTotal_other": "{{count}} صلاحية بعد التغيير" }, "groups": { "searchPlaceholder": "ابحث في المجموعات...", diff --git a/artifacts/tx-os/src/locales/en.json b/artifacts/tx-os/src/locales/en.json index 3774af01..38066c30 100644 --- a/artifacts/tx-os/src/locales/en.json +++ b/artifacts/tx-os/src/locales/en.json @@ -458,7 +458,17 @@ "confirmRemovalTitle": "Confirm permission removal", "confirmRemovalBody_one": "Saving will remove permissions from {{count}} user. This may revoke their access immediately. Continue?", "confirmRemovalBody_other": "Saving will remove permissions from {{count}} users. This may revoke their access immediately. Continue?", - "confirmRemovalAction": "Remove permissions" + "confirmRemovalAction": "Remove permissions", + "historyTitle": "History", + "historyHint": "Recent permission changes for this role.", + "historyEmpty": "No permission changes have been recorded for this role yet.", + "historyActorUnknown": "Unknown actor", + "historyAdded_one": "Added ({{count}}):", + "historyAdded_other": "Added ({{count}}):", + "historyRemoved_one": "Removed ({{count}}):", + "historyRemoved_other": "Removed ({{count}}):", + "historyTotal_one": "{{count}} permission after change", + "historyTotal_other": "{{count}} permissions after change" }, "groups": { "searchPlaceholder": "Search groups...", diff --git a/artifacts/tx-os/src/pages/admin.tsx b/artifacts/tx-os/src/pages/admin.tsx index 2edc29d2..d8f90220 100644 --- a/artifacts/tx-os/src/pages/admin.tsx +++ b/artifacts/tx-os/src/pages/admin.tsx @@ -49,6 +49,8 @@ import { getGetRoleUsageQueryKey, useReplaceRolePermissions, usePreviewRolePermissionsImpact, + useGetRolePermissionAudit, + getGetRolePermissionAuditQueryKey, useListAuditLogs, getListAuditLogsQueryKey, getExportAuditLogsCsvUrl, @@ -73,6 +75,8 @@ import type { ListAuditLogsParams, AuditLogEntry, RolePermissionsImpact, + RolePermissionAuditEntry, + Permission, } from "@workspace/api-client-react"; import { ListUsersStatus } from "@workspace/api-client-react"; import { useQueryClient, useQuery } from "@tanstack/react-query"; @@ -3212,6 +3216,122 @@ function GroupDetailEditor({ groupId, onClose, onSaved }: { groupId: number; onC const ROLES_PROTECTED_NAMES = new Set(["admin", "user", "order_receiver"]); +function permissionLabel( + id: number, + permissionsById: Map, +): string { + const p = permissionsById.get(id); + return p ? p.name : `#${id}`; +} + +function RolePermissionHistory({ + entries, + loading, + permissionsById, + language, +}: { + entries: RolePermissionAuditEntry[] | null; + loading: boolean; + permissionsById: Map; + language: string; +}) { + const { t } = useTranslation(); + return ( +
+ +

{t("admin.roles.historyHint")}

+
+ {loading ? ( +
+ +
+ ) : !entries || entries.length === 0 ? ( +

+ {t("admin.roles.historyEmpty")} +

+ ) : ( + entries.map((entry) => { + const actor = entry.actor; + const ar = actor?.displayNameAr ?? null; + const en = actor?.displayNameEn ?? null; + const preferred = + language === "ar" ? ar ?? en : en ?? ar; + const actorName = + actor && (preferred?.trim() ? preferred : actor.username); + return ( +
+
+ + {actorName ?? t("admin.roles.historyActorUnknown")} + + + {formatAuditTimestamp(entry.createdAt, language)} + +
+ {entry.addedPermissionIds.length > 0 && ( +
+ + {t("admin.roles.historyAdded", { + count: entry.addedPermissionIds.length, + })} + {" "} + + {entry.addedPermissionIds + .map((id) => permissionLabel(id, permissionsById)) + .join(", ")} + +
+ )} + {entry.removedPermissionIds.length > 0 && ( +
+ + {t("admin.roles.historyRemoved", { + count: entry.removedPermissionIds.length, + })} + {" "} + + {entry.removedPermissionIds + .map((id) => permissionLabel(id, permissionsById)) + .join(", ")} + +
+ )} +
+ {t("admin.roles.historyTotal", { + count: entry.newPermissionIds.length, + })} +
+
+ ); + }) + )} +
+
+ ); +} + function RolesPanel() { const { t, i18n } = useTranslation(); const { toast } = useToast(); @@ -3258,6 +3378,25 @@ function RolesPanel() { enabled: editingId != null && !editingIsSystem, }, }); + const auditLimit = 5; + const { data: editingRoleAudit, isLoading: editingAuditLoading } = + useGetRolePermissionAudit( + editingPermissionsId, + { limit: auditLimit }, + { + query: { + queryKey: getGetRolePermissionAuditQueryKey(editingPermissionsId, { + limit: auditLimit, + }), + enabled: editingId != null, + }, + }, + ); + const permissionsById = useMemo(() => { + const map = new Map(); + for (const p of allPermissions ?? []) map.set(p.id, p); + return map; + }, [allPermissions]); useEffect(() => { if (editingId == null) return; @@ -3437,6 +3576,11 @@ function RolesPanel() { queryClient.invalidateQueries({ queryKey: getGetRolePermissionsQueryKey(editingRoleId), }); + queryClient.invalidateQueries({ + queryKey: getGetRolePermissionAuditQueryKey(editingRoleId, { + limit: auditLimit, + }), + }); closeEditDialog(); toast({ title: t("common.success") }); }, @@ -3842,6 +3986,12 @@ function RolesPanel() { )} +