From bdc3b2425b52305e724959aa38b80dd3f346f892 Mon Sep 17 00:00:00 2001 From: Owen Date: Tue, 21 Oct 2025 17:35:13 -0700 Subject: [PATCH] Basic table working --- messages/en-US.json | 3 +- .../routers/auditLogs/exportActionAuditLog.ts | 102 ++++++++++++++++++ server/private/routers/auditLogs/index.ts | 3 +- .../routers/auditLogs/queryActionAuditLog.ts | 29 ++--- server/private/routers/external.ts | 6 ++ .../settings/logs/{access => action}/page.tsx | 37 +++++++ src/app/navigation.tsx | 2 +- src/components/DateTimePicker.tsx | 1 - src/components/LogDataTable.tsx | 56 ++++------ 9 files changed, 186 insertions(+), 53 deletions(-) create mode 100644 server/private/routers/auditLogs/exportActionAuditLog.ts rename src/app/[orgId]/settings/logs/{access => action}/page.tsx (84%) diff --git a/messages/en-US.json b/messages/en-US.json index 4a44ab33..e6790e0d 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1900,5 +1900,6 @@ "action": "Action", "actor": "Actor", "timestamp": "Timestamp", - "accessLogs": "Access Logs" + "accessLogs": "Access Logs", + "exportCsv": "Export CSV" } diff --git a/server/private/routers/auditLogs/exportActionAuditLog.ts b/server/private/routers/auditLogs/exportActionAuditLog.ts new file mode 100644 index 00000000..de6cf298 --- /dev/null +++ b/server/private/routers/auditLogs/exportActionAuditLog.ts @@ -0,0 +1,102 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +import { actionAuditLog, db } from "@server/db"; +import { registry } from "@server/openApi"; +import { NextFunction } from "express"; +import { Request, Response } from "express"; +import { eq, gt, lt, and, count } from "drizzle-orm"; +import { OpenAPITags } from "@server/openApi"; +import { z } from "zod"; +import createHttpError from "http-errors"; +import HttpCode from "@server/types/HttpCode"; +import { fromError } from "zod-validation-error"; +import { QueryActionAuditLogResponse } from "@server/routers/auditLogs/types"; +import response from "@server/lib/response"; +import logger from "@server/logger"; +import { queryAccessAuditLogsParams, queryAccessAuditLogsQuery, querySites } from "./queryActionAuditLog"; + +function generateCSV(data: any[]): string { + if (data.length === 0) { + return "orgId,action,actorType,timestamp,actor\n"; + } + + const headers = Object.keys(data[0]).join(","); + const rows = data.map(row => + Object.values(row).map(value => + typeof value === 'string' && value.includes(',') + ? `"${value.replace(/"/g, '""')}"` + : value + ).join(",") + ); + + return [headers, ...rows].join("\n"); +} + +registry.registerPath({ + method: "get", + path: "/org/{orgId}/logs/actionk/export", + description: "Export the action audit log for an organization as CSV", + tags: [OpenAPITags.Org], + request: { + query: queryAccessAuditLogsQuery, + params: queryAccessAuditLogsParams + }, + responses: {} +}); + +export async function exportAccessAuditLogs( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedQuery = queryAccessAuditLogsQuery.safeParse(req.query); + if (!parsedQuery.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedQuery.error) + ) + ); + } + const { timeStart, timeEnd, limit, offset } = parsedQuery.data; + + const parsedParams = queryAccessAuditLogsParams.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error) + ) + ); + } + const { orgId } = parsedParams.data; + + const baseQuery = querySites(timeStart, timeEnd, orgId); + + const log = await baseQuery.limit(limit).offset(offset); + + const csvData = generateCSV(log); + + res.setHeader('Content-Type', 'text/csv'); + res.setHeader('Content-Disposition', `attachment; filename="audit-logs-${orgId}-${Date.now()}.csv"`); + + return res.send(csvData); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} \ No newline at end of file diff --git a/server/private/routers/auditLogs/index.ts b/server/private/routers/auditLogs/index.ts index 1a1b1408..9b4a6e7f 100644 --- a/server/private/routers/auditLogs/index.ts +++ b/server/private/routers/auditLogs/index.ts @@ -11,4 +11,5 @@ * This file is not licensed under the AGPLv3. */ -export * from "./queryActionAuditLog"; \ No newline at end of file +export * from "./queryActionAuditLog"; +export * from "./exportActionAuditLog"; \ No newline at end of file diff --git a/server/private/routers/auditLogs/queryActionAuditLog.ts b/server/private/routers/auditLogs/queryActionAuditLog.ts index 27b8b658..6139ce4e 100644 --- a/server/private/routers/auditLogs/queryActionAuditLog.ts +++ b/server/private/routers/auditLogs/queryActionAuditLog.ts @@ -59,7 +59,7 @@ export const queryAccessAuditLogsParams = z.object({ orgId: z.string() }); -function querySites(timeStart: number, timeEnd: number, orgId: string) { +export function querySites(timeStart: number, timeEnd: number, orgId: string) { return db .select({ orgId: actionAuditLog.orgId, @@ -79,6 +79,20 @@ function querySites(timeStart: number, timeEnd: number, orgId: string) { .orderBy(actionAuditLog.timestamp); } +export function countQuery(timeStart: number, timeEnd: number, orgId: string) { + const countQuery = db + .select({ count: count() }) + .from(actionAuditLog) + .where( + and( + gt(actionAuditLog.timestamp, timeStart), + lt(actionAuditLog.timestamp, timeEnd), + eq(actionAuditLog.orgId, orgId) + ) + ); + return countQuery; +} + registry.registerPath({ method: "get", path: "/org/{orgId}/logs/action", @@ -123,18 +137,7 @@ export async function queryAccessAuditLogs( const log = await baseQuery.limit(limit).offset(offset); - const countQuery = db - .select({ count: count() }) - .from(actionAuditLog) - .where( - and( - gt(actionAuditLog.timestamp, timeStart), - lt(actionAuditLog.timestamp, timeEnd), - eq(actionAuditLog.orgId, orgId) - ) - ); - - const totalCountResult = await countQuery; + const totalCountResult = await countQuery(timeStart, timeEnd, orgId); const totalCount = totalCountResult[0].count; return response(res, { diff --git a/server/private/routers/external.ts b/server/private/routers/external.ts index 7e81336b..566c1c55 100644 --- a/server/private/routers/external.ts +++ b/server/private/routers/external.ts @@ -349,4 +349,10 @@ authenticated.post( authenticated.get( "/org/:orgId/logs/action", logs.queryAccessAuditLogs +) + + +authenticated.get( + "/org/:orgId/logs/action/export", + logs.exportAccessAuditLogs ) \ No newline at end of file diff --git a/src/app/[orgId]/settings/logs/access/page.tsx b/src/app/[orgId]/settings/logs/action/page.tsx similarity index 84% rename from src/app/[orgId]/settings/logs/access/page.tsx rename to src/app/[orgId]/settings/logs/action/page.tsx index 58479166..2fe8acd7 100644 --- a/src/app/[orgId]/settings/logs/access/page.tsx +++ b/src/app/[orgId]/settings/logs/action/page.tsx @@ -21,6 +21,7 @@ export default function GeneralPage() { const [rows, setRows] = useState([]); const [isRefreshing, setIsRefreshing] = useState(false); + const [isExporting, setIsExporting] = useState(false); // Set default date range to last 24 hours const getDefaultDateRange = () => { @@ -127,6 +128,40 @@ export default function GeneralPage() { } }; + const exportData = async () => { + try { + setIsExporting(true); + const response = await api.get(`/org/${orgId}/logs/action/export`, { + responseType: "blob", + params: { + timeStart: dateRange.startDate?.date + ? new Date(dateRange.startDate.date).toISOString() + : undefined, + timeEnd: dateRange.endDate?.date + ? new Date(dateRange.endDate.date).toISOString() + : undefined, + }, + }); + + // Create a URL for the blob and trigger a download + const url = window.URL.createObjectURL(new Blob([response.data])); + const link = document.createElement("a"); + link.href = url; + const epoch = Math.floor(Date.now() / 1000); + link.setAttribute("download", `access_audit_logs_${orgId}_${epoch}.csv`); + document.body.appendChild(link); + link.click(); + link.parentNode?.removeChild(link); + setIsExporting(false); + } catch (error) { + toast({ + title: t("error"), + description: t("exportError"), + variant: "destructive" + }); + } + }; + const columns: ColumnDef[] = [ { accessorKey: "timestamp", @@ -212,6 +247,8 @@ export default function GeneralPage() { searchColumn="action" onRefresh={refreshData} isRefreshing={isRefreshing} + onExport={exportData} + isExporting={isExporting} onDateRangeChange={handleDateRangeChange} dateRange={{ start: dateRange.startDate, diff --git a/src/app/navigation.tsx b/src/app/navigation.tsx index 80ef2e2e..4b8cfe58 100644 --- a/src/app/navigation.tsx +++ b/src/app/navigation.tsx @@ -141,7 +141,7 @@ export const orgNavSections = ( : []), { title: "sidebarLogs", - href: "/{orgId}/settings/logs/access", + href: "/{orgId}/settings/logs/action", icon: }, { diff --git a/src/components/DateTimePicker.tsx b/src/components/DateTimePicker.tsx index da6cb009..f4efce70 100644 --- a/src/components/DateTimePicker.tsx +++ b/src/components/DateTimePicker.tsx @@ -50,7 +50,6 @@ export function DateTimePicker({ const handleDateChange = (date: Date | undefined) => { setInternalDate(date); const newValue = { date, time: internalTime }; - setOpen(false); onChange?.(newValue); }; diff --git a/src/components/LogDataTable.tsx b/src/components/LogDataTable.tsx index 9fc3789b..5b9e0514 100644 --- a/src/components/LogDataTable.tsx +++ b/src/components/LogDataTable.tsx @@ -23,7 +23,7 @@ import { Button } from "@app/components/ui/button"; import { useEffect, useMemo, useState } from "react"; import { Input } from "@app/components/ui/input"; import { DataTablePagination } from "@app/components/DataTablePagination"; -import { Plus, Search, RefreshCw, Filter, X } from "lucide-react"; +import { Plus, Search, RefreshCw, Filter, X, Download } from "lucide-react"; import { Card, CardContent, @@ -82,6 +82,8 @@ type DataTableProps = { title?: string; addButtonText?: string; onRefresh?: () => void; + onExport?: () => void; + isExporting?: boolean; isRefreshing?: boolean; searchPlaceholder?: string; searchColumn?: string; @@ -109,6 +111,8 @@ export function LogDataTable({ title, onRefresh, isRefreshing, + onExport, + isExporting, searchPlaceholder = "Search...", searchColumn = "name", defaultSort, @@ -142,7 +146,6 @@ export function LogDataTable({ defaultTab || tabs?.[0]?.id || "" ); - const [showDatePicker, setShowDatePicker] = useState(false); const [startDate, setStartDate] = useState( dateRange?.start || {} ); @@ -233,22 +236,11 @@ export function LogDataTable({ onDateRangeChange?.(start, end); }; - const clearDateFilter = () => { - const emptyStart = {}; - const emptyEnd = {}; - setStartDate(emptyStart); - setEndDate(emptyEnd); - onDateRangeChange?.(emptyStart, emptyEnd); - setShowDatePicker(false); - }; - - const hasDateFilter = startDate?.date || endDate?.date; - return (
-
+
({ String(e.target.value) ) } - className="w-full pl-8" + className="w-full pl-8 m-0" />
- {tabs && tabs.length > 0 && ( - - - {tabs.map((tab) => ( - - {tab.label} ( - {data.filter(tab.filterFn).length}) - - ))} - - - )} - ({ className="flex-wrap gap-2" />
-
+
{onRefresh && ( )} + {onExport && ( + + )}