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:
riyadhafraa
2026-05-01 16:11:43 +00:00
parent de7973f35b
commit 49bf44f0a2
9 changed files with 701 additions and 16 deletions
+148
View File
@@ -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);
});
+2
View File
@@ -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": "أي شخص",
+2
View File
@@ -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",
+108 -16
View File
@@ -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,22 +5230,42 @@ 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">
<Button {hasMore && (
type="button" <Button
size="sm" type="button"
variant="outline" size="sm"
disabled={isLoadingMore || isFetching} variant="outline"
onClick={loadMore} disabled={isLoadingMore || isFetching}
data-testid="role-history-load-more" onClick={loadMore}
> data-testid="role-history-load-more"
{isLoadingMore || isFetching ? ( >
<Loader2 size={14} className="animate-spin" /> {isLoadingMore || isFetching ? (
) : ( <Loader2 size={14} className="animate-spin" />
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;
}; };
+123
View File
@@ -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
+64
View File
@@ -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
+31
View File
@@ -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