diff --git a/artifacts/api-server/src/routes/audit.ts b/artifacts/api-server/src/routes/audit.ts index db14886d..39c4fd2e 100644 --- a/artifacts/api-server/src/routes/audit.ts +++ b/artifacts/api-server/src/routes/audit.ts @@ -1,4 +1,4 @@ -import { Router, type IRouter } from "express"; +import { Router, type IRouter, type Request, type Response } from "express"; import { and, desc, eq, gte, lt, sql, type SQL } from "drizzle-orm"; import { db } from "@workspace/db"; import { auditLogsTable, usersTable } from "@workspace/db"; @@ -7,6 +7,7 @@ import { requireAdmin } from "../middlewares/auth"; const router: IRouter = Router(); const DATE_RE = /^\d{4}-\d{2}-\d{2}$/; +const EXPORT_ROW_CAP = 50_000; function parseUtcDate(s: string): Date | null { if (!DATE_RE.test(s)) return null; @@ -14,7 +15,13 @@ function parseUtcDate(s: string): Date | null { return Number.isNaN(d.getTime()) ? null : d; } -router.get("/admin/audit-logs", requireAdmin, async (req, res): Promise => { +type AuditFilters = { + action: string; + fromDate: Date | null; + toDateExclusive: Date | null; +}; + +function parseFilters(req: Request, res: Response): AuditFilters | null { const action = typeof req.query.action === "string" ? req.query.action.trim() : ""; const fromStr = typeof req.query.from === "string" ? req.query.from.trim() : ""; const toStr = typeof req.query.to === "string" ? req.query.to.trim() : ""; @@ -25,21 +32,49 @@ router.get("/admin/audit-logs", requireAdmin, async (req, res): Promise => fromDate = parseUtcDate(fromStr); if (!fromDate) { res.status(400).json({ error: "Invalid 'from' date (expected YYYY-MM-DD)" }); - return; + return null; } } if (toStr) { const toDate = parseUtcDate(toStr); if (!toDate) { res.status(400).json({ error: "Invalid 'to' date (expected YYYY-MM-DD)" }); - return; + return null; } toDateExclusive = new Date(toDate.getTime() + 24 * 60 * 60 * 1000); } if (fromDate && toDateExclusive && fromDate >= toDateExclusive) { res.status(400).json({ error: "'from' must be on or before 'to'" }); - return; + return null; } + return { action, fromDate, toDateExclusive }; +} + +function buildWhere(filters: AuditFilters): SQL | undefined { + const conditions: SQL[] = []; + if (filters.action) conditions.push(eq(auditLogsTable.action, filters.action)); + if (filters.fromDate) conditions.push(gte(auditLogsTable.createdAt, filters.fromDate)); + if (filters.toDateExclusive) + conditions.push(lt(auditLogsTable.createdAt, filters.toDateExclusive)); + 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 => { + const filters = parseFilters(req, res); + if (!filters) return; const limitRaw = Number(req.query.limit); const offsetRaw = Number(req.query.offset); @@ -50,11 +85,7 @@ router.get("/admin/audit-logs", requireAdmin, async (req, res): Promise => const offset = Number.isFinite(offsetRaw) && offsetRaw >= 0 ? Math.floor(offsetRaw) : 0; - const conditions: SQL[] = []; - if (action) conditions.push(eq(auditLogsTable.action, action)); - if (fromDate) conditions.push(gte(auditLogsTable.createdAt, fromDate)); - if (toDateExclusive) conditions.push(lt(auditLogsTable.createdAt, toDateExclusive)); - const whereClause = conditions.length > 0 ? and(...conditions) : undefined; + const whereClause = buildWhere(filters); const baseQuery = db .select({ @@ -121,4 +152,66 @@ router.get("/admin/audit-logs", requireAdmin, async (req, res): Promise => }); }); +router.get( + "/admin/audit-logs/export", + requireAdmin, + async (req, res): Promise => { + const filters = parseFilters(req, res); + if (!filters) return; + + const whereClause = buildWhere(filters); + + const baseQuery = db + .select({ + action: auditLogsTable.action, + targetType: auditLogsTable.targetType, + targetId: auditLogsTable.targetId, + metadata: auditLogsTable.metadata, + createdAt: auditLogsTable.createdAt, + actorUsername: usersTable.username, + }) + .from(auditLogsTable) + .leftJoin(usersTable, eq(usersTable.id, auditLogsTable.actorUserId)); + + const rows = await (whereClause ? baseQuery.where(whereClause) : baseQuery) + .orderBy(desc(auditLogsTable.createdAt), desc(auditLogsTable.id)) + .limit(EXPORT_ROW_CAP); + + const stamp = new Date().toISOString().slice(0, 10); + const filename = `audit-log-${stamp}.csv`; + res.setHeader("Content-Type", "text/csv; charset=utf-8"); + res.setHeader( + "Content-Disposition", + `attachment; filename="${filename}"`, + ); + res.setHeader("Cache-Control", "no-store"); + + // UTF-8 BOM so spreadsheet apps (Excel) detect encoding correctly, + // important for Arabic content stored in metadata. + res.write("\uFEFF"); + res.write("action,actor_username,target_type,target_id,created_at,metadata\r\n"); + + for (const r of rows) { + const createdAtIso = + r.createdAt instanceof Date + ? r.createdAt.toISOString() + : new Date(r.createdAt as unknown as string).toISOString(); + const metadataJson = + r.metadata == null ? "" : JSON.stringify(r.metadata); + const line = [ + csvEscape(r.action), + csvEscape(r.actorUsername), + csvEscape(r.targetType), + csvEscape(r.targetId), + csvEscape(createdAtIso), + csvEscape(metadataJson), + ].join(","); + res.write(line); + res.write("\r\n"); + } + + res.end(); + }, +); + export default router; diff --git a/artifacts/tx-os/public/opengraph.jpg b/artifacts/tx-os/public/opengraph.jpg index c353446e..03e81247 100644 Binary files a/artifacts/tx-os/public/opengraph.jpg and b/artifacts/tx-os/public/opengraph.jpg differ diff --git a/artifacts/tx-os/src/locales/ar.json b/artifacts/tx-os/src/locales/ar.json index 3410f2d1..9644e0a5 100644 --- a/artifacts/tx-os/src/locales/ar.json +++ b/artifacts/tx-os/src/locales/ar.json @@ -489,6 +489,10 @@ "showing": "عرض {{shown}} من أصل {{total}}", "empty": "لا توجد إدخالات في سجل التدقيق تطابق الفلاتر.", "loadMore": "عرض المزيد", + "export": { + "button": "تصدير CSV", + "failed": "تعذّر تصدير سجل التدقيق. يُرجى المحاولة مرة أخرى." + }, "errors": { "invalidDate": "يجب أن تكون التواريخ بصيغة YYYY-MM-DD.", "invertedDates": "تاريخ \"من\" يجب أن يكون قبل أو يساوي تاريخ \"إلى\".", diff --git a/artifacts/tx-os/src/locales/en.json b/artifacts/tx-os/src/locales/en.json index ba661044..18c8cef8 100644 --- a/artifacts/tx-os/src/locales/en.json +++ b/artifacts/tx-os/src/locales/en.json @@ -486,6 +486,10 @@ "showing": "Showing {{shown}} of {{total}}", "empty": "No audit log entries match these filters.", "loadMore": "Load more", + "export": { + "button": "Export CSV", + "failed": "Could not export the audit log. Please try again." + }, "errors": { "invalidDate": "Dates must be in YYYY-MM-DD format.", "invertedDates": "\"From\" date must be on or before \"To\" date.", diff --git a/artifacts/tx-os/src/pages/admin.tsx b/artifacts/tx-os/src/pages/admin.tsx index 7df48775..ee8040b2 100644 --- a/artifacts/tx-os/src/pages/admin.tsx +++ b/artifacts/tx-os/src/pages/admin.tsx @@ -48,6 +48,7 @@ import { useReplaceRolePermissions, useListAuditLogs, getListAuditLogsQueryKey, + getExportAuditLogsCsvUrl, ApiError, } from "@workspace/api-client-react"; import type { @@ -74,7 +75,7 @@ import { useQueryClient, useQuery } from "@tanstack/react-query"; import { useAuth } from "@/contexts/AuthContext"; import { useUpload, type UploadResponse } from "@workspace/object-storage-web"; import { resolveServiceImageUrl } from "@/lib/image-url"; -import { ArrowRight, ArrowLeft, Settings, Plus, Pencil, Trash2, Shield, Upload, Loader2, LayoutDashboard, Grid2X2, Coffee, Users as UsersIcon, Menu as MenuIcon, TrendingUp, TrendingDown, UserPlus, Activity, KeyRound, Copy, ChevronDown, ScrollText } from "lucide-react"; +import { ArrowRight, ArrowLeft, Settings, Plus, Pencil, Trash2, Shield, Upload, Loader2, LayoutDashboard, Grid2X2, Coffee, Users as UsersIcon, Menu as MenuIcon, TrendingUp, TrendingDown, UserPlus, Activity, KeyRound, Copy, ChevronDown, ScrollText, Download } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; @@ -4001,6 +4002,8 @@ function AuditLogPanel() { const [appliedFrom, setAppliedFrom] = useState(""); const [appliedTo, setAppliedTo] = useState(""); const [pageLimit, setPageLimit] = useState(50); + const [exporting, setExporting] = useState(false); + const [exportError, setExportError] = useState(null); const fromValid = !appliedFrom || DATE_RE.test(appliedFrom); const toValid = !appliedTo || DATE_RE.test(appliedTo); @@ -4048,6 +4051,45 @@ function AuditLogPanel() { const loadMore = () => setPageLimit((n) => Math.min(n + 50, 200)); + const handleExportCsv = async () => { + if (!filtersValid || exporting) return; + setExportError(null); + setExporting(true); + try { + const url = getExportAuditLogsCsvUrl({ + ...(actionFilter ? { action: actionFilter } : {}), + ...(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(); + const stamp = new Date().toISOString().slice(0, 10); + const filename = `audit-log-${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 (err) { + setExportError( + err instanceof Error ? err.message : t("admin.audit.export.failed"), + ); + } finally { + setExporting(false); + } + }; + return (
@@ -4107,11 +4149,33 @@ function AuditLogPanel() {
+
+ +
{inputErrors.map((msg, i) => (

{msg}

))} + {exportError && ( +

+ {t("admin.audit.export.failed")} +

+ )}
{filtersValid ? t("admin.audit.showing", { diff --git a/lib/api-client-react/src/generated/api.schemas.ts b/lib/api-client-react/src/generated/api.schemas.ts index d8544a22..00112103 100644 --- a/lib/api-client-react/src/generated/api.schemas.ts +++ b/lib/api-client-react/src/generated/api.schemas.ts @@ -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; +}; diff --git a/lib/api-client-react/src/generated/api.ts b/lib/api-client-react/src/generated/api.ts index 7c1e374b..3fcd554f 100644 --- a/lib/api-client-react/src/generated/api.ts +++ b/lib/api-client-react/src/generated/api.ts @@ -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 => { + return customFetch(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>, + TError = ErrorType, +>( + params?: ExportAuditLogsCsvParams, + options?: { + query?: UseQueryOptions< + Awaited>, + TError, + TData + >; + request?: SecondParameter; + }, +) => { + const { query: queryOptions, request: requestOptions } = options ?? {}; + + const queryKey = + queryOptions?.queryKey ?? getExportAuditLogsCsvQueryKey(params); + + const queryFn: QueryFunction< + Awaited> + > = ({ signal }) => exportAuditLogsCsv(params, { signal, ...requestOptions }); + + return { queryKey, queryFn, ...queryOptions } as UseQueryOptions< + Awaited>, + TError, + TData + > & { queryKey: QueryKey }; +}; + +export type ExportAuditLogsCsvQueryResult = NonNullable< + Awaited> +>; +export type ExportAuditLogsCsvQueryError = ErrorType; + +/** + * @summary Export filtered audit log as CSV (admin) + */ + +export function useExportAuditLogsCsv< + TData = Awaited>, + TError = ErrorType, +>( + params?: ExportAuditLogsCsvParams, + options?: { + query?: UseQueryOptions< + Awaited>, + TError, + TData + >; + request?: SecondParameter; + }, +): UseQueryResult & { queryKey: QueryKey } { + const queryOptions = getExportAuditLogsCsvQueryOptions(params, options); + + const query = useQuery(queryOptions) as UseQueryResult & { + queryKey: QueryKey; + }; + + return { ...query, queryKey: queryOptions.queryKey }; +} diff --git a/lib/api-spec/openapi.yaml b/lib/api-spec/openapi.yaml index ded457f3..fe0334d7 100644 --- a/lib/api-spec/openapi.yaml +++ b/lib/api-spec/openapi.yaml @@ -1854,6 +1854,52 @@ paths: schema: $ref: "#/components/schemas/ErrorResponse" + /admin/audit-logs/export: + get: + operationId: exportAuditLogsCsv + tags: [audit] + summary: Export filtered audit log as CSV (admin) + description: | + 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. + parameters: + - in: query + name: action + required: false + schema: + type: string + description: Exact action name to filter by (e.g. "group.force_delete"). + - 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: CSV file download + content: + text/csv: + schema: + type: string + format: binary + "400": + description: Invalid filter parameters + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + components: schemas: HealthStatus: diff --git a/lib/api-zod/src/generated/api.ts b/lib/api-zod/src/generated/api.ts index 350d7bbe..ff3b0fc8 100644 --- a/lib/api-zod/src/generated/api.ts +++ b/lib/api-zod/src/generated/api.ts @@ -2098,3 +2098,23 @@ export const ListAuditLogsResponse = zod.object({ "All distinct action names present in the audit log (for building filter UI).", ), }); + +/** + * 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 ExportAuditLogsCsvQueryParams = zod.object({ + action: zod.coerce + .string() + .optional() + .describe('Exact action name to filter by (e.g. \"group.force_delete\").'), + from: zod + .date() + .optional() + .describe("Start date (inclusive, YYYY-MM-DD UTC)."), + to: zod.date().optional().describe("End date (inclusive, YYYY-MM-DD UTC)."), +});