Task #146: Filter and paginate role permission history
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
This commit is contained in:
@@ -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<void> => {
|
||||
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<void> => {
|
||||
const id = Number(req.params.id);
|
||||
if (!Number.isInteger(id)) {
|
||||
@@ -69,15 +151,31 @@ router.get("/roles/:id/audit", requireAdmin, async (req, res): Promise<void> =>
|
||||
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<void> =>
|
||||
})
|
||||
.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<number>`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<void> =>
|
||||
: 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<void> => {
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
});
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
@@ -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": "ابحث في المجموعات...",
|
||||
|
||||
@@ -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...",
|
||||
|
||||
@@ -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<number, Permission>;
|
||||
language: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Two layers of state: the live <input> 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<number | "">("");
|
||||
const [fromInput, setFromInput] = useState<string>("");
|
||||
const [toInput, setToInput] = useState<string>("");
|
||||
const [appliedFrom, setAppliedFrom] = useState<string>("");
|
||||
const [appliedTo, setAppliedTo] = useState<string>("");
|
||||
|
||||
// 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<RolePermissionAuditEntry[]>([]);
|
||||
const [extraNextOffset, setExtraNextOffset] = useState<number | null>(null);
|
||||
const [hasExtras, setHasExtras] = useState(false);
|
||||
const [isLoadingMore, setIsLoadingMore] = useState(false);
|
||||
const [loadMoreError, setLoadMoreError] = useState<string | null>(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 (
|
||||
<div className="space-y-1 pt-2" data-testid="role-history-section">
|
||||
<div className="space-y-2 pt-2" data-testid="role-history-section">
|
||||
<Label>{t("admin.roles.historyTitle")}</Label>
|
||||
<p className="text-xs text-muted-foreground">{t("admin.roles.historyHint")}</p>
|
||||
<div
|
||||
className="grid gap-2 sm:grid-cols-2 md:grid-cols-4"
|
||||
data-testid="role-history-filters"
|
||||
>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">{t("admin.roles.historyFilters.actor")}</Label>
|
||||
<select
|
||||
value={actorUserId === "" ? "" : String(actorUserId)}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value;
|
||||
setActorUserId(v === "" ? "" : Number(v));
|
||||
// Pagination state is reset by the filtersKey effect.
|
||||
}}
|
||||
className="bg-white/70 border border-slate-200 rounded-lg px-3 py-2 text-sm w-full"
|
||||
data-testid="role-history-actor-filter"
|
||||
>
|
||||
<option value="">
|
||||
{t("admin.roles.historyFilters.allActors")}
|
||||
</option>
|
||||
{sortedUsers.map((u) => {
|
||||
const label =
|
||||
(language === "ar"
|
||||
? u.displayNameAr ?? u.displayNameEn
|
||||
: u.displayNameEn ?? u.displayNameAr) ?? u.username;
|
||||
return (
|
||||
<option key={u.id} value={u.id}>
|
||||
{label}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">{t("admin.roles.historyFilters.from")}</Label>
|
||||
<Input
|
||||
type="date"
|
||||
value={fromInput}
|
||||
onChange={(e) => setFromInput(e.target.value)}
|
||||
className="bg-white/70 border-slate-200"
|
||||
data-testid="role-history-from-input"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">{t("admin.roles.historyFilters.to")}</Label>
|
||||
<Input
|
||||
type="date"
|
||||
value={toInput}
|
||||
onChange={(e) => setToInput(e.target.value)}
|
||||
className="bg-white/70 border-slate-200"
|
||||
data-testid="role-history-to-input"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-end gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
onClick={applyFilters}
|
||||
className="flex-1"
|
||||
data-testid="role-history-apply-filters"
|
||||
>
|
||||
{t("admin.roles.historyFilters.apply")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={resetFilters}
|
||||
disabled={!filtersActive && !fromInput && !toInput}
|
||||
data-testid="role-history-reset-filters"
|
||||
>
|
||||
{t("admin.roles.historyFilters.reset")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{filterErrors.map((msg, i) => (
|
||||
<p
|
||||
key={i}
|
||||
className="text-xs text-destructive"
|
||||
data-testid="role-history-filter-error"
|
||||
>
|
||||
{msg}
|
||||
</p>
|
||||
))}
|
||||
<div
|
||||
className="text-xs text-muted-foreground"
|
||||
data-testid="role-history-showing"
|
||||
>
|
||||
{filtersValid
|
||||
? t("admin.roles.historyShowing", {
|
||||
shown: showingCount,
|
||||
total: totalCount,
|
||||
})
|
||||
: t("admin.roles.historyErrors.fixFiltersFirst")}
|
||||
</div>
|
||||
<div
|
||||
className="border border-slate-200 rounded-xl bg-white/60 max-h-56 overflow-y-auto divide-y divide-slate-100"
|
||||
data-testid="role-history-list"
|
||||
>
|
||||
{loading ? (
|
||||
{!filtersValid ? (
|
||||
// Hide stale entries while the filter inputs are invalid so the
|
||||
// list can't be confused with the (now wrong) filtered results.
|
||||
<p
|
||||
className="text-xs text-muted-foreground p-3 text-center"
|
||||
data-testid="role-history-fix-filters"
|
||||
>
|
||||
{t("admin.roles.historyErrors.fixFiltersFirst")}
|
||||
</p>
|
||||
) : isLoading ? (
|
||||
<div className="flex justify-center py-6 text-muted-foreground">
|
||||
<Loader2 size={16} className="animate-spin" />
|
||||
</div>
|
||||
) : !entries || entries.length === 0 ? (
|
||||
) : entries.length === 0 ? (
|
||||
<p
|
||||
className="text-xs text-muted-foreground p-3 text-center"
|
||||
data-testid="role-history-empty"
|
||||
>
|
||||
{t("admin.roles.historyEmpty")}
|
||||
{filtersActive
|
||||
? t("admin.roles.historyEmptyFiltered")
|
||||
: t("admin.roles.historyEmpty")}
|
||||
</p>
|
||||
) : (
|
||||
entries.map((entry) => {
|
||||
@@ -3728,6 +4005,32 @@ function RolePermissionHistory({
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
{filtersValid && hasMore && (
|
||||
<div className="flex justify-center pt-1">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={isLoadingMore || isFetching}
|
||||
onClick={loadMore}
|
||||
data-testid="role-history-load-more"
|
||||
>
|
||||
{isLoadingMore || isFetching ? (
|
||||
<Loader2 size={14} className="animate-spin" />
|
||||
) : (
|
||||
t("admin.roles.historyLoadMore")
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{loadMoreError && (
|
||||
<p
|
||||
className="text-xs text-destructive text-center"
|
||||
data-testid="role-history-load-more-error"
|
||||
>
|
||||
{loadMoreError}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<number, Permission>();
|
||||
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() {
|
||||
)}
|
||||
</div>
|
||||
<RolePermissionHistory
|
||||
entries={editingRoleAudit ?? null}
|
||||
loading={editingAuditLoading}
|
||||
roleId={editingPermissionsId}
|
||||
enabled={editingId != null}
|
||||
permissionsById={permissionsById}
|
||||
language={i18n.language}
|
||||
/>
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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<RolePermissionAuditEntry[]> => {
|
||||
return customFetch<RolePermissionAuditEntry[]>(
|
||||
): Promise<RolePermissionAuditList> => {
|
||||
return customFetch<RolePermissionAuditList>(
|
||||
getGetRolePermissionAuditUrl(id, params),
|
||||
{
|
||||
...options,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user