Add functionality to download role permission audit history as a CSV file
Introduce a new admin-only API endpoint for exporting role permission audit data to CSV, including resolved permission names and UTF-8 BOM for Excel compatibility. Frontend button and backend logic implemented to support this feature. Replit-Commit-Author: Agent Replit-Commit-Session-Id: 77cfe984-2c65-4152-bb7a-0df28274fe66 Replit-Commit-Checkpoint-Type: full_checkpoint Replit-Commit-Event-Id: 0cb48020-8f0c-42bb-8fe8-d638905f7fce Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/c3c252e4-c83d-40ca-9fff-99a3ea60701e/77cfe984-2c65-4152-bb7a-0df28274fe66/g7BgHDL Replit-Helium-Checkpoint-Created: true
This commit is contained in:
@@ -273,6 +273,154 @@ router.get("/roles/:id/audit", requireAdmin, async (req, res): Promise<void> =>
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Hard cap on the CSV export size. Each audit row is small (a handful of
|
||||||
|
// short fields), so 10k rows is comfortably under a few MB even with
|
||||||
|
// long permission lists. Caps the working-set memory and prevents an
|
||||||
|
// admin from accidentally tying up the connection on a giant role with
|
||||||
|
// years of churn. Date-range filters are the recommended way to scope
|
||||||
|
// larger exports.
|
||||||
|
const ROLE_AUDIT_CSV_MAX_ROWS = 10_000;
|
||||||
|
|
||||||
|
// RFC 4180 escaping: wrap a field in double quotes and double any
|
||||||
|
// embedded quotes if it contains comma, quote, CR, or LF. Otherwise
|
||||||
|
// emit the field verbatim. Numeric / null values are coerced to string
|
||||||
|
// first so callers don't need to handle them at every site.
|
||||||
|
function csvEscape(value: string | number | null | undefined): string {
|
||||||
|
if (value === null || value === undefined) return "";
|
||||||
|
const s = String(value);
|
||||||
|
if (/[",\r\n]/.test(s)) return `"${s.replace(/"/g, '""')}"`;
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
"/roles/:id/audit.csv",
|
||||||
|
requireAdmin,
|
||||||
|
async (req, res): Promise<void> => {
|
||||||
|
// Mirrors GET /roles/:id/audit's contract (admin-only, same actor +
|
||||||
|
// date filters, same 400/404 semantics) but returns a downloadable
|
||||||
|
// CSV containing all matching rows up to ROLE_AUDIT_CSV_MAX_ROWS,
|
||||||
|
// with permission IDs resolved to human-readable names so the file
|
||||||
|
// is useful in a spreadsheet without a separate join. Used by the
|
||||||
|
// "Download CSV" button in the admin role-history panel (#205).
|
||||||
|
const id = Number(req.params.id);
|
||||||
|
if (!Number.isInteger(id)) {
|
||||||
|
res.status(400).json({ error: "Invalid id" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [role] = await db
|
||||||
|
.select({ id: rolesTable.id, name: rolesTable.name })
|
||||||
|
.from(rolesTable)
|
||||||
|
.where(eq(rolesTable.id, id));
|
||||||
|
if (!role) {
|
||||||
|
res.status(404).json({ error: "Role not found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse filters AFTER the role-existence check so 404 takes
|
||||||
|
// precedence over 400 — same ordering as the JSON endpoint.
|
||||||
|
const filters = parseRoleAuditFilters(req, res);
|
||||||
|
if (!filters) return;
|
||||||
|
|
||||||
|
const whereClause = buildRoleAuditWhere(id, filters);
|
||||||
|
|
||||||
|
const rows = await db
|
||||||
|
.select({
|
||||||
|
id: rolePermissionAuditTable.id,
|
||||||
|
previousPermissionIds: rolePermissionAuditTable.previousPermissionIds,
|
||||||
|
newPermissionIds: rolePermissionAuditTable.newPermissionIds,
|
||||||
|
createdAt: rolePermissionAuditTable.createdAt,
|
||||||
|
actorUsername: usersTable.username,
|
||||||
|
})
|
||||||
|
.from(rolePermissionAuditTable)
|
||||||
|
.leftJoin(
|
||||||
|
usersTable,
|
||||||
|
eq(usersTable.id, rolePermissionAuditTable.actorUserId),
|
||||||
|
)
|
||||||
|
.where(whereClause)
|
||||||
|
.orderBy(
|
||||||
|
desc(rolePermissionAuditTable.createdAt),
|
||||||
|
desc(rolePermissionAuditTable.id),
|
||||||
|
)
|
||||||
|
.limit(ROLE_AUDIT_CSV_MAX_ROWS);
|
||||||
|
|
||||||
|
// Collect every permission id referenced anywhere in the page so we
|
||||||
|
// can resolve names in a single SELECT instead of N per row. Unknown
|
||||||
|
// ids (e.g. a permission that was deleted after the audit row was
|
||||||
|
// written) degrade to "#<id>" — same fallback the UI uses.
|
||||||
|
const permissionIdSet = new Set<number>();
|
||||||
|
for (const r of rows) {
|
||||||
|
for (const p of (r.previousPermissionIds ?? []) as number[])
|
||||||
|
permissionIdSet.add(p);
|
||||||
|
for (const p of (r.newPermissionIds ?? []) as number[])
|
||||||
|
permissionIdSet.add(p);
|
||||||
|
}
|
||||||
|
const permissionNameById = new Map<number, string>();
|
||||||
|
if (permissionIdSet.size > 0) {
|
||||||
|
const permRows = await db
|
||||||
|
.select({ id: permissionsTable.id, name: permissionsTable.name })
|
||||||
|
.from(permissionsTable)
|
||||||
|
.where(inArray(permissionsTable.id, Array.from(permissionIdSet)));
|
||||||
|
for (const p of permRows) permissionNameById.set(p.id, p.name);
|
||||||
|
}
|
||||||
|
const labelFor = (pid: number): string =>
|
||||||
|
permissionNameById.get(pid) ?? `#${pid}`;
|
||||||
|
|
||||||
|
// Build the file in memory — capped at 10k rows above and each row
|
||||||
|
// is short, so the worst-case payload is a few MB. Streaming is not
|
||||||
|
// worth the complexity at this size.
|
||||||
|
const lines: string[] = [];
|
||||||
|
lines.push(
|
||||||
|
[
|
||||||
|
"timestamp",
|
||||||
|
"actor_username",
|
||||||
|
"added_permissions",
|
||||||
|
"removed_permissions",
|
||||||
|
]
|
||||||
|
.map(csvEscape)
|
||||||
|
.join(","),
|
||||||
|
);
|
||||||
|
for (const r of rows) {
|
||||||
|
const prev = (r.previousPermissionIds ?? []) as number[];
|
||||||
|
const next = (r.newPermissionIds ?? []) as number[];
|
||||||
|
const prevSet = new Set(prev);
|
||||||
|
const nextSet = new Set(next);
|
||||||
|
const added = next.filter((p) => !prevSet.has(p)).map(labelFor);
|
||||||
|
const removed = prev.filter((p) => !nextSet.has(p)).map(labelFor);
|
||||||
|
const ts =
|
||||||
|
r.createdAt instanceof Date
|
||||||
|
? r.createdAt.toISOString()
|
||||||
|
: String(r.createdAt ?? "");
|
||||||
|
lines.push(
|
||||||
|
[ts, r.actorUsername ?? "", added.join("; "), removed.join("; ")]
|
||||||
|
.map(csvEscape)
|
||||||
|
.join(","),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Trailing newline so naive `tail -1` / `wc -l` workflows don't
|
||||||
|
// miscount, and so concatenating two CSVs doesn't smash rows.
|
||||||
|
const body = `\uFEFF${lines.join("\r\n")}\r\n`;
|
||||||
|
|
||||||
|
const stamp = new Date().toISOString().slice(0, 10).replace(/-/g, "");
|
||||||
|
// The role name is allowed in the suggested filename for context
|
||||||
|
// but stripped of anything other than [A-Za-z0-9_-] so it can't
|
||||||
|
// smuggle a quote into the Content-Disposition header. Falls back
|
||||||
|
// to the numeric id when the sanitized name would be empty.
|
||||||
|
const safeName = (role.name ?? "").replace(/[^A-Za-z0-9_-]/g, "");
|
||||||
|
const filename = `role-${safeName || role.id}-history-${stamp}.csv`;
|
||||||
|
|
||||||
|
res.setHeader("Content-Type", "text/csv; charset=utf-8");
|
||||||
|
res.setHeader(
|
||||||
|
"Content-Disposition",
|
||||||
|
`attachment; filename="${filename}"`,
|
||||||
|
);
|
||||||
|
// The export is a snapshot in time — discourage proxy / browser
|
||||||
|
// caching so a re-run after a permission change reflects reality.
|
||||||
|
res.setHeader("Cache-Control", "no-store");
|
||||||
|
res.status(200).send(body);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
router.get("/roles/:id/usage", requireAdmin, async (req, res): Promise<void> => {
|
router.get("/roles/:id/usage", requireAdmin, async (req, res): Promise<void> => {
|
||||||
const id = Number(req.params.id);
|
const id = Number(req.params.id);
|
||||||
if (!Number.isInteger(id)) {
|
if (!Number.isInteger(id)) {
|
||||||
|
|||||||
@@ -466,3 +466,208 @@ test("GET /api/roles/:id/audit filters by date range", async () => {
|
|||||||
assert.equal(r.status, 400, `expected 400 for from=${bad}`);
|
assert.equal(r.status, 400, `expected 400 for from=${bad}`);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// --- GET /api/roles/:id/audit.csv -------------------------------------------
|
||||||
|
// CSV export endpoint added for #205. The download is admin-only, returns
|
||||||
|
// `text/csv` with an attachment Content-Disposition, prepends a UTF-8 BOM
|
||||||
|
// for spreadsheet apps, and resolves permission IDs to human-readable
|
||||||
|
// names so the file is useful in Excel without a separate join.
|
||||||
|
|
||||||
|
// Parse a single CSV line into fields, honouring RFC 4180 double-quote
|
||||||
|
// escaping. Tests build comma-separated rows by hand on the server side
|
||||||
|
// so we need a small parser that knows about quoted fields. Returns null
|
||||||
|
// inputs as undefined.
|
||||||
|
function parseCsvLine(line) {
|
||||||
|
const fields = [];
|
||||||
|
let cur = "";
|
||||||
|
let inQuotes = false;
|
||||||
|
for (let i = 0; i < line.length; i++) {
|
||||||
|
const ch = line[i];
|
||||||
|
if (inQuotes) {
|
||||||
|
if (ch === '"') {
|
||||||
|
if (line[i + 1] === '"') {
|
||||||
|
cur += '"';
|
||||||
|
i++;
|
||||||
|
} else {
|
||||||
|
inQuotes = false;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
cur += ch;
|
||||||
|
}
|
||||||
|
} else if (ch === ",") {
|
||||||
|
fields.push(cur);
|
||||||
|
cur = "";
|
||||||
|
} else if (ch === '"') {
|
||||||
|
inQuotes = true;
|
||||||
|
} else {
|
||||||
|
cur += ch;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fields.push(cur);
|
||||||
|
return fields;
|
||||||
|
}
|
||||||
|
|
||||||
|
test("GET /api/roles/:id/audit.csv exports CSV with headers and resolved permission names", async () => {
|
||||||
|
const role = await createRole();
|
||||||
|
const [p1, p2] = await getTwoPermissionIds();
|
||||||
|
|
||||||
|
// Look up the permission names so the assertions below don't depend
|
||||||
|
// on whatever the seeded data calls them.
|
||||||
|
const permRows = await pool.query(
|
||||||
|
`SELECT id, name FROM permissions WHERE id = ANY($1::int[])`,
|
||||||
|
[[p1, p2]],
|
||||||
|
);
|
||||||
|
const nameById = new Map(permRows.rows.map((r) => [r.id, r.name]));
|
||||||
|
|
||||||
|
// Two writes: first adds p1, second swaps p1 → p2 (so the second
|
||||||
|
// row should have one added and one removed permission).
|
||||||
|
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 res = await fetch(`${API_BASE}/api/roles/${role.id}/audit.csv`, {
|
||||||
|
headers: { Cookie: adminCookie },
|
||||||
|
});
|
||||||
|
assert.equal(res.status, 200);
|
||||||
|
const ctype = res.headers.get("content-type") ?? "";
|
||||||
|
assert.match(ctype, /^text\/csv/i, `unexpected Content-Type: ${ctype}`);
|
||||||
|
const dispo = res.headers.get("content-disposition") ?? "";
|
||||||
|
assert.match(dispo, /attachment;\s*filename="role-/i);
|
||||||
|
assert.match(dispo, /\.csv"?$/i);
|
||||||
|
|
||||||
|
// Read raw bytes so we can verify the UTF-8 BOM is present — the
|
||||||
|
// standard `Response.text()` decoder strips a leading BOM, so we'd
|
||||||
|
// otherwise miss a regression where the server forgot to emit it.
|
||||||
|
// The BOM is required for Excel to render Arabic permission names
|
||||||
|
// correctly when an admin opens the file.
|
||||||
|
const buf = new Uint8Array(await res.arrayBuffer());
|
||||||
|
assert.deepEqual(
|
||||||
|
[buf[0], buf[1], buf[2]],
|
||||||
|
[0xef, 0xbb, 0xbf],
|
||||||
|
"missing UTF-8 BOM",
|
||||||
|
);
|
||||||
|
const body = new TextDecoder("utf-8").decode(buf.subarray(3));
|
||||||
|
|
||||||
|
const lines = body.split(/\r\n|\n/).filter((l) => l.length > 0);
|
||||||
|
assert.deepEqual(parseCsvLine(lines[0]), [
|
||||||
|
"timestamp",
|
||||||
|
"actor_username",
|
||||||
|
"added_permissions",
|
||||||
|
"removed_permissions",
|
||||||
|
]);
|
||||||
|
// Two writes → two data rows.
|
||||||
|
assert.equal(lines.length - 1, 2, `expected 2 data rows, got ${lines.length - 1}`);
|
||||||
|
|
||||||
|
// Newest first: row index 1 is the p1→p2 swap, row index 2 is the
|
||||||
|
// initial add of p1.
|
||||||
|
const swap = parseCsvLine(lines[1]);
|
||||||
|
const initial = parseCsvLine(lines[2]);
|
||||||
|
assert.equal(swap[1], adminUsername);
|
||||||
|
assert.equal(swap[2], nameById.get(p2));
|
||||||
|
assert.equal(swap[3], nameById.get(p1));
|
||||||
|
assert.equal(initial[1], adminUsername);
|
||||||
|
assert.equal(initial[2], nameById.get(p1));
|
||||||
|
assert.equal(initial[3], "");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("GET /api/roles/:id/audit.csv honours actorUserId and date filters", async () => {
|
||||||
|
const role = await createRole();
|
||||||
|
const [p1, p2] = await getTwoPermissionIds();
|
||||||
|
|
||||||
|
// Spin up a second admin actor so we can prove the actor filter
|
||||||
|
// narrows the export to one row out of three.
|
||||||
|
const stamp = `${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 6)}`;
|
||||||
|
const otherUsername = `role_audit_csv_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 CSV 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);
|
||||||
|
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
|
||||||
|
// actorUserId narrows export to one row.
|
||||||
|
const filteredRes = await fetch(
|
||||||
|
`${API_BASE}/api/roles/${role.id}/audit.csv?actorUserId=${otherId}`,
|
||||||
|
{ headers: { Cookie: adminCookie } },
|
||||||
|
);
|
||||||
|
assert.equal(filteredRes.status, 200);
|
||||||
|
const filtered = (await filteredRes.text()).slice(1); // strip BOM
|
||||||
|
const filteredLines = filtered.split(/\r\n|\n/).filter((l) => l.length > 0);
|
||||||
|
assert.equal(filteredLines.length, 2, "header + 1 data row expected");
|
||||||
|
assert.equal(parseCsvLine(filteredLines[1])[1], otherUsername);
|
||||||
|
|
||||||
|
// Yesterday-only window → no data rows but still returns a header.
|
||||||
|
const yesterday = new Date(Date.now() - 24 * 60 * 60 * 1000)
|
||||||
|
.toISOString()
|
||||||
|
.slice(0, 10);
|
||||||
|
const pastRes = await fetch(
|
||||||
|
`${API_BASE}/api/roles/${role.id}/audit.csv?from=${yesterday}&to=${yesterday}`,
|
||||||
|
{ headers: { Cookie: adminCookie } },
|
||||||
|
);
|
||||||
|
assert.equal(pastRes.status, 200);
|
||||||
|
const past = (await pastRes.text()).slice(1);
|
||||||
|
const pastLines = past.split(/\r\n|\n/).filter((l) => l.length > 0);
|
||||||
|
assert.equal(pastLines.length, 1, "header only when no rows match");
|
||||||
|
|
||||||
|
// Bad filter → 400, same as the JSON endpoint.
|
||||||
|
const badRes = await fetch(
|
||||||
|
`${API_BASE}/api/roles/${role.id}/audit.csv?from=not-a-date`,
|
||||||
|
{ headers: { Cookie: adminCookie } },
|
||||||
|
);
|
||||||
|
assert.equal(badRes.status, 400);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("GET /api/roles/:id/audit.csv returns 404 for unknown role", async () => {
|
||||||
|
const res = await fetch(`${API_BASE}/api/roles/999999999/audit.csv`, {
|
||||||
|
headers: { Cookie: adminCookie },
|
||||||
|
});
|
||||||
|
assert.equal(res.status, 404);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("GET /api/roles/:id/audit.csv requires admin", async () => {
|
||||||
|
const role = await createRole();
|
||||||
|
|
||||||
|
// Brand-new user with no admin role assignment. They should be
|
||||||
|
// blocked at the requireAdmin middleware just like the JSON endpoint.
|
||||||
|
const stamp = `${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 6)}`;
|
||||||
|
const username = `role_audit_csv_nonadmin_${stamp}`;
|
||||||
|
const row = await pool.query(
|
||||||
|
`INSERT INTO users (username, email, password_hash, display_name_en, preferred_language, is_active)
|
||||||
|
VALUES ($1, $2, $3, 'CSV Non-Admin', 'en', true) RETURNING id`,
|
||||||
|
[username, `${username}@example.com`, TEST_PASSWORD_HASH],
|
||||||
|
);
|
||||||
|
createdUserIds.push(row.rows[0].id);
|
||||||
|
const cookie = await loginAndGetCookie(username, TEST_PASSWORD);
|
||||||
|
|
||||||
|
const res = await fetch(`${API_BASE}/api/roles/${role.id}/audit.csv`, {
|
||||||
|
headers: { Cookie: cookie },
|
||||||
|
});
|
||||||
|
// requireAdmin returns 403 for authenticated-but-not-admin users.
|
||||||
|
assert.equal(res.status, 403);
|
||||||
|
});
|
||||||
|
|||||||
@@ -516,6 +516,8 @@
|
|||||||
"historyTotal_other": "{{count}} صلاحية بعد التغيير",
|
"historyTotal_other": "{{count}} صلاحية بعد التغيير",
|
||||||
"historyShowing": "يظهر {{shown}} من أصل {{total}}",
|
"historyShowing": "يظهر {{shown}} من أصل {{total}}",
|
||||||
"historyLoadMore": "عرض المزيد",
|
"historyLoadMore": "عرض المزيد",
|
||||||
|
"historyDownloadCsv": "تنزيل CSV",
|
||||||
|
"historyDownloadCsvError": "تعذّر تنزيل ملف CSV. حاول مرة أخرى.",
|
||||||
"historyFilters": {
|
"historyFilters": {
|
||||||
"actor": "غُيّرت بواسطة",
|
"actor": "غُيّرت بواسطة",
|
||||||
"allActors": "أي شخص",
|
"allActors": "أي شخص",
|
||||||
|
|||||||
@@ -489,6 +489,8 @@
|
|||||||
"historyTotal_other": "{{count}} permissions after change",
|
"historyTotal_other": "{{count}} permissions after change",
|
||||||
"historyShowing": "Showing {{shown}} of {{total}}",
|
"historyShowing": "Showing {{shown}} of {{total}}",
|
||||||
"historyLoadMore": "Load more",
|
"historyLoadMore": "Load more",
|
||||||
|
"historyDownloadCsv": "Download CSV",
|
||||||
|
"historyDownloadCsvError": "Could not download CSV. Please try again.",
|
||||||
"historyFilters": {
|
"historyFilters": {
|
||||||
"actor": "Changed by",
|
"actor": "Changed by",
|
||||||
"allActors": "Anyone",
|
"allActors": "Anyone",
|
||||||
|
|||||||
@@ -93,6 +93,7 @@ import {
|
|||||||
useListAuditLogs,
|
useListAuditLogs,
|
||||||
getListAuditLogsQueryKey,
|
getListAuditLogsQueryKey,
|
||||||
getExportAuditLogsCsvUrl,
|
getExportAuditLogsCsvUrl,
|
||||||
|
getExportRolePermissionAuditCsvUrl,
|
||||||
ApiError,
|
ApiError,
|
||||||
} from "@workspace/api-client-react";
|
} from "@workspace/api-client-react";
|
||||||
import type {
|
import type {
|
||||||
@@ -4827,6 +4828,14 @@ function RolePermissionHistory({
|
|||||||
const [isLoadingMore, setIsLoadingMore] = useState(false);
|
const [isLoadingMore, setIsLoadingMore] = useState(false);
|
||||||
const [loadMoreError, setLoadMoreError] = useState<string | null>(null);
|
const [loadMoreError, setLoadMoreError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// CSV export state for the "Download CSV" button (#205). The download
|
||||||
|
// is independent of the paginated list shown above — it always asks
|
||||||
|
// the server for ALL rows matching the current filters (subject to
|
||||||
|
// a server-side cap), so the spreadsheet doesn't only contain the
|
||||||
|
// pages the admin already scrolled through.
|
||||||
|
const [isDownloadingCsv, setIsDownloadingCsv] = useState(false);
|
||||||
|
const [downloadCsvError, setDownloadCsvError] = useState<string | null>(null);
|
||||||
|
|
||||||
// Reset filters and pagination whenever the dialog switches to a
|
// Reset filters and pagination whenever the dialog switches to a
|
||||||
// different role so an admin doesn't see stale filters carried over.
|
// different role so an admin doesn't see stale filters carried over.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -4840,6 +4849,8 @@ function RolePermissionHistory({
|
|||||||
setHasExtras(false);
|
setHasExtras(false);
|
||||||
setLoadMoreError(null);
|
setLoadMoreError(null);
|
||||||
setIsLoadingMore(false);
|
setIsLoadingMore(false);
|
||||||
|
setDownloadCsvError(null);
|
||||||
|
setIsDownloadingCsv(false);
|
||||||
}, [roleId]);
|
}, [roleId]);
|
||||||
|
|
||||||
const fromValid = !appliedFrom || ROLE_HISTORY_DATE_RE.test(appliedFrom);
|
const fromValid = !appliedFrom || ROLE_HISTORY_DATE_RE.test(appliedFrom);
|
||||||
@@ -4960,6 +4971,59 @@ function RolePermissionHistory({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// CSV export handler (#205). Goes via plain `fetch` (with credentials)
|
||||||
|
// rather than the generated client because we want a Blob download
|
||||||
|
// triggered through a synthetic <a download> element — the generated
|
||||||
|
// wrapper unwraps the body which makes filename / Content-Type harder
|
||||||
|
// to use. We reuse the generated URL builder so the route + query
|
||||||
|
// serialization stays in lock-step with the OpenAPI spec. `limit` /
|
||||||
|
// `offset` are intentionally NOT forwarded — the backend exports all
|
||||||
|
// matching rows up to its own cap.
|
||||||
|
const downloadCsv = async () => {
|
||||||
|
if (!filtersValid || isDownloadingCsv) return;
|
||||||
|
setIsDownloadingCsv(true);
|
||||||
|
setDownloadCsvError(null);
|
||||||
|
try {
|
||||||
|
const url = getExportRolePermissionAuditCsvUrl(roleId, {
|
||||||
|
...(actorUserId !== "" ? { actorUserId: actorUserId as number } : {}),
|
||||||
|
...(appliedFrom ? { from: appliedFrom } : {}),
|
||||||
|
...(appliedTo ? { to: appliedTo } : {}),
|
||||||
|
});
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method: "GET",
|
||||||
|
credentials: "include",
|
||||||
|
headers: { Accept: "text/csv" },
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`HTTP ${res.status}`);
|
||||||
|
}
|
||||||
|
const blob = await res.blob();
|
||||||
|
// Honor the server-provided filename when present so admins
|
||||||
|
// get the role name in the file (server sanitizes it). Fall
|
||||||
|
// back to a timestamped default for any unexpected response.
|
||||||
|
const disposition = res.headers.get("Content-Disposition") ?? "";
|
||||||
|
const match = /filename="?([^";]+)"?/i.exec(disposition);
|
||||||
|
const stamp = new Date().toISOString().slice(0, 10).replace(/-/g, "");
|
||||||
|
const filename = match?.[1] ?? `role-${roleId}-history-${stamp}.csv`;
|
||||||
|
const objectUrl = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = objectUrl;
|
||||||
|
a.download = filename;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
a.remove();
|
||||||
|
// Defer revoke so the browser can finish the download.
|
||||||
|
setTimeout(() => URL.revokeObjectURL(objectUrl), 1000);
|
||||||
|
} catch (e) {
|
||||||
|
setDownloadCsvError(
|
||||||
|
(e as { message?: string }).message ??
|
||||||
|
t("admin.roles.historyDownloadCsvError"),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setIsDownloadingCsv(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const filtersActive =
|
const filtersActive =
|
||||||
actorUserId !== "" || appliedFrom !== "" || appliedTo !== "";
|
actorUserId !== "" || appliedFrom !== "" || appliedTo !== "";
|
||||||
|
|
||||||
@@ -5166,8 +5230,9 @@ function RolePermissionHistory({
|
|||||||
})
|
})
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{filtersValid && hasMore && (
|
{filtersValid && (hasMore || entries.length > 0) && (
|
||||||
<div className="flex justify-center pt-1">
|
<div className="flex flex-wrap items-center justify-center gap-2 pt-1">
|
||||||
|
{hasMore && (
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -5182,6 +5247,25 @@ function RolePermissionHistory({
|
|||||||
t("admin.roles.historyLoadMore")
|
t("admin.roles.historyLoadMore")
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
|
)}
|
||||||
|
{entries.length > 0 && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
className="gap-1.5"
|
||||||
|
disabled={isDownloadingCsv}
|
||||||
|
onClick={downloadCsv}
|
||||||
|
data-testid="role-history-download-csv"
|
||||||
|
>
|
||||||
|
{isDownloadingCsv ? (
|
||||||
|
<Loader2 size={14} className="animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Download size={14} />
|
||||||
|
)}
|
||||||
|
{t("admin.roles.historyDownloadCsv")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{loadMoreError && (
|
{loadMoreError && (
|
||||||
@@ -5192,6 +5276,14 @@ function RolePermissionHistory({
|
|||||||
{loadMoreError}
|
{loadMoreError}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
{downloadCsvError && (
|
||||||
|
<p
|
||||||
|
className="text-xs text-destructive text-center"
|
||||||
|
data-testid="role-history-download-csv-error"
|
||||||
|
>
|
||||||
|
{t("admin.roles.historyDownloadCsvError")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1254,6 +1254,24 @@ export type GetRolePermissionAuditParams = {
|
|||||||
to?: string;
|
to?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ExportRolePermissionAuditCsvParams = {
|
||||||
|
/**
|
||||||
|
* 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 = {
|
export type ListGroupsParams = {
|
||||||
q?: string;
|
q?: string;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ import type {
|
|||||||
DeleteUserParams,
|
DeleteUserParams,
|
||||||
ErrorResponse,
|
ErrorResponse,
|
||||||
ExportAuditLogsCsvParams,
|
ExportAuditLogsCsvParams,
|
||||||
|
ExportRolePermissionAuditCsvParams,
|
||||||
ForgotPasswordBody,
|
ForgotPasswordBody,
|
||||||
ForgotPasswordResponse,
|
ForgotPasswordResponse,
|
||||||
GetAdminAppDependentGroupsParams,
|
GetAdminAppDependentGroupsParams,
|
||||||
@@ -5742,6 +5743,128 @@ export function useGetRolePermissionAudit<
|
|||||||
return { ...query, queryKey: queryOptions.queryKey };
|
return { ...query, queryKey: queryOptions.queryKey };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the same audit rows as `GET /roles/{id}/audit` but as a
|
||||||
|
downloadable CSV file with permission IDs resolved to names.
|
||||||
|
Honors the optional `actorUserId`, `from`, and `to` filters
|
||||||
|
identically. Pagination parameters are NOT accepted: the file
|
||||||
|
contains all matching rows up to a server-side cap (currently
|
||||||
|
10,000 — narrow the date range for a longer history).
|
||||||
|
|
||||||
|
* @summary Download permission-change audit entries for a role as CSV (admin)
|
||||||
|
*/
|
||||||
|
export const getExportRolePermissionAuditCsvUrl = (
|
||||||
|
id: number,
|
||||||
|
params?: ExportRolePermissionAuditCsvParams,
|
||||||
|
) => {
|
||||||
|
const normalizedParams = new URLSearchParams();
|
||||||
|
|
||||||
|
Object.entries(params || {}).forEach(([key, value]) => {
|
||||||
|
if (value !== undefined) {
|
||||||
|
normalizedParams.append(key, value === null ? "null" : value.toString());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const stringifiedParams = normalizedParams.toString();
|
||||||
|
|
||||||
|
return stringifiedParams.length > 0
|
||||||
|
? `/api/roles/${id}/audit.csv?${stringifiedParams}`
|
||||||
|
: `/api/roles/${id}/audit.csv`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const exportRolePermissionAuditCsv = async (
|
||||||
|
id: number,
|
||||||
|
params?: ExportRolePermissionAuditCsvParams,
|
||||||
|
options?: RequestInit,
|
||||||
|
): Promise<Blob> => {
|
||||||
|
return customFetch<Blob>(getExportRolePermissionAuditCsvUrl(id, params), {
|
||||||
|
...options,
|
||||||
|
method: "GET",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getExportRolePermissionAuditCsvQueryKey = (
|
||||||
|
id: number,
|
||||||
|
params?: ExportRolePermissionAuditCsvParams,
|
||||||
|
) => {
|
||||||
|
return [`/api/roles/${id}/audit.csv`, ...(params ? [params] : [])] as const;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getExportRolePermissionAuditCsvQueryOptions = <
|
||||||
|
TData = Awaited<ReturnType<typeof exportRolePermissionAuditCsv>>,
|
||||||
|
TError = ErrorType<ErrorResponse>,
|
||||||
|
>(
|
||||||
|
id: number,
|
||||||
|
params?: ExportRolePermissionAuditCsvParams,
|
||||||
|
options?: {
|
||||||
|
query?: UseQueryOptions<
|
||||||
|
Awaited<ReturnType<typeof exportRolePermissionAuditCsv>>,
|
||||||
|
TError,
|
||||||
|
TData
|
||||||
|
>;
|
||||||
|
request?: SecondParameter<typeof customFetch>;
|
||||||
|
},
|
||||||
|
) => {
|
||||||
|
const { query: queryOptions, request: requestOptions } = options ?? {};
|
||||||
|
|
||||||
|
const queryKey =
|
||||||
|
queryOptions?.queryKey ??
|
||||||
|
getExportRolePermissionAuditCsvQueryKey(id, params);
|
||||||
|
|
||||||
|
const queryFn: QueryFunction<
|
||||||
|
Awaited<ReturnType<typeof exportRolePermissionAuditCsv>>
|
||||||
|
> = ({ signal }) =>
|
||||||
|
exportRolePermissionAuditCsv(id, params, { signal, ...requestOptions });
|
||||||
|
|
||||||
|
return {
|
||||||
|
queryKey,
|
||||||
|
queryFn,
|
||||||
|
enabled: !!id,
|
||||||
|
...queryOptions,
|
||||||
|
} as UseQueryOptions<
|
||||||
|
Awaited<ReturnType<typeof exportRolePermissionAuditCsv>>,
|
||||||
|
TError,
|
||||||
|
TData
|
||||||
|
> & { queryKey: QueryKey };
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ExportRolePermissionAuditCsvQueryResult = NonNullable<
|
||||||
|
Awaited<ReturnType<typeof exportRolePermissionAuditCsv>>
|
||||||
|
>;
|
||||||
|
export type ExportRolePermissionAuditCsvQueryError = ErrorType<ErrorResponse>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @summary Download permission-change audit entries for a role as CSV (admin)
|
||||||
|
*/
|
||||||
|
|
||||||
|
export function useExportRolePermissionAuditCsv<
|
||||||
|
TData = Awaited<ReturnType<typeof exportRolePermissionAuditCsv>>,
|
||||||
|
TError = ErrorType<ErrorResponse>,
|
||||||
|
>(
|
||||||
|
id: number,
|
||||||
|
params?: ExportRolePermissionAuditCsvParams,
|
||||||
|
options?: {
|
||||||
|
query?: UseQueryOptions<
|
||||||
|
Awaited<ReturnType<typeof exportRolePermissionAuditCsv>>,
|
||||||
|
TError,
|
||||||
|
TData
|
||||||
|
>;
|
||||||
|
request?: SecondParameter<typeof customFetch>;
|
||||||
|
},
|
||||||
|
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
|
||||||
|
const queryOptions = getExportRolePermissionAuditCsvQueryOptions(
|
||||||
|
id,
|
||||||
|
params,
|
||||||
|
options,
|
||||||
|
);
|
||||||
|
|
||||||
|
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
|
||||||
|
queryKey: QueryKey;
|
||||||
|
};
|
||||||
|
|
||||||
|
return { ...query, queryKey: queryOptions.queryKey };
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Given a candidate set of permission IDs for the role, returns the list of
|
* Given a candidate set of permission IDs for the role, returns the list of
|
||||||
permissions that would be removed and, for each one, how many users would
|
permissions that would be removed and, for each one, how many users would
|
||||||
|
|||||||
@@ -1666,6 +1666,70 @@ paths:
|
|||||||
schema:
|
schema:
|
||||||
$ref: "#/components/schemas/ErrorResponse"
|
$ref: "#/components/schemas/ErrorResponse"
|
||||||
|
|
||||||
|
/roles/{id}/audit.csv:
|
||||||
|
get:
|
||||||
|
operationId: exportRolePermissionAuditCsv
|
||||||
|
tags: [roles]
|
||||||
|
summary: Download permission-change audit entries for a role as CSV (admin)
|
||||||
|
description: |
|
||||||
|
Returns the same audit rows as `GET /roles/{id}/audit` but as a
|
||||||
|
downloadable CSV file with permission IDs resolved to names.
|
||||||
|
Honors the optional `actorUserId`, `from`, and `to` filters
|
||||||
|
identically. Pagination parameters are NOT accepted: the file
|
||||||
|
contains all matching rows up to a server-side cap (currently
|
||||||
|
10,000 — narrow the date range for a longer history).
|
||||||
|
parameters:
|
||||||
|
- name: id
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
- 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: |
|
||||||
|
UTF-8 CSV (with BOM) containing columns: timestamp,
|
||||||
|
actor_username, added_permissions, removed_permissions.
|
||||||
|
content:
|
||||||
|
text/csv:
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
format: binary
|
||||||
|
"400":
|
||||||
|
description: Invalid filter parameters
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/ErrorResponse"
|
||||||
|
"404":
|
||||||
|
description: Role not found
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/ErrorResponse"
|
||||||
|
|
||||||
/roles/{id}/permissions/impact-preview:
|
/roles/{id}/permissions/impact-preview:
|
||||||
post:
|
post:
|
||||||
operationId: previewRolePermissionsImpact
|
operationId: previewRolePermissionsImpact
|
||||||
|
|||||||
@@ -2114,6 +2114,37 @@ export const GetRolePermissionAuditResponse = zod.object({
|
|||||||
nextOffset: zod.number().nullable(),
|
nextOffset: zod.number().nullable(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the same audit rows as `GET /roles/{id}/audit` but as a
|
||||||
|
downloadable CSV file with permission IDs resolved to names.
|
||||||
|
Honors the optional `actorUserId`, `from`, and `to` filters
|
||||||
|
identically. Pagination parameters are NOT accepted: the file
|
||||||
|
contains all matching rows up to a server-side cap (currently
|
||||||
|
10,000 — narrow the date range for a longer history).
|
||||||
|
|
||||||
|
* @summary Download permission-change audit entries for a role as CSV (admin)
|
||||||
|
*/
|
||||||
|
export const ExportRolePermissionAuditCsvParams = zod.object({
|
||||||
|
id: zod.coerce.number(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const exportRolePermissionAuditCsvQueryActorUserIdMin = 0;
|
||||||
|
|
||||||
|
export const ExportRolePermissionAuditCsvQueryParams = zod.object({
|
||||||
|
actorUserId: zod.coerce
|
||||||
|
.number()
|
||||||
|
.min(exportRolePermissionAuditCsvQueryActorUserIdMin)
|
||||||
|
.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)."),
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Given a candidate set of permission IDs for the role, returns the list of
|
* Given a candidate set of permission IDs for the role, returns the list of
|
||||||
permissions that would be removed and, for each one, how many users would
|
permissions that would be removed and, for each one, how many users would
|
||||||
|
|||||||
Reference in New Issue
Block a user