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 cc11c240..b018b84c 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/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() { )} +