Task #205: Add "Download CSV" export for role permission history
Backend
- New endpoint: GET /api/roles/:id/audit/export
- Reuses parseRoleAuditFilters/buildRoleAuditWhere so it honors actor
and from/to filters identically to the existing /audit list.
- Resolves added/removed permission ids -> names in a single query.
- Streams text/csv with a UTF-8 BOM (Excel compatibility), header
timestamp,actor_username,added_permissions,removed_permissions,
capped at 50,000 rows, filename role-{name}-history-{YYYY-MM-DD}.csv.
- Extracted csvEscape into artifacts/api-server/src/lib/csv.ts (with
formula-injection mitigation) and refactored audit.ts to import it.
- OpenAPI spec entry exportRolePermissionAuditCsv added; codegen run to
regenerate getExportRolePermissionAuditCsvUrl.
Frontend
- artifacts/tx-os/src/pages/admin.tsx RolePermissionHistory: new
Download button (testid role-history-export-csv) next to Load more,
with exporting / exportFailed state and a blob download flow that
respects the active actor/from/to filters.
- exportFailed is a boolean (per code review): the panel only shows a
localized generic message, so storing the raw error string would
have been dead state.
- en/ar locale strings: admin.roles.historyExport.button =
"Download CSV" / "تنزيل CSV", plus a failure message.
Tests
- 4 new server tests in role-permission-audit.test.mjs cover: 404 for
unknown role, CSV header + rows + UTF-8 BOM bytes, actor/date filter
honoring, and admin-only enforcement. All audit tests pass.
- e2e UI run via runTest verified: admin login, creating a role and
saving permissions twice, the History panel shows entries, the
Download CSV button downloads a valid CSV (correct header, BOM,
data rows), and the same flow works in the Arabic UI.
Other test failures observed in the workflow (executive-meetings,
app-permissions-impact) are pre-existing flakes unrelated to this
change and do not touch any modified files.
This commit is contained in:
@@ -0,0 +1,16 @@
|
||||
// Shared CSV helpers used by admin export endpoints. Centralizing the
|
||||
// escape logic keeps the formula-injection mitigation consistent across
|
||||
// the audit-log export, the role-permission-history export, and any
|
||||
// future CSV download we add.
|
||||
export function csvEscape(value: unknown): string {
|
||||
if (value === null || value === undefined) return "";
|
||||
let s = typeof value === "string" ? value : String(value);
|
||||
// Mitigate CSV/spreadsheet formula injection (Excel, LibreOffice, Google
|
||||
// Sheets evaluate cells starting with =, +, -, @, or a tab/CR as formulas).
|
||||
// Prefix a single quote so the spreadsheet renders the value as text.
|
||||
if (s.length > 0 && /^[=+\-@\t\r]/.test(s)) {
|
||||
s = `'${s}`;
|
||||
}
|
||||
if (/[",\r\n]/.test(s)) return `"${s.replace(/"/g, '""')}"`;
|
||||
return s;
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { and, desc, eq, gte, inArray, lt, or, sql, type SQL } from "drizzle-orm"
|
||||
import { db } from "@workspace/db";
|
||||
import { auditLogsTable, usersTable } from "@workspace/db";
|
||||
import { requireAdmin } from "../middlewares/auth";
|
||||
import { csvEscape } from "../lib/csv.js";
|
||||
|
||||
const router: IRouter = Router();
|
||||
|
||||
@@ -136,19 +137,6 @@ function buildWhere(filters: AuditFilters): SQL | undefined {
|
||||
return conditions.length > 0 ? and(...conditions) : undefined;
|
||||
}
|
||||
|
||||
function csvEscape(value: unknown): string {
|
||||
if (value === null || value === undefined) return "";
|
||||
let s = typeof value === "string" ? value : String(value);
|
||||
// Mitigate CSV/spreadsheet formula injection (Excel, LibreOffice, Google
|
||||
// Sheets evaluate cells starting with =, +, -, @, or a tab/CR as formulas).
|
||||
// Prefix a single quote so the spreadsheet renders the value as text.
|
||||
if (s.length > 0 && /^[=+\-@\t\r]/.test(s)) {
|
||||
s = `'${s}`;
|
||||
}
|
||||
if (/[",\r\n]/.test(s)) return `"${s.replace(/"/g, '""')}"`;
|
||||
return s;
|
||||
}
|
||||
|
||||
router.get("/admin/audit-logs", requireAdmin, async (req, res): Promise<void> => {
|
||||
const filters = parseFilters(req, res);
|
||||
if (!filters) return;
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
emitAppsChangedToRoleHolders,
|
||||
emitRolePermissionsChangedToHolders,
|
||||
} from "../lib/realtime.js";
|
||||
import { csvEscape } from "../lib/csv.js";
|
||||
|
||||
const router: IRouter = Router();
|
||||
|
||||
@@ -281,17 +282,6 @@ router.get("/roles/:id/audit", requireAdmin, async (req, res): Promise<void> =>
|
||||
// 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,
|
||||
|
||||
Reference in New Issue
Block a user