From fe736e3ff723d86af9208afa38d8308789fc51e3 Mon Sep 17 00:00:00 2001 From: riyadhafraa <49757212-riyadhafraa@users.noreply.replit.com> Date: Thu, 30 Apr 2026 10:45:07 +0000 Subject: [PATCH] Task #146: Filter and paginate role permission history MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Summary - Backend: GET /api/roles/:id/audit now accepts limit (default 10, max 200), offset, actorUserId (0 = no filter), and from/to (YYYY-MM-DD UTC). The response is now a paginated envelope `{entries, totalCount, limit, offset, nextOffset}` instead of a bare array. - OpenAPI: lib/api-spec/openapi.yaml updated with the new params and a new RolePermissionAuditList schema; client hooks regenerated via `pnpm --filter @workspace/api-spec run codegen`. - Frontend: RolePermissionHistory in artifacts/tx-os/src/pages/admin.tsx is now self-contained — owns its own filter state, fetches the user list for the actor dropdown, applies actor changes immediately, and validates date inputs (rejecting invalid / inverted ranges). - Pagination: switched to TRUE OFFSET PAGINATION. The first page comes from React Query (so live cache invalidations after a save still refresh it), and subsequent "Load more" clicks fetch `offset = nextOffset` imperatively via getRolePermissionAudit() and append the rows to local state. There is no client-side ceiling on how far back an admin can page; we simply stop showing the button when nextOffset is null. A filtersKey effect resets the appended pages whenever any filter (actor / from / to) changes so we never serve overlapping or out-of-order rows. - i18n: added missing keys in artifacts/tx-os/src/locales/{en,ar}.json (historyEmptyFiltered, historyShowing, historyLoadMore, historyFilters.*, historyErrors.*). - Tests: artifacts/api-server/tests/role-permission-audit.test.mjs updated to read the new envelope and now also covers offset-based pagination, actorUserId filtering (including the "0 = no filter" semantic), and date-range filtering (in-range / past-range / inverted / garbage). All 9 audit tests pass; tx-os typecheck clean. - E2E: ran the testing skill end-to-end against /admin → role edit dialog → history panel: created a fresh role through the UI, made 25 permission writes, verified 10 → 20 → 25 pagination with no duplicates and correct hide-on-end behaviour, filter-by-actor reset to first page, empty date range showed empty state, inverted dates surfaced the validation error. Drift / notes - Pre-existing executive-meetings tests in the `test` workflow are still failing — unchanged by this task. - The Arabic language toggle isn't exposed in the page header in this build, so the e2e Arabic step was skipped; locale strings are in place and the test ids do not change with locale. - Code review (initial pass) flagged a 200-row UI ceiling in the previous "growing limit" approach. Replaced with true offset pagination (described above) so admins can scroll back through arbitrarily long histories. Code review follow-ups (round 2) - Tightened parseRoleAuditUtcDate to reject impossible calendar days (2024-02-31, 2025-13-01, etc.) instead of silently rolling forward. - Aligned OpenAPI: actorUserId schema is now `minimum: 0` so the spec matches the runtime "0 = no filter" contract; client regenerated. - Added a targeted backend test that asserts impossible dates → 400. Code review follow-ups (round 3) - UX: when applied filters become invalid, the History list now hides the stale entries and shows the "fix filters first" hint instead, and the Load more button is hidden until filters are valid again. Avoids presenting yesterday's results as the current view. Code review follow-ups (round 4) - Belt-and-braces: also reset the appended history pages when the first page's totalCount + first-row id signature changes, so an external cache invalidation (e.g. another save while the dialog is still open) cannot leave appended pages out of sync with the refreshed first page. Replit-Task-Id: cc3c9e83-921f-4f72-b443-79f95f6467b1 --- artifacts/api-server/src/routes/roles.ts | 129 ++++++- .../tests/role-permission-audit.test.mjs | 195 +++++++++- artifacts/tx-os/public/opengraph.jpg | Bin 0 -> 25885 bytes artifacts/tx-os/src/locales/ar.json | 20 +- artifacts/tx-os/src/locales/en.json | 20 +- artifacts/tx-os/src/pages/admin.tsx | 344 ++++++++++++++++-- .../src/generated/api.schemas.ts | 30 +- lib/api-client-react/src/generated/api.ts | 14 +- lib/api-spec/openapi.yaml | 74 +++- lib/api-zod/src/generated/api.ts | 72 ++-- 10 files changed, 819 insertions(+), 79 deletions(-) create mode 100644 artifacts/tx-os/public/opengraph.jpg diff --git a/artifacts/api-server/src/routes/roles.ts b/artifacts/api-server/src/routes/roles.ts index d4652915..e2f41b03 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 { desc, eq, inArray, sql } from "drizzle-orm"; +import { Router, type IRouter, type Request, type Response } from "express"; +import { and, desc, eq, gte, inArray, lt, sql, type SQL } from "drizzle-orm"; import { db } from "@workspace/db"; import { rolesTable, @@ -62,6 +62,88 @@ router.get("/permissions", requireAdmin, async (_req, res): Promise => { res.json(rows.map(serializePermission)); }); +const ROLE_AUDIT_DATE_RE = /^\d{4}-\d{2}-\d{2}$/; + +function parseRoleAuditUtcDate(s: string): Date | null { + if (!ROLE_AUDIT_DATE_RE.test(s)) return null; + const d = new Date(`${s}T00:00:00.000Z`); + if (Number.isNaN(d.getTime())) return null; + // Reject impossible calendar days (e.g. 2024-02-31, 2025-13-01) that + // would otherwise silently roll forward into a different real date. + const [y, m, day] = s.split("-").map(Number); + if ( + d.getUTCFullYear() !== y || + d.getUTCMonth() + 1 !== m || + d.getUTCDate() !== day + ) { + return null; + } + return d; +} + +type RoleAuditFilters = { + actorUserId: number | null; + fromDate: Date | null; + toDateExclusive: Date | null; +}; + +function parseRoleAuditFilters( + req: Request, + res: Response, +): RoleAuditFilters | null { + const actorUserIdStr = + typeof req.query.actorUserId === "string" ? req.query.actorUserId.trim() : ""; + const fromStr = typeof req.query.from === "string" ? req.query.from.trim() : ""; + const toStr = typeof req.query.to === "string" ? req.query.to.trim() : ""; + + let actorUserId: number | null = null; + if (actorUserIdStr) { + const parsed = Number(actorUserIdStr); + if (!Number.isFinite(parsed) || !Number.isInteger(parsed) || parsed < 0) { + res + .status(400) + .json({ error: "Invalid 'actorUserId' (expected non-negative integer)" }); + return null; + } + // Treat 0 as "no filter" so callers can clear the dropdown without + // dropping the query param entirely. + actorUserId = parsed > 0 ? parsed : null; + } + let fromDate: Date | null = null; + let toDateExclusive: Date | null = null; + if (fromStr) { + fromDate = parseRoleAuditUtcDate(fromStr); + if (!fromDate) { + res.status(400).json({ error: "Invalid 'from' date (expected YYYY-MM-DD)" }); + return null; + } + } + if (toStr) { + const toDate = parseRoleAuditUtcDate(toStr); + if (!toDate) { + res.status(400).json({ error: "Invalid 'to' date (expected YYYY-MM-DD)" }); + return null; + } + toDateExclusive = new Date(toDate.getTime() + 24 * 60 * 60 * 1000); + } + if (fromDate && toDateExclusive && fromDate >= toDateExclusive) { + res.status(400).json({ error: "'from' must be on or before 'to'" }); + return null; + } + return { actorUserId, fromDate, toDateExclusive }; +} + +function buildRoleAuditWhere(roleId: number, filters: RoleAuditFilters): SQL { + const conditions: SQL[] = [eq(rolePermissionAuditTable.roleId, roleId)]; + if (filters.actorUserId != null) + conditions.push(eq(rolePermissionAuditTable.actorUserId, filters.actorUserId)); + if (filters.fromDate) + conditions.push(gte(rolePermissionAuditTable.createdAt, filters.fromDate)); + if (filters.toDateExclusive) + conditions.push(lt(rolePermissionAuditTable.createdAt, filters.toDateExclusive)); + return and(...conditions) as SQL; +} + router.get("/roles/:id/audit", requireAdmin, async (req, res): Promise => { const id = Number(req.params.id); if (!Number.isInteger(id)) { @@ -69,15 +151,31 @@ router.get("/roles/:id/audit", requireAdmin, async (req, res): Promise => return; } const limitRaw = Number(req.query.limit); + const offsetRaw = Number(req.query.offset); const limit = Number.isFinite(limitRaw) && limitRaw > 0 - ? Math.min(Math.floor(limitRaw), 50) + ? Math.min(Math.floor(limitRaw), 200) : 10; - const [role] = await db.select({ id: rolesTable.id }).from(rolesTable).where(eq(rolesTable.id, id)); + const offset = + Number.isFinite(offsetRaw) && offsetRaw >= 0 ? Math.floor(offsetRaw) : 0; + + 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; } + + // Filter parsing is intentionally performed AFTER the role existence + // check so 404 takes precedence over 400 for unknown roles, matching + // the behaviour of the other role sub-routes. + const filters = parseRoleAuditFilters(req, res); + if (!filters) return; + + const whereClause = buildRoleAuditWhere(id, filters); + const rows = await db .select({ id: rolePermissionAuditTable.id, @@ -93,9 +191,16 @@ router.get("/roles/:id/audit", requireAdmin, async (req, res): Promise => }) .from(rolePermissionAuditTable) .leftJoin(usersTable, eq(usersTable.id, rolePermissionAuditTable.actorUserId)) - .where(eq(rolePermissionAuditTable.roleId, id)) + .where(whereClause) .orderBy(desc(rolePermissionAuditTable.createdAt), desc(rolePermissionAuditTable.id)) - .limit(limit); + .limit(limit) + .offset(offset); + + const [{ count: totalCount } = { count: 0 }] = await db + .select({ count: sql`count(*)::int` }) + .from(rolePermissionAuditTable) + .where(whereClause); + const entries = rows.map((r) => { const prev = (r.previousPermissionIds ?? []) as number[]; const next = (r.newPermissionIds ?? []) as number[]; @@ -121,7 +226,17 @@ router.get("/roles/:id/audit", requireAdmin, async (req, res): Promise => : null, }; }); - res.json(entries); + + const nextOffset = + offset + rows.length < totalCount ? offset + rows.length : null; + + res.json({ + entries, + totalCount, + limit, + offset, + nextOffset, + }); }); router.get("/roles/:id/usage", requireAdmin, async (req, res): Promise => { diff --git a/artifacts/api-server/tests/role-permission-audit.test.mjs b/artifacts/api-server/tests/role-permission-audit.test.mjs index 5c1564cc..bc709239 100644 --- a/artifacts/api-server/tests/role-permission-audit.test.mjs +++ b/artifacts/api-server/tests/role-permission-audit.test.mjs @@ -199,8 +199,12 @@ test("GET /api/roles/:id/audit reports no-op saves with empty added/removed", as headers: { Cookie: adminCookie }, }); assert.equal(get.status, 200); - const entries = await get.json(); + const body = await get.json(); + const { entries } = body; assert.equal(entries.length, 2); + assert.equal(body.totalCount, 2); + assert.equal(body.offset, 0); + assert.equal(body.nextOffset, null); // 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]); @@ -236,11 +240,15 @@ test("GET /api/roles/:id/audit returns entries newest first with diff fields and headers: { Cookie: adminCookie }, }); assert.equal(get.status, 200); - const entries = await get.json(); - assert.ok(Array.isArray(entries)); - assert.equal(entries.length, 2); + const body = await get.json(); + assert.ok(Array.isArray(body.entries)); + assert.equal(body.entries.length, 2); + assert.equal(body.totalCount, 2); + assert.equal(body.offset, 0); + assert.equal(body.limit, 10); + assert.equal(body.nextOffset, null); - const [latest, earlier] = entries; + const [latest, earlier] = body.entries; assert.deepEqual(latest.previousPermissionIds, [p1]); assert.deepEqual(latest.newPermissionIds, [p2]); assert.deepEqual(latest.addedPermissionIds, [p2]); @@ -282,6 +290,179 @@ test("GET /api/roles/:id/audit honours the limit parameter", async () => { { headers: { Cookie: adminCookie } }, ); assert.equal(limited.status, 200); - const entries = await limited.json(); - assert.equal(entries.length, 2); + const body = await limited.json(); + assert.equal(body.entries.length, 2); + assert.equal(body.totalCount, 4); + assert.equal(body.limit, 2); + assert.equal(body.offset, 0); + // Two more rows beyond the page → nextOffset should point at row 2. + assert.equal(body.nextOffset, 2); +}); + +test("GET /api/roles/:id/audit paginates with offset and stops at the end", async () => { + const role = await createRole(); + const [p1, p2] = await getTwoPermissionIds(); + + // 4 distinct permission sets → 4 audit rows, deterministic ordering thanks + // to the small inter-write delay. + 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 page1Res = await fetch( + `${API_BASE}/api/roles/${role.id}/audit?limit=2&offset=0`, + { headers: { Cookie: adminCookie } }, + ); + assert.equal(page1Res.status, 200); + const page1 = await page1Res.json(); + assert.equal(page1.entries.length, 2); + assert.equal(page1.totalCount, 4); + assert.equal(page1.nextOffset, 2); + + const page2Res = await fetch( + `${API_BASE}/api/roles/${role.id}/audit?limit=2&offset=2`, + { headers: { Cookie: adminCookie } }, + ); + assert.equal(page2Res.status, 200); + const page2 = await page2Res.json(); + assert.equal(page2.entries.length, 2); + assert.equal(page2.totalCount, 4); + // Reached the end of the table — there is nothing more to page through. + assert.equal(page2.nextOffset, null); + + // Pages must not overlap. + const page1Ids = new Set(page1.entries.map((e) => e.id)); + for (const e of page2.entries) { + assert.equal(page1Ids.has(e.id), false, "page1/page2 overlap"); + } +}); + +test("GET /api/roles/:id/audit filters by actorUserId", async () => { + const role = await createRole(); + const [p1, p2] = await getTwoPermissionIds(); + + // Create a SECOND admin so we can attribute audit rows to two different + // actors and then verify that filtering returns only one of them. + const stamp = `${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 6)}`; + const otherUsername = `role_audit_admin_other_${stamp}`; + const otherRow = await pool.query( + `INSERT INTO users (username, email, password_hash, display_name_en, preferred_language, is_active) + VALUES ($1, $2, $3, 'Role Audit Admin Other', 'en', true) RETURNING id`, + [otherUsername, `${otherUsername}@example.com`, TEST_PASSWORD_HASH], + ); + const otherId = otherRow.rows[0].id; + createdUserIds.push(otherId); + await pool.query( + `INSERT INTO user_roles (user_id, role_id) SELECT $1, id FROM roles WHERE name = 'admin'`, + [otherId], + ); + const otherCookie = await loginAndGetCookie(otherUsername, TEST_PASSWORD); + + // Two writes by adminCookie + one by otherCookie. + for (const [cookie, set] of [ + [adminCookie, [p1]], + [otherCookie, [p1, p2]], + [adminCookie, [p2]], + ]) { + const r = await fetch(`${API_BASE}/api/roles/${role.id}/permissions`, { + method: "PUT", + headers: { "Content-Type": "application/json", Cookie: cookie }, + body: JSON.stringify({ permissionIds: set }), + }); + assert.equal(r.status, 200); + await new Promise((r) => setTimeout(r, 5)); + } + + const filteredRes = await fetch( + `${API_BASE}/api/roles/${role.id}/audit?actorUserId=${otherId}`, + { headers: { Cookie: adminCookie } }, + ); + assert.equal(filteredRes.status, 200); + const filtered = await filteredRes.json(); + assert.equal(filtered.totalCount, 1); + assert.equal(filtered.entries.length, 1); + assert.equal(filtered.entries[0].actor.id, otherId); + + // actorUserId=0 means "no filter" — should match all 3 rows. + const allRes = await fetch( + `${API_BASE}/api/roles/${role.id}/audit?actorUserId=0`, + { headers: { Cookie: adminCookie } }, + ); + assert.equal(allRes.status, 200); + const all = await allRes.json(); + assert.equal(all.totalCount, 3); +}); + +test("GET /api/roles/:id/audit filters by date range", async () => { + const role = await createRole(); + const [p1, p2] = await getTwoPermissionIds(); + + // Make a couple of writes "today" (UTC). + for (const set of [[p1], [p2]]) { + 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, 5)); + } + + const today = new Date().toISOString().slice(0, 10); + const tomorrow = new Date(Date.now() + 24 * 60 * 60 * 1000) + .toISOString() + .slice(0, 10); + const yesterday = new Date(Date.now() - 24 * 60 * 60 * 1000) + .toISOString() + .slice(0, 10); + + // Today is in range → both rows should match. + const inRangeRes = await fetch( + `${API_BASE}/api/roles/${role.id}/audit?from=${today}&to=${today}`, + { headers: { Cookie: adminCookie } }, + ); + assert.equal(inRangeRes.status, 200); + const inRange = await inRangeRes.json(); + assert.equal(inRange.totalCount, 2); + + // Yesterday-only window → no rows. + const pastRes = await fetch( + `${API_BASE}/api/roles/${role.id}/audit?from=${yesterday}&to=${yesterday}`, + { headers: { Cookie: adminCookie } }, + ); + assert.equal(pastRes.status, 200); + const past = await pastRes.json(); + assert.equal(past.totalCount, 0); + assert.equal(past.entries.length, 0); + + // Inverted range → 400. + const badRes = await fetch( + `${API_BASE}/api/roles/${role.id}/audit?from=${tomorrow}&to=${yesterday}`, + { headers: { Cookie: adminCookie } }, + ); + assert.equal(badRes.status, 400); + + // Garbage date → 400. + const garbageRes = await fetch( + `${API_BASE}/api/roles/${role.id}/audit?from=not-a-date`, + { headers: { Cookie: adminCookie } }, + ); + assert.equal(garbageRes.status, 400); + + // Impossible calendar days are rejected too — without strict validation + // these would silently roll forward into a different real date. + for (const bad of ["2024-02-31", "2025-13-01", "2025-00-15", "2025-02-30"]) { + const r = await fetch( + `${API_BASE}/api/roles/${role.id}/audit?from=${bad}`, + { headers: { Cookie: adminCookie } }, + ); + assert.equal(r.status, 400, `expected 400 for from=${bad}`); + } }); diff --git a/artifacts/tx-os/public/opengraph.jpg b/artifacts/tx-os/public/opengraph.jpg new file mode 100644 index 0000000000000000000000000000000000000000..d0f85d39ebbf4f120e19e13cefeca74e59664dae GIT binary patch literal 25885 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 R>PGc1?BMjftbTp>KLJ`oTSEW< literal 0 HcmV?d00001 diff --git a/artifacts/tx-os/src/locales/ar.json b/artifacts/tx-os/src/locales/ar.json index c1e99ee2..d66321bb 100644 --- a/artifacts/tx-os/src/locales/ar.json +++ b/artifacts/tx-os/src/locales/ar.json @@ -498,8 +498,9 @@ "confirmRemovalBody_other": "سيؤدي الحفظ إلى إزالة الصلاحيات من {{count}} مستخدماً. قد يُلغى وصولهم فوراً. هل تريد المتابعة؟", "confirmRemovalAction": "إزالة الصلاحيات", "historyTitle": "السجل", - "historyHint": "آخر التغييرات على صلاحيات هذا الدور.", + "historyHint": "تغييرات صلاحيات هذا الدور. استخدم عوامل التصفية لتحديد من قام بالتغيير أو متى.", "historyEmpty": "لم تُسجَّل أي تغييرات على صلاحيات هذا الدور بعد.", + "historyEmptyFiltered": "لا توجد تغييرات تطابق عوامل التصفية الحالية.", "historyActorUnknown": "مستخدم غير معروف", "historyAdded_zero": "أُضيفت (0):", "historyAdded_one": "أُضيفت (1):", @@ -518,7 +519,22 @@ "historyTotal_two": "صلاحيتان بعد التغيير", "historyTotal_few": "{{count}} صلاحيات بعد التغيير", "historyTotal_many": "{{count}} صلاحية بعد التغيير", - "historyTotal_other": "{{count}} صلاحية بعد التغيير" + "historyTotal_other": "{{count}} صلاحية بعد التغيير", + "historyShowing": "يظهر {{shown}} من أصل {{total}}", + "historyLoadMore": "عرض المزيد", + "historyFilters": { + "actor": "غُيّرت بواسطة", + "allActors": "أي شخص", + "from": "من", + "to": "إلى", + "apply": "تطبيق", + "reset": "إعادة تعيين" + }, + "historyErrors": { + "invalidDate": "يجب أن تكون التواريخ بصيغة YYYY-MM-DD.", + "invertedDates": "يجب أن يكون \"من\" قبل \"إلى\" أو في نفس اليوم.", + "fixFiltersFirst": "صحّح عوامل التصفية أعلاه لتحميل السجل." + } }, "groups": { "searchPlaceholder": "ابحث في المجموعات...", diff --git a/artifacts/tx-os/src/locales/en.json b/artifacts/tx-os/src/locales/en.json index 1d120b65..ec33dcba 100644 --- a/artifacts/tx-os/src/locales/en.json +++ b/artifacts/tx-os/src/locales/en.json @@ -495,15 +495,31 @@ "confirmRemovalBody_other": "Saving will remove permissions from {{count}} users. This may revoke their access immediately. Continue?", "confirmRemovalAction": "Remove permissions", "historyTitle": "History", - "historyHint": "Recent permission changes for this role.", + "historyHint": "Permission changes for this role. Use the filters to narrow by who changed it or when.", "historyEmpty": "No permission changes have been recorded for this role yet.", + "historyEmptyFiltered": "No permission changes match the current filters.", "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" + "historyTotal_other": "{{count}} permissions after change", + "historyShowing": "Showing {{shown}} of {{total}}", + "historyLoadMore": "Load more", + "historyFilters": { + "actor": "Changed by", + "allActors": "Anyone", + "from": "From", + "to": "To", + "apply": "Apply", + "reset": "Reset" + }, + "historyErrors": { + "invalidDate": "Dates must be in YYYY-MM-DD format.", + "invertedDates": "\"From\" must be on or before \"To\".", + "fixFiltersFirst": "Fix the filters above to load history." + } }, "groups": { "searchPlaceholder": "Search groups...", diff --git a/artifacts/tx-os/src/pages/admin.tsx b/artifacts/tx-os/src/pages/admin.tsx index 680c5b2e..94400247 100644 --- a/artifacts/tx-os/src/pages/admin.tsx +++ b/artifacts/tx-os/src/pages/admin.tsx @@ -29,6 +29,7 @@ import { useGetAdminAppOpensByApp, getGetAdminAppOpensByAppQueryKey, getAdminAppOpensByApp, + getRolePermissionAudit, useGetAdminAppOpensByUser, getGetAdminAppOpensByUserQueryKey, getAdminAppOpensByUser, @@ -79,6 +80,7 @@ import type { ListAuditLogsParams, AuditLogEntry, RolePermissionsImpact, + GetRolePermissionAuditParams, RolePermissionAuditEntry, Permission, } from "@workspace/api-client-react"; @@ -3624,36 +3626,311 @@ function permissionLabel( return p ? p.name : `#${id}`; } +const ROLE_HISTORY_DATE_RE = /^\d{4}-\d{2}-\d{2}$/; +const ROLE_HISTORY_PAGE_SIZE = 10; + function RolePermissionHistory({ - entries, - loading, + roleId, + enabled, permissionsById, language, }: { - entries: RolePermissionAuditEntry[] | null; - loading: boolean; + roleId: number; + enabled: boolean; permissionsById: Map; language: string; }) { const { t } = useTranslation(); + + // Two layers of state: the live values (typed in by the admin) + // and the "applied" values that are actually fed into the query. The + // dropdown for the actor user is applied immediately because picking a + // different person is the natural way to commit; the date inputs only + // commit on the explicit Apply button so partial date typing doesn't + // refetch on every keystroke. + const [actorUserId, setActorUserId] = useState(""); + const [fromInput, setFromInput] = useState(""); + const [toInput, setToInput] = useState(""); + const [appliedFrom, setAppliedFrom] = useState(""); + const [appliedTo, setAppliedTo] = useState(""); + + // True offset pagination: the first page comes from the React Query cache + // (so live updates from PUT /roles/:id/permissions invalidations can + // refresh it), and subsequent pages are fetched imperatively and appended + // to `extraEntries`. We track the next offset to request, plus per-page + // load state, so admins can keep paging back through arbitrarily long + // histories without a hard ceiling. + const [extraEntries, setExtraEntries] = useState([]); + const [extraNextOffset, setExtraNextOffset] = useState(null); + const [hasExtras, setHasExtras] = useState(false); + const [isLoadingMore, setIsLoadingMore] = useState(false); + const [loadMoreError, setLoadMoreError] = useState(null); + + // Reset filters and pagination whenever the dialog switches to a + // different role so an admin doesn't see stale filters carried over. + useEffect(() => { + setActorUserId(""); + setFromInput(""); + setToInput(""); + setAppliedFrom(""); + setAppliedTo(""); + setExtraEntries([]); + setExtraNextOffset(null); + setHasExtras(false); + setLoadMoreError(null); + setIsLoadingMore(false); + }, [roleId]); + + const fromValid = !appliedFrom || ROLE_HISTORY_DATE_RE.test(appliedFrom); + const toValid = !appliedTo || ROLE_HISTORY_DATE_RE.test(appliedTo); + const orderValid = !appliedFrom || !appliedTo || appliedFrom <= appliedTo; + const filtersValid = fromValid && toValid && orderValid; + + const { data: users } = useListUsers(undefined, { + query: { queryKey: getListUsersQueryKey(), enabled }, + }); + const sortedUsers = useMemo(() => { + const list = (users ?? []).slice(); + list.sort((a, b) => { + const aLabel = + (language === "ar" + ? a.displayNameAr ?? a.displayNameEn + : a.displayNameEn ?? a.displayNameAr) ?? a.username; + const bLabel = + (language === "ar" + ? b.displayNameAr ?? b.displayNameEn + : b.displayNameEn ?? b.displayNameAr) ?? b.username; + return aLabel.localeCompare(bLabel); + }); + return list; + }, [users, language]); + + // First-page params — only this page lives in React Query so cache + // invalidations after a permission save naturally refresh it. The other + // pages are fetched imperatively below. + const firstPageParams: GetRolePermissionAuditParams = { + limit: ROLE_HISTORY_PAGE_SIZE, + offset: 0, + ...(actorUserId !== "" ? { actorUserId: actorUserId as number } : {}), + ...(appliedFrom ? { from: appliedFrom } : {}), + ...(appliedTo ? { to: appliedTo } : {}), + }; + + const { data, isLoading, isFetching } = useGetRolePermissionAudit( + roleId, + firstPageParams, + { + query: { + queryKey: getGetRolePermissionAuditQueryKey(roleId, firstPageParams), + enabled: enabled && filtersValid, + }, + }, + ); + + // When the first page changes (filters applied) wipe the appended extra + // pages so we don't end up with overlapping or out-of-order rows. + const filtersKey = JSON.stringify(firstPageParams); + useEffect(() => { + setExtraEntries([]); + setExtraNextOffset(null); + setHasExtras(false); + setLoadMoreError(null); + setIsLoadingMore(false); + }, [filtersKey]); + + // Belt-and-braces: also reset the appended pages whenever React Query + // refetches the first page under the same filter key (e.g. an external + // cache invalidation triggered by a permission save while the dialog + // is still open). Without this, the appended pages could drift out of + // sync with the refreshed first page. + const firstPageSignature = data + ? `${data.totalCount}|${data.entries[0]?.id ?? ""}` + : null; + useEffect(() => { + if (firstPageSignature == null) return; + setExtraEntries([]); + setExtraNextOffset(null); + setHasExtras(false); + setLoadMoreError(null); + setIsLoadingMore(false); + }, [firstPageSignature]); + + const firstPageEntries = data?.entries ?? []; + const totalCount = data?.totalCount ?? 0; + const entries = hasExtras + ? [...firstPageEntries, ...extraEntries] + : firstPageEntries; + const showingCount = entries.length; + const nextOffset = hasExtras ? extraNextOffset : data?.nextOffset ?? null; + const hasMore = nextOffset !== null; + + const applyFilters = () => { + setAppliedFrom(fromInput.trim()); + setAppliedTo(toInput.trim()); + // Pagination state is reset by the filtersKey effect above. + }; + + const resetFilters = () => { + setActorUserId(""); + setFromInput(""); + setToInput(""); + setAppliedFrom(""); + setAppliedTo(""); + }; + + const loadMore = async () => { + if (nextOffset === null || isLoadingMore) return; + setIsLoadingMore(true); + setLoadMoreError(null); + try { + const next = await getRolePermissionAudit(roleId, { + ...firstPageParams, + offset: nextOffset, + }); + setExtraEntries((prev) => [...prev, ...next.entries]); + setExtraNextOffset(next.nextOffset); + setHasExtras(true); + } catch (e) { + setLoadMoreError( + (e as { message?: string }).message ?? t("common.error"), + ); + } finally { + setIsLoadingMore(false); + } + }; + + const filtersActive = + actorUserId !== "" || appliedFrom !== "" || appliedTo !== ""; + + const filterErrors: string[] = []; + if (!fromValid || !toValid) + filterErrors.push(t("admin.roles.historyErrors.invalidDate")); + else if (!orderValid) + filterErrors.push(t("admin.roles.historyErrors.invertedDates")); + return ( -
+

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

