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:
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user