Task #95: Let admins export the audit log as CSV

What was done
- Added a new admin-only endpoint GET /api/admin/audit-logs/export that streams
  the filtered audit log as a CSV download. Honors the same `action`, `from`,
  and `to` query parameters as the list endpoint, validated through a shared
  parseFilters/buildWhere helper extracted from the existing handler.
- Columns: action, actor_username, target_type, target_id, created_at,
  metadata (compact JSON). Rows ordered newest-first and capped at 50,000 to
  bound response size. UTF-8 BOM prepended so spreadsheet apps (incl. Excel)
  detect encoding correctly for Arabic content stored in metadata.
- Response sets Content-Type: text/csv; charset=utf-8,
  Content-Disposition: attachment; filename="audit-log-YYYY-MM-DD.csv",
  Cache-Control: no-store, and is written via res.write streaming.
- Updated lib/api-spec/openapi.yaml with operationId exportAuditLogsCsv and
  ran codegen to regenerate the React client + Zod schemas.
- Frontend (artifacts/tx-os/src/pages/admin.tsx → AuditLogPanel): added an
  "Export CSV" button next to the existing filter actions. Clicking it
  fetches the export URL with current filters using same-origin cookies,
  triggers a browser download, and surfaces a localized error if the request
  fails. The button is disabled while filters are invalid or while a
  download is in flight, and a spinner replaces the icon during export.
- Added bilingual translation keys admin.audit.export.button /
  admin.audit.export.failed in en.json and ar.json.

Security hardening (post code-review)
- csvEscape now prefixes a single quote when a cell value begins with one of
  =, +, -, @, tab, or CR, mitigating CSV/spreadsheet formula injection
  (Excel, LibreOffice, Google Sheets evaluate such cells as formulas).
  Verified end-to-end by inserting a synthetic audit row whose action started
  with "=" and confirming the export wrote it as "'=...".

Verification
- Typecheck of tx-os passes; api-server has only pre-existing executive-meetings
  errors unrelated to this change.
- Manual curl smoke test: 200 with correct headers, BOM present, action and
  date filters honored, invalid date returns 400.
- E2e UI test (Playwright via testing skill): logged in as admin, opened
  the Audit Log section, downloaded the unfiltered CSV (correct filename and
  header row), then re-exported with action=app.delete and confirmed only
  matching rows came back.
- Targeted security check confirmed CSV injection mitigation (see above).

Notes / drift
- None for the spec. The generated `exportAuditLogsCsv()` helper in
  lib/api-client-react is typed Promise<Blob> but, because customFetch
  auto-infers text/csv as text, would currently return text if anyone called
  it directly. The Audit Log UI uses a raw fetch with credentials: "include"
  to download the blob, so this does not affect the feature. Updating the
  generated client's responseType handling is project-wide config and is
  intentionally out of scope here.

Follow-ups proposed
- #123 Add automated tests for the audit log CSV export (test_gaps)
- #124 Let admins pick which columns to include when exporting the audit log
  (next_steps)

Replit-Task-Id: 88a50100-caa7-4b37-b9da-cfdc42bee119
This commit is contained in:
riyadhafraa
2026-04-29 06:56:09 +00:00
parent 30ea1facd3
commit be4ecb30bb
9 changed files with 360 additions and 11 deletions
@@ -999,3 +999,18 @@ export type ListAuditLogsParams = {
*/
offset?: number;
};
export type ExportAuditLogsCsvParams = {
/**
* Exact action name to filter by (e.g. "group.force_delete").
*/
action?: string;
/**
* Start date (inclusive, YYYY-MM-DD UTC).
*/
from?: string;
/**
* End date (inclusive, YYYY-MM-DD UTC).
*/
to?: string;
};
+103
View File
@@ -41,6 +41,7 @@ import type {
DeleteServiceParams,
DeleteUserParams,
ErrorResponse,
ExportAuditLogsCsvParams,
ForgotPasswordBody,
ForgotPasswordResponse,
GetAdminAppOpensByAppParams,
@@ -6261,3 +6262,105 @@ export function useListAuditLogs<
return { ...query, queryKey: queryOptions.queryKey };
}
/**
* Streams the filtered audit log as a CSV download.
Honors the same `action`, `from`, and `to` filters as the list endpoint.
Columns: action, actor_username, target_type, target_id, created_at, metadata.
Capped at 50,000 rows per export to bound response size.
* @summary Export filtered audit log as CSV (admin)
*/
export const getExportAuditLogsCsvUrl = (params?: ExportAuditLogsCsvParams) => {
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/admin/audit-logs/export?${stringifiedParams}`
: `/api/admin/audit-logs/export`;
};
export const exportAuditLogsCsv = async (
params?: ExportAuditLogsCsvParams,
options?: RequestInit,
): Promise<Blob> => {
return customFetch<Blob>(getExportAuditLogsCsvUrl(params), {
...options,
method: "GET",
});
};
export const getExportAuditLogsCsvQueryKey = (
params?: ExportAuditLogsCsvParams,
) => {
return [`/api/admin/audit-logs/export`, ...(params ? [params] : [])] as const;
};
export const getExportAuditLogsCsvQueryOptions = <
TData = Awaited<ReturnType<typeof exportAuditLogsCsv>>,
TError = ErrorType<ErrorResponse>,
>(
params?: ExportAuditLogsCsvParams,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof exportAuditLogsCsv>>,
TError,
TData
>;
request?: SecondParameter<typeof customFetch>;
},
) => {
const { query: queryOptions, request: requestOptions } = options ?? {};
const queryKey =
queryOptions?.queryKey ?? getExportAuditLogsCsvQueryKey(params);
const queryFn: QueryFunction<
Awaited<ReturnType<typeof exportAuditLogsCsv>>
> = ({ signal }) => exportAuditLogsCsv(params, { signal, ...requestOptions });
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
Awaited<ReturnType<typeof exportAuditLogsCsv>>,
TError,
TData
> & { queryKey: QueryKey };
};
export type ExportAuditLogsCsvQueryResult = NonNullable<
Awaited<ReturnType<typeof exportAuditLogsCsv>>
>;
export type ExportAuditLogsCsvQueryError = ErrorType<ErrorResponse>;
/**
* @summary Export filtered audit log as CSV (admin)
*/
export function useExportAuditLogsCsv<
TData = Awaited<ReturnType<typeof exportAuditLogsCsv>>,
TError = ErrorType<ErrorResponse>,
>(
params?: ExportAuditLogsCsvParams,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof exportAuditLogsCsv>>,
TError,
TData
>;
request?: SecondParameter<typeof customFetch>;
},
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getExportAuditLogsCsvQueryOptions(params, options);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
queryKey: QueryKey;
};
return { ...query, queryKey: queryOptions.queryKey };
}