+
+
+ + +
+
+ + setFromInput(e.target.value)} + className="bg-white/70 border-slate-200" + data-testid="role-history-from-input" + /> +
+
+ + setToInput(e.target.value)} + className="bg-white/70 border-slate-200" + data-testid="role-history-to-input" + /> +
+
+ + +
+
+ {filterErrors.map((msg, i) => ( +

+ {msg} +

+ ))} +
+ {filtersValid + ? t("admin.roles.historyShowing", { + shown: showingCount, + total: totalCount, + }) + : t("admin.roles.historyErrors.fixFiltersFirst")} +
- {loading ? ( + {!filtersValid ? ( + // Hide stale entries while the filter inputs are invalid so the + // list can't be confused with the (now wrong) filtered results. +

+ {t("admin.roles.historyErrors.fixFiltersFirst")} +

+ ) : isLoading ? (
- ) : !entries || entries.length === 0 ? ( + ) : entries.length === 0 ? (

- {t("admin.roles.historyEmpty")} + {filtersActive + ? t("admin.roles.historyEmptyFiltered") + : t("admin.roles.historyEmpty")}

) : ( entries.map((entry) => { @@ -3728,6 +4005,32 @@ function RolePermissionHistory({ }) )}
+ {filtersValid && hasMore && ( +
+ +
+ )} + {loadMoreError && ( +

+ {loadMoreError} +

+ )}
); } @@ -3778,20 +4081,6 @@ 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); @@ -3976,10 +4265,11 @@ function RolesPanel() { queryClient.invalidateQueries({ queryKey: getGetRolePermissionsQueryKey(editingRoleId), }); + // Invalidate every cached page/filter for this role's audit so + // the History panel picks up the new entry no matter which + // pagination/filter the admin currently has applied. queryClient.invalidateQueries({ - queryKey: getGetRolePermissionAuditQueryKey(editingRoleId, { - limit: auditLimit, - }), + queryKey: [`/api/roles/${editingRoleId}/audit`], }); closeEditDialog(); toast({ title: t("common.success") }); @@ -4387,8 +4677,8 @@ function RolesPanel() { )}
diff --git a/lib/api-client-react/src/generated/api.schemas.ts b/lib/api-client-react/src/generated/api.schemas.ts index 09253ab5..2f22a92b 100644 --- a/lib/api-client-react/src/generated/api.schemas.ts +++ b/lib/api-client-react/src/generated/api.schemas.ts @@ -901,6 +901,15 @@ export interface RolePermissionAuditEntry { actor: AuditLogActor | null; } +export interface RolePermissionAuditList { + entries: RolePermissionAuditEntry[]; + totalCount: number; + limit: number; + offset: number; + /** @nullable */ + nextOffset: number | null; +} + /** * @nullable */ @@ -984,9 +993,28 @@ export type DeleteUserParams = { export type GetRolePermissionAuditParams = { /** * @minimum 1 - * @maximum 50 + * @maximum 200 */ limit?: number; + /** + * @minimum 0 + */ + offset?: number; + /** + * Restrict results to entries authored by the given user. Use +`0` or omit to return entries from any actor. + + * @minimum 0 + */ + actorUserId?: number; + /** + * Start date (inclusive, YYYY-MM-DD UTC). + */ + from?: string; + /** + * End date (inclusive, YYYY-MM-DD UTC). + */ + to?: string; }; export type ListGroupsParams = { diff --git a/lib/api-client-react/src/generated/api.ts b/lib/api-client-react/src/generated/api.ts index 261a5c7e..90fb83f9 100644 --- a/lib/api-client-react/src/generated/api.ts +++ b/lib/api-client-react/src/generated/api.ts @@ -70,7 +70,7 @@ import type { ResetPasswordBody, Role, RoleDeletionConflict, - RolePermissionAuditEntry, + RolePermissionAuditList, RolePermissionsImpact, RolePermissionsImpactBody, RoleUsage, @@ -5494,8 +5494,12 @@ export function useGetRoleUsage< } /** - * Returns the most recent permission-change audit records for the given -role, newest first. Each record captures the actor (if known), the + * Returns permission-change audit records for the given role, newest +first. Supports offset-based pagination via `limit`/`offset` and +optional filters by acting user (`actorUserId`) and a date range +(`from`/`to`, inclusive UTC days). The response includes the total +matching count and the next offset to load (or `null` when the end +has been reached). Each entry captures the actor (if known), the previous permission IDs, the new permission IDs, and the timestamp. * @summary List recent permission-change audit entries for a role (admin) @@ -5523,8 +5527,8 @@ export const getRolePermissionAudit = async ( id: number, params?: GetRolePermissionAuditParams, options?: RequestInit, -): Promise => { - return customFetch( +): Promise => { + return customFetch( getGetRolePermissionAuditUrl(id, params), { ...options, diff --git a/lib/api-spec/openapi.yaml b/lib/api-spec/openapi.yaml index b0928399..21d5f38e 100644 --- a/lib/api-spec/openapi.yaml +++ b/lib/api-spec/openapi.yaml @@ -1547,8 +1547,12 @@ paths: tags: [roles] summary: List recent permission-change audit entries for a role (admin) description: | - Returns the most recent permission-change audit records for the given - role, newest first. Each record captures the actor (if known), the + Returns permission-change audit records for the given role, newest + first. Supports offset-based pagination via `limit`/`offset` and + optional filters by acting user (`actorUserId`) and a date range + (`from`/`to`, inclusive UTC days). The response includes the total + matching count and the next offset to load (or `null` when the end + has been reached). Each entry captures the actor (if known), the previous permission IDs, the new permission IDs, and the timestamp. parameters: - name: id @@ -1562,17 +1566,51 @@ paths: schema: type: integer minimum: 1 - maximum: 50 + maximum: 200 default: 10 + - in: query + name: offset + required: false + schema: + type: integer + minimum: 0 + default: 0 + - in: query + name: actorUserId + required: false + schema: + type: integer + minimum: 0 + description: | + Restrict results to entries authored by the given user. Use + `0` or omit to return entries from any actor. + - in: query + name: from + required: false + schema: + type: string + format: date + description: Start date (inclusive, YYYY-MM-DD UTC). + - in: query + name: to + required: false + schema: + type: string + format: date + description: End date (inclusive, YYYY-MM-DD UTC). responses: "200": - description: Recent permission-change audit entries for the role + description: A page of permission-change audit entries for the role content: application/json: schema: - type: array - items: - $ref: "#/components/schemas/RolePermissionAuditEntry" + $ref: "#/components/schemas/RolePermissionAuditList" + "400": + description: Invalid filter parameters + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" "404": description: Role not found content: @@ -3764,6 +3802,28 @@ components: - id - username + RolePermissionAuditList: + type: object + properties: + entries: + type: array + items: + $ref: "#/components/schemas/RolePermissionAuditEntry" + totalCount: + type: integer + limit: + type: integer + offset: + type: integer + nextOffset: + type: ["integer", "null"] + required: + - entries + - totalCount + - limit + - offset + - nextOffset + RolePermissionAuditEntry: type: object properties: diff --git a/lib/api-zod/src/generated/api.ts b/lib/api-zod/src/generated/api.ts index 3caac32d..f04b60e1 100644 --- a/lib/api-zod/src/generated/api.ts +++ b/lib/api-zod/src/generated/api.ts @@ -1956,8 +1956,12 @@ export const GetRoleUsageResponse = zod.object({ }); /** - * Returns the most recent permission-change audit records for the given -role, newest first. Each record captures the actor (if known), the + * Returns permission-change audit records for the given role, newest +first. Supports offset-based pagination via `limit`/`offset` and +optional filters by acting user (`actorUserId`) and a date range +(`from`/`to`, inclusive UTC days). The response includes the total +matching count and the next offset to load (or `null` when the end +has been reached). Each entry captures the actor (if known), the previous permission IDs, the new permission IDs, and the timestamp. * @summary List recent permission-change audit entries for a role (admin) @@ -1967,7 +1971,12 @@ export const GetRolePermissionAuditParams = zod.object({ }); export const getRolePermissionAuditQueryLimitDefault = 10; -export const getRolePermissionAuditQueryLimitMax = 50; +export const getRolePermissionAuditQueryLimitMax = 200; + +export const getRolePermissionAuditQueryOffsetDefault = 0; +export const getRolePermissionAuditQueryOffsetMin = 0; + +export const getRolePermissionAuditQueryActorUserIdMin = 0; export const GetRolePermissionAuditQueryParams = zod.object({ limit: zod.coerce @@ -1975,30 +1984,51 @@ export const GetRolePermissionAuditQueryParams = zod.object({ .min(1) .max(getRolePermissionAuditQueryLimitMax) .default(getRolePermissionAuditQueryLimitDefault), + offset: zod.coerce + .number() + .min(getRolePermissionAuditQueryOffsetMin) + .default(getRolePermissionAuditQueryOffsetDefault), + actorUserId: zod.coerce + .number() + .min(getRolePermissionAuditQueryActorUserIdMin) + .optional() + .describe( + "Restrict results to entries authored by the given user. Use\n`0` or omit to return entries from any actor.\n", + ), + from: zod + .date() + .optional() + .describe("Start date (inclusive, YYYY-MM-DD UTC)."), + to: zod.date().optional().describe("End date (inclusive, YYYY-MM-DD UTC)."), }); -export const GetRolePermissionAuditResponseItem = zod.object({ - id: zod.number(), - roleId: zod.number(), - previousPermissionIds: zod.array(zod.number()), - newPermissionIds: zod.array(zod.number()), - addedPermissionIds: zod.array(zod.number()), - removedPermissionIds: zod.array(zod.number()), - createdAt: zod.coerce.date(), - actor: zod.union([ +export const GetRolePermissionAuditResponse = zod.object({ + entries: zod.array( zod.object({ id: zod.number(), - username: zod.string(), - displayNameAr: zod.string().nullish(), - displayNameEn: zod.string().nullish(), - avatarUrl: zod.string().nullish(), + roleId: zod.number(), + previousPermissionIds: zod.array(zod.number()), + newPermissionIds: zod.array(zod.number()), + addedPermissionIds: zod.array(zod.number()), + removedPermissionIds: zod.array(zod.number()), + createdAt: zod.coerce.date(), + actor: zod.union([ + zod.object({ + id: zod.number(), + username: zod.string(), + displayNameAr: zod.string().nullish(), + displayNameEn: zod.string().nullish(), + avatarUrl: zod.string().nullish(), + }), + zod.null(), + ]), }), - zod.null(), - ]), + ), + totalCount: zod.number(), + limit: zod.number(), + offset: zod.number(), + nextOffset: zod.number().nullable(), }); -export const GetRolePermissionAuditResponse = zod.array( - GetRolePermissionAuditResponseItem, -); /** * Given a candidate set of permission IDs for the role, returns the list of