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:
@@ -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 { and, desc, eq, gte, lt, sql, type SQL } from "drizzle-orm";
|
||||||
import { db } from "@workspace/db";
|
import { db } from "@workspace/db";
|
||||||
import { auditLogsTable, usersTable } from "@workspace/db";
|
import { auditLogsTable, usersTable } from "@workspace/db";
|
||||||
@@ -7,6 +7,7 @@ import { requireAdmin } from "../middlewares/auth";
|
|||||||
const router: IRouter = Router();
|
const router: IRouter = Router();
|
||||||
|
|
||||||
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
|
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
|
||||||
|
const EXPORT_ROW_CAP = 50_000;
|
||||||
|
|
||||||
function parseUtcDate(s: string): Date | null {
|
function parseUtcDate(s: string): Date | null {
|
||||||
if (!DATE_RE.test(s)) return 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;
|
return Number.isNaN(d.getTime()) ? null : d;
|
||||||
}
|
}
|
||||||
|
|
||||||
router.get("/admin/audit-logs", requireAdmin, async (req, res): Promise<void> => {
|
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 action = typeof req.query.action === "string" ? req.query.action.trim() : "";
|
||||||
const fromStr = typeof req.query.from === "string" ? req.query.from.trim() : "";
|
const fromStr = typeof req.query.from === "string" ? req.query.from.trim() : "";
|
||||||
const toStr = typeof req.query.to === "string" ? req.query.to.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<void> =>
|
|||||||
fromDate = parseUtcDate(fromStr);
|
fromDate = parseUtcDate(fromStr);
|
||||||
if (!fromDate) {
|
if (!fromDate) {
|
||||||
res.status(400).json({ error: "Invalid 'from' date (expected YYYY-MM-DD)" });
|
res.status(400).json({ error: "Invalid 'from' date (expected YYYY-MM-DD)" });
|
||||||
return;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (toStr) {
|
if (toStr) {
|
||||||
const toDate = parseUtcDate(toStr);
|
const toDate = parseUtcDate(toStr);
|
||||||
if (!toDate) {
|
if (!toDate) {
|
||||||
res.status(400).json({ error: "Invalid 'to' date (expected YYYY-MM-DD)" });
|
res.status(400).json({ error: "Invalid 'to' date (expected YYYY-MM-DD)" });
|
||||||
return;
|
return null;
|
||||||
}
|
}
|
||||||
toDateExclusive = new Date(toDate.getTime() + 24 * 60 * 60 * 1000);
|
toDateExclusive = new Date(toDate.getTime() + 24 * 60 * 60 * 1000);
|
||||||
}
|
}
|
||||||
if (fromDate && toDateExclusive && fromDate >= toDateExclusive) {
|
if (fromDate && toDateExclusive && fromDate >= toDateExclusive) {
|
||||||
res.status(400).json({ error: "'from' must be on or before 'to'" });
|
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<void> => {
|
||||||
|
const filters = parseFilters(req, res);
|
||||||
|
if (!filters) return;
|
||||||
|
|
||||||
const limitRaw = Number(req.query.limit);
|
const limitRaw = Number(req.query.limit);
|
||||||
const offsetRaw = Number(req.query.offset);
|
const offsetRaw = Number(req.query.offset);
|
||||||
@@ -50,11 +85,7 @@ router.get("/admin/audit-logs", requireAdmin, async (req, res): Promise<void> =>
|
|||||||
const offset =
|
const offset =
|
||||||
Number.isFinite(offsetRaw) && offsetRaw >= 0 ? Math.floor(offsetRaw) : 0;
|
Number.isFinite(offsetRaw) && offsetRaw >= 0 ? Math.floor(offsetRaw) : 0;
|
||||||
|
|
||||||
const conditions: SQL[] = [];
|
const whereClause = buildWhere(filters);
|
||||||
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 baseQuery = db
|
const baseQuery = db
|
||||||
.select({
|
.select({
|
||||||
@@ -121,4 +152,66 @@ router.get("/admin/audit-logs", requireAdmin, async (req, res): Promise<void> =>
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
"/admin/audit-logs/export",
|
||||||
|
requireAdmin,
|
||||||
|
async (req, res): Promise<void> => {
|
||||||
|
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;
|
export default router;
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 24 KiB |
@@ -489,6 +489,10 @@
|
|||||||
"showing": "عرض {{shown}} من أصل {{total}}",
|
"showing": "عرض {{shown}} من أصل {{total}}",
|
||||||
"empty": "لا توجد إدخالات في سجل التدقيق تطابق الفلاتر.",
|
"empty": "لا توجد إدخالات في سجل التدقيق تطابق الفلاتر.",
|
||||||
"loadMore": "عرض المزيد",
|
"loadMore": "عرض المزيد",
|
||||||
|
"export": {
|
||||||
|
"button": "تصدير CSV",
|
||||||
|
"failed": "تعذّر تصدير سجل التدقيق. يُرجى المحاولة مرة أخرى."
|
||||||
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
"invalidDate": "يجب أن تكون التواريخ بصيغة YYYY-MM-DD.",
|
"invalidDate": "يجب أن تكون التواريخ بصيغة YYYY-MM-DD.",
|
||||||
"invertedDates": "تاريخ \"من\" يجب أن يكون قبل أو يساوي تاريخ \"إلى\".",
|
"invertedDates": "تاريخ \"من\" يجب أن يكون قبل أو يساوي تاريخ \"إلى\".",
|
||||||
|
|||||||
@@ -486,6 +486,10 @@
|
|||||||
"showing": "Showing {{shown}} of {{total}}",
|
"showing": "Showing {{shown}} of {{total}}",
|
||||||
"empty": "No audit log entries match these filters.",
|
"empty": "No audit log entries match these filters.",
|
||||||
"loadMore": "Load more",
|
"loadMore": "Load more",
|
||||||
|
"export": {
|
||||||
|
"button": "Export CSV",
|
||||||
|
"failed": "Could not export the audit log. Please try again."
|
||||||
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
"invalidDate": "Dates must be in YYYY-MM-DD format.",
|
"invalidDate": "Dates must be in YYYY-MM-DD format.",
|
||||||
"invertedDates": "\"From\" date must be on or before \"To\" date.",
|
"invertedDates": "\"From\" date must be on or before \"To\" date.",
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ import {
|
|||||||
useReplaceRolePermissions,
|
useReplaceRolePermissions,
|
||||||
useListAuditLogs,
|
useListAuditLogs,
|
||||||
getListAuditLogsQueryKey,
|
getListAuditLogsQueryKey,
|
||||||
|
getExportAuditLogsCsvUrl,
|
||||||
ApiError,
|
ApiError,
|
||||||
} from "@workspace/api-client-react";
|
} from "@workspace/api-client-react";
|
||||||
import type {
|
import type {
|
||||||
@@ -74,7 +75,7 @@ import { useQueryClient, useQuery } from "@tanstack/react-query";
|
|||||||
import { useAuth } from "@/contexts/AuthContext";
|
import { useAuth } from "@/contexts/AuthContext";
|
||||||
import { useUpload, type UploadResponse } from "@workspace/object-storage-web";
|
import { useUpload, type UploadResponse } from "@workspace/object-storage-web";
|
||||||
import { resolveServiceImageUrl } from "@/lib/image-url";
|
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 { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
@@ -4001,6 +4002,8 @@ function AuditLogPanel() {
|
|||||||
const [appliedFrom, setAppliedFrom] = useState<string>("");
|
const [appliedFrom, setAppliedFrom] = useState<string>("");
|
||||||
const [appliedTo, setAppliedTo] = useState<string>("");
|
const [appliedTo, setAppliedTo] = useState<string>("");
|
||||||
const [pageLimit, setPageLimit] = useState<number>(50);
|
const [pageLimit, setPageLimit] = useState<number>(50);
|
||||||
|
const [exporting, setExporting] = useState<boolean>(false);
|
||||||
|
const [exportError, setExportError] = useState<string | null>(null);
|
||||||
|
|
||||||
const fromValid = !appliedFrom || DATE_RE.test(appliedFrom);
|
const fromValid = !appliedFrom || DATE_RE.test(appliedFrom);
|
||||||
const toValid = !appliedTo || DATE_RE.test(appliedTo);
|
const toValid = !appliedTo || DATE_RE.test(appliedTo);
|
||||||
@@ -4048,6 +4051,45 @@ function AuditLogPanel() {
|
|||||||
|
|
||||||
const loadMore = () => setPageLimit((n) => Math.min(n + 50, 200));
|
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 (
|
return (
|
||||||
<div className="space-y-3" data-testid="audit-log-panel">
|
<div className="space-y-3" data-testid="audit-log-panel">
|
||||||
<div className="glass-panel rounded-2xl p-3 space-y-3">
|
<div className="glass-panel rounded-2xl p-3 space-y-3">
|
||||||
@@ -4107,11 +4149,33 @@ function AuditLogPanel() {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex items-center justify-end">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={handleExportCsv}
|
||||||
|
disabled={!filtersValid || exporting}
|
||||||
|
className="gap-1.5"
|
||||||
|
data-testid="audit-export-csv"
|
||||||
|
>
|
||||||
|
{exporting ? (
|
||||||
|
<Loader2 size={14} className="animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Download size={14} />
|
||||||
|
)}
|
||||||
|
{t("admin.audit.export.button")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
{inputErrors.map((msg, i) => (
|
{inputErrors.map((msg, i) => (
|
||||||
<p key={i} className="text-xs text-destructive">
|
<p key={i} className="text-xs text-destructive">
|
||||||
{msg}
|
{msg}
|
||||||
</p>
|
</p>
|
||||||
))}
|
))}
|
||||||
|
{exportError && (
|
||||||
|
<p className="text-xs text-destructive" data-testid="audit-export-error">
|
||||||
|
{t("admin.audit.export.failed")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
<div className="text-xs text-muted-foreground">
|
<div className="text-xs text-muted-foreground">
|
||||||
{filtersValid
|
{filtersValid
|
||||||
? t("admin.audit.showing", {
|
? t("admin.audit.showing", {
|
||||||
|
|||||||
@@ -999,3 +999,18 @@ export type ListAuditLogsParams = {
|
|||||||
*/
|
*/
|
||||||
offset?: number;
|
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,
|
DeleteServiceParams,
|
||||||
DeleteUserParams,
|
DeleteUserParams,
|
||||||
ErrorResponse,
|
ErrorResponse,
|
||||||
|
ExportAuditLogsCsvParams,
|
||||||
ForgotPasswordBody,
|
ForgotPasswordBody,
|
||||||
ForgotPasswordResponse,
|
ForgotPasswordResponse,
|
||||||
GetAdminAppOpensByAppParams,
|
GetAdminAppOpensByAppParams,
|
||||||
@@ -6261,3 +6262,105 @@ export function useListAuditLogs<
|
|||||||
|
|
||||||
return { ...query, queryKey: queryOptions.queryKey };
|
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 };
|
||||||
|
}
|
||||||
|
|||||||
@@ -1854,6 +1854,52 @@ paths:
|
|||||||
schema:
|
schema:
|
||||||
$ref: "#/components/schemas/ErrorResponse"
|
$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:
|
components:
|
||||||
schemas:
|
schemas:
|
||||||
HealthStatus:
|
HealthStatus:
|
||||||
|
|||||||
@@ -2098,3 +2098,23 @@ export const ListAuditLogsResponse = zod.object({
|
|||||||
"All distinct action names present in the audit log (for building filter UI).",
|
"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)."),
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user