diff --git a/echo/frontend/src/components/settings/AuditLogsCard.tsx b/echo/frontend/src/components/settings/AuditLogsCard.tsx new file mode 100644 index 00000000..0d76f95e --- /dev/null +++ b/echo/frontend/src/components/settings/AuditLogsCard.tsx @@ -0,0 +1,471 @@ +import { t } from "@lingui/core/macro"; +import { Trans } from "@lingui/react/macro"; +import { + ActionIcon, + Alert, + Badge, + Button, + Group, + Loader, + Menu, + MultiSelect, + Pagination, + Paper, + ScrollArea, + Select, + Stack, + Table, + Text, + Tooltip, +} from "@mantine/core"; +import { + IconDownload, + IconFileTypeCsv, + IconFileTypeJs, + IconLogs, + IconRefresh, +} from "@tabler/icons-react"; +import { + type ColumnDef, + flexRender, + getCoreRowModel, + useReactTable, +} from "@tanstack/react-table"; +import { useEffect, useMemo, useState } from "react"; +import { toast } from "@/components/common/Toaster"; +import { + type AuditLogEntry, + type AuditLogExportFormat, + type AuditLogFilters, + type AuditLogOption, + useAuditLogMetadata, + useAuditLogsExport, + useAuditLogsQuery, +} from "./hooks"; + +const PAGE_SIZE_OPTIONS = ["10", "25", "50"]; +const DEFAULT_PAGE_SIZE = 25; + +const formatTimestamp = (value: string) => { + const date = new Date(value); + if (Number.isNaN(date.getTime())) return value; + return new Intl.DateTimeFormat(undefined, { + dateStyle: "medium", + timeStyle: "short", + }).format(date); +}; + +const getActorLabel = (entry: AuditLogEntry) => { + const name = [entry.user?.first_name, entry.user?.last_name] + .filter(Boolean) + .join(" ") + .trim(); + + if (name.length > 0) return name; + if (entry.user?.email) return entry.user.email; + return t`System`; +}; + +const toSelectData = (options: AuditLogOption[]) => + options.map((option) => ({ + label: `${option.label} (${option.count})`, + value: option.value, + })); + +export const AuditLogsCard = () => { + const [selectedActions, setSelectedActions] = useState([]); + const [selectedCollections, setSelectedCollections] = useState([]); + const [page, setPage] = useState(1); + const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE); + + const filters: AuditLogFilters = useMemo( + () => ({ + actions: selectedActions, + collections: selectedCollections, + }), + [selectedActions, selectedCollections], + ); + + const pageIndex = page - 1; + + const { data, error, isError, isFetching, isLoading, refetch } = + useAuditLogsQuery({ + filters, + page: pageIndex, + pageSize, + }); + + const { data: metadata, isLoading: isMetadataLoading } = + useAuditLogMetadata(); + + const exportMutation = useAuditLogsExport(); + + const totalItems = data?.total ?? 0; + const totalPages = Math.max(1, Math.ceil((data?.total ?? 0) / pageSize)); + + const tableData = data?.items ?? []; + + const columns = useMemo[]>( + () => [ + { + accessorKey: "action", + cell: ({ row }) => ( + + + {row.original.action} + + + {formatTimestamp(row.original.timestamp)} + + + ), + header: () => t`Action`, + }, + { + accessorKey: "collection", + cell: ({ row }) => ( + + {row.original.collection} + + ), + header: () => t`Collection`, + }, + { + accessorKey: "item", + cell: ({ row }) => {row.original.item}, + header: () => t`Action On`, + }, + { + accessorKey: "user", + cell: ({ row }) => ( + + {getActorLabel(row.original)} + + ), + header: () => t`Action By`, + }, + { + accessorKey: "ip", + cell: ({ row }) => ( + {row.original.ip ?? t`Unknown`} + ), + header: () => t`IP Address`, + }, + { + accessorKey: "user_agent", + cell: ({ row }) => { + const agent = row.original.user_agent; + + if (!agent) { + return {t`Unknown`}; + } + + return ( + + + {agent} + + + ); + }, + header: () => t`User Agent`, + }, + ], + [], + ); + + const table = useReactTable({ + columns, + data: tableData, + getCoreRowModel: getCoreRowModel(), + manualPagination: true, + pageCount: totalPages, + state: { + pagination: { + pageIndex, + pageSize, + }, + }, + }); + + const handlePageSizeChange = (value: string | null) => { + if (!value) return; + const next = Number(value); + setPageSize(next); + setPage(1); + }; + + const handleActionsChange = (value: string[]) => { + setSelectedActions(value); + setPage(1); + }; + + const handleCollectionsChange = (value: string[]) => { + setSelectedCollections(value); + setPage(1); + }; + + useEffect(() => { + if (page > totalPages) { + setPage(totalPages); + } + }, [page, totalPages]); + + const handleExport = async (format: AuditLogExportFormat) => { + try { + const result = await exportMutation.mutateAsync({ filters, format }); + const blobUrl = URL.createObjectURL(result.blob); + + const link = document.createElement("a"); + link.href = blobUrl; + link.download = result.filename; + document.body.appendChild(link); + link.click(); + link.remove(); + URL.revokeObjectURL(blobUrl); + + toast.success( + format === "csv" + ? t`Audit logs exported to CSV` + : t`Audit logs exported to JSON`, + ); + } catch (exportError) { + const message = + exportError instanceof Error + ? exportError.message + : t`Something went wrong while exporting audit logs.`; + toast.error(message); + } + }; + + const isEmpty = !isLoading && tableData.length === 0; + const displayFrom = totalItems === 0 ? 0 : pageIndex * pageSize + 1; + const displayTo = Math.min(totalItems, (pageIndex + 1) * pageSize); + + return ( + + + + + + + + Audit logs + + + + + Review activity for your workspace. Filter by collection or + action, and export the current view for further investigation. + + + + + refetch()} + disabled={isFetching && !isLoading} + > + {isFetching && !isLoading ? ( + + ) : ( + + )} + + + + + + + + + Download as + + } + onClick={() => handleExport("csv")} + > + CSV + + } + onClick={() => handleExport("json")} + > + JSON + + + + + + + + + + + + +