From cef7987a7242d8630d7bdf9ae11914dc06244493 Mon Sep 17 00:00:00 2001 From: Lucas Smith Date: Tue, 13 Jan 2026 14:18:10 +1100 Subject: [PATCH] feat: add audit logs to document details page (#2379) - Add collapsible audit logs section with paginated table - Add View JSON button to inspect raw audit log entries - Display legacy document ID and recipient roles - Add admin TRPC endpoint for fetching audit logs - Add database index on envelopeId for DocumentAuditLog table image --- AGENTS.md | 2 + .../tables/admin-document-logs-table.tsx | 210 ++++++++++++++++++ .../_authenticated+/admin+/documents.$id.tsx | 25 +++ .../migration.sql | 2 + packages/prisma/schema.prisma | 2 + .../admin-router/find-document-audit-logs.ts | 66 ++++++ .../find-document-audit-logs.types.ts | 17 ++ packages/trpc/server/admin-router/router.ts | 2 + 8 files changed, 326 insertions(+) create mode 100644 apps/remix/app/components/tables/admin-document-logs-table.tsx create mode 100644 packages/prisma/migrations/20260113022858_add_envelope_id_index_to_document_audit_log_table/migration.sql create mode 100644 packages/trpc/server/admin-router/find-document-audit-logs.ts create mode 100644 packages/trpc/server/admin-router/find-document-audit-logs.types.ts diff --git a/AGENTS.md b/AGENTS.md index b6fba867d..bf92317f2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -11,6 +11,8 @@ - `npm run format` - Format code with Prettier - `npm run dev` - Start development server for Remix app +**Important:** Do not run `npm run build` to verify changes unless explicitly asked. Builds take a long time (~2 minutes). Use `npx tsc --noEmit` for type checking specific packages if needed. + ## Code Style Guidelines - Use TypeScript for all code; prefer `type` over `interface` diff --git a/apps/remix/app/components/tables/admin-document-logs-table.tsx b/apps/remix/app/components/tables/admin-document-logs-table.tsx new file mode 100644 index 000000000..0d8b248b1 --- /dev/null +++ b/apps/remix/app/components/tables/admin-document-logs-table.tsx @@ -0,0 +1,210 @@ +import { useMemo, useState } from 'react'; + +import { msg } from '@lingui/core/macro'; +import { useLingui } from '@lingui/react'; +import { Trans } from '@lingui/react/macro'; +import { DateTime } from 'luxon'; +import type { DateTimeFormatOptions } from 'luxon'; +import { useSearchParams } from 'react-router'; +import { UAParser } from 'ua-parser-js'; + +import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params'; +import type { TDocumentAuditLog } from '@documenso/lib/types/document-audit-logs'; +import { ZUrlSearchParamsSchema } from '@documenso/lib/types/search-params'; +import { formatDocumentAuditLogAction } from '@documenso/lib/utils/document-audit-logs'; +import { trpc } from '@documenso/trpc/react'; +import { CopyTextButton } from '@documenso/ui/components/common/copy-text-button'; +import { Button } from '@documenso/ui/primitives/button'; +import type { DataTableColumnDef } from '@documenso/ui/primitives/data-table'; +import { DataTable } from '@documenso/ui/primitives/data-table'; +import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination'; +import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@documenso/ui/primitives/dialog'; +import { Skeleton } from '@documenso/ui/primitives/skeleton'; +import { TableCell } from '@documenso/ui/primitives/table'; +import { useToast } from '@documenso/ui/primitives/use-toast'; + +export type AdminDocumentLogsTableProps = { + envelopeId: string; +}; + +const dateFormat: DateTimeFormatOptions = { + ...DateTime.DATETIME_SHORT, + hourCycle: 'h12', +}; + +export const AdminDocumentLogsTable = ({ envelopeId }: AdminDocumentLogsTableProps) => { + const { _, i18n } = useLingui(); + const { toast } = useToast(); + + const [searchParams] = useSearchParams(); + const updateSearchParams = useUpdateSearchParams(); + + const [selectedAuditLog, setSelectedAuditLog] = useState(null); + + const parsedSearchParams = ZUrlSearchParamsSchema.parse(Object.fromEntries(searchParams ?? [])); + + const { data, isLoading, isLoadingError } = trpc.admin.document.findAuditLogs.useQuery( + { + envelopeId, + page: parsedSearchParams.page, + perPage: parsedSearchParams.perPage, + }, + { + placeholderData: (previousData) => previousData, + }, + ); + + const onPaginationChange = (page: number, perPage: number) => { + updateSearchParams({ + page, + perPage, + }); + }; + + const results = data ?? { + data: [], + perPage: 10, + currentPage: 1, + totalPages: 1, + }; + + const columns = useMemo(() => { + const parser = new UAParser(); + + return [ + { + header: _(msg`Time`), + accessorKey: 'createdAt', + cell: ({ row }) => i18n.date(row.original.createdAt, dateFormat), + }, + { + header: _(msg`User`), + accessorKey: 'name', + cell: ({ row }) => + row.original.name || row.original.email ? ( +
+ {row.original.name && ( +

+ {row.original.name} +

+ )} + + {row.original.email && ( +

+ {row.original.email} +

+ )} +
+ ) : ( +

N/A

+ ), + }, + { + header: _(msg`Action`), + accessorKey: 'type', + cell: ({ row }) => ( + {formatDocumentAuditLogAction(i18n, row.original).description} + ), + }, + { + header: _(msg`IP Address`), + accessorKey: 'ipAddress', + }, + { + header: _(msg`Browser`), + cell: ({ row }) => { + if (!row.original.userAgent) { + return 'N/A'; + } + + parser.setUA(row.original.userAgent); + + const result = parser.getResult(); + + return result.browser.name ?? 'N/A'; + }, + }, + { + header: '', + id: 'actions', + cell: ({ row }) => ( + + ), + }, + ] satisfies DataTableColumnDef<(typeof results)['data'][number]>[]; + }, []); + + return ( + <> + + + + + +
+ + +
+
+ + + + + + + + + + + + + + ), + }} + > + {(table) => } +
+ + setSelectedAuditLog(null)}> + + + + Audit Log Details + + + + {selectedAuditLog && ( +
+
+ toast({ title: _(msg`Copied to clipboard`) })} + /> +
+ +
+                {JSON.stringify(selectedAuditLog, null, 2)}
+              
+
+ )} +
+
+ + ); +}; diff --git a/apps/remix/app/routes/_authenticated+/admin+/documents.$id.tsx b/apps/remix/app/routes/_authenticated+/admin+/documents.$id.tsx index 45bdf5629..c6ec9adfe 100644 --- a/apps/remix/app/routes/_authenticated+/admin+/documents.$id.tsx +++ b/apps/remix/app/routes/_authenticated+/admin+/documents.$id.tsx @@ -6,6 +6,7 @@ import { DateTime } from 'luxon'; import { Link, redirect } from 'react-router'; import { unsafeGetEntireEnvelope } from '@documenso/lib/server-only/admin/get-entire-document'; +import { mapSecondaryIdToDocumentId } from '@documenso/lib/utils/envelope'; import { trpc } from '@documenso/trpc/react'; import { Accordion, @@ -26,6 +27,7 @@ import { useToast } from '@documenso/ui/primitives/use-toast'; import { AdminDocumentDeleteDialog } from '~/components/dialogs/admin-document-delete-dialog'; import { DocumentStatus } from '~/components/general/document/document-status'; import { AdminDocumentJobsTable } from '~/components/tables/admin-document-jobs-table'; +import { AdminDocumentLogsTable } from '~/components/tables/admin-document-logs-table'; import { AdminDocumentRecipientItemTable } from '~/components/tables/admin-document-recipient-item-table'; import type { Route } from './+types/documents.$id'; @@ -87,6 +89,10 @@ export default function AdminDocumentDetailsPage({ loaderData }: Route.Component
+
+ Document ID: {mapSecondaryIdToDocumentId(envelope.secondaryId)} +
+
Created on: {i18n.date(envelope.createdAt, DateTime.DATETIME_MED)}
@@ -156,6 +162,9 @@ export default function AdminDocumentDetailsPage({ loaderData }: Route.Component {recipient.email} + + {recipient.role} +
@@ -175,6 +184,22 @@ export default function AdminDocumentDetailsPage({ loaderData }: Route.Component
+ + + +

+ Audit Logs +

+
+ + + + +
+
+ +
+ {envelope && } ); diff --git a/packages/prisma/migrations/20260113022858_add_envelope_id_index_to_document_audit_log_table/migration.sql b/packages/prisma/migrations/20260113022858_add_envelope_id_index_to_document_audit_log_table/migration.sql new file mode 100644 index 000000000..d2086bb05 --- /dev/null +++ b/packages/prisma/migrations/20260113022858_add_envelope_id_index_to_document_audit_log_table/migration.sql @@ -0,0 +1,2 @@ +-- CreateIndex +CREATE INDEX IF NOT EXISTS "DocumentAuditLog_envelopeId_idx" ON "DocumentAuditLog"("envelopeId"); diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index 2526fd853..29db786f2 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -472,6 +472,8 @@ model DocumentAuditLog { ipAddress String? envelope Envelope @relation(fields: [envelopeId], references: [id], onDelete: Cascade) + + @@index([envelopeId]) } enum DocumentDataType { diff --git a/packages/trpc/server/admin-router/find-document-audit-logs.ts b/packages/trpc/server/admin-router/find-document-audit-logs.ts new file mode 100644 index 000000000..f3a35a236 --- /dev/null +++ b/packages/trpc/server/admin-router/find-document-audit-logs.ts @@ -0,0 +1,66 @@ +import { EnvelopeType } from '@prisma/client'; + +import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; +import type { FindResultResponse } from '@documenso/lib/types/search-params'; +import { parseDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs'; +import { unsafeBuildEnvelopeIdQuery } from '@documenso/lib/utils/envelope'; +import { prisma } from '@documenso/prisma'; + +import { adminProcedure } from '../trpc'; +import { + ZFindDocumentAuditLogsRequestSchema, + ZFindDocumentAuditLogsResponseSchema, +} from './find-document-audit-logs.types'; + +export const findDocumentAuditLogsRoute = adminProcedure + .input(ZFindDocumentAuditLogsRequestSchema) + .output(ZFindDocumentAuditLogsResponseSchema) + .query(async ({ input }) => { + const { + envelopeId, + page = 1, + perPage = 50, + orderByColumn = 'createdAt', + orderByDirection = 'desc', + } = input; + + const envelope = await prisma.envelope.findFirst({ + where: unsafeBuildEnvelopeIdQuery( + { + type: 'envelopeId', + id: envelopeId, + }, + EnvelopeType.DOCUMENT, + ), + }); + + if (!envelope) { + throw new AppError(AppErrorCode.NOT_FOUND, { + message: 'Envelope not found', + }); + } + + const [data, count] = await Promise.all([ + prisma.documentAuditLog.findMany({ + where: { envelopeId: envelope.id }, + skip: Math.max(page - 1, 0) * perPage, + take: perPage, + orderBy: { + [orderByColumn]: orderByDirection, + }, + }), + prisma.documentAuditLog.count({ + where: { envelopeId: envelope.id }, + }), + ]); + + const parsedData = data.map((auditLog) => parseDocumentAuditLogData(auditLog)); + + return { + data: parsedData, + count, + currentPage: Math.max(page, 1), + perPage, + totalPages: Math.ceil(count / perPage), + } satisfies FindResultResponse; + }); diff --git a/packages/trpc/server/admin-router/find-document-audit-logs.types.ts b/packages/trpc/server/admin-router/find-document-audit-logs.types.ts new file mode 100644 index 000000000..b39205981 --- /dev/null +++ b/packages/trpc/server/admin-router/find-document-audit-logs.types.ts @@ -0,0 +1,17 @@ +import { z } from 'zod'; + +import { ZDocumentAuditLogSchema } from '@documenso/lib/types/document-audit-logs'; +import { ZFindResultResponse, ZFindSearchParamsSchema } from '@documenso/lib/types/search-params'; + +export const ZFindDocumentAuditLogsRequestSchema = ZFindSearchParamsSchema.extend({ + envelopeId: z.string(), + orderByColumn: z.enum(['createdAt']).optional(), + orderByDirection: z.enum(['asc', 'desc']).optional(), +}); + +export const ZFindDocumentAuditLogsResponseSchema = ZFindResultResponse.extend({ + data: ZDocumentAuditLogSchema.array(), +}); + +export type TFindDocumentAuditLogsRequest = z.infer; +export type TFindDocumentAuditLogsResponse = z.infer; diff --git a/packages/trpc/server/admin-router/router.ts b/packages/trpc/server/admin-router/router.ts index 526f04980..7e1a7d5d7 100644 --- a/packages/trpc/server/admin-router/router.ts +++ b/packages/trpc/server/admin-router/router.ts @@ -8,6 +8,7 @@ import { deleteUserRoute } from './delete-user'; import { disableUserRoute } from './disable-user'; import { enableUserRoute } from './enable-user'; import { findAdminOrganisationsRoute } from './find-admin-organisations'; +import { findDocumentAuditLogsRoute } from './find-document-audit-logs'; import { findDocumentJobsRoute } from './find-document-jobs'; import { findDocumentsRoute } from './find-documents'; import { findSubscriptionClaimsRoute } from './find-subscription-claims'; @@ -56,6 +57,7 @@ export const adminRouter = router({ delete: deleteDocumentRoute, reseal: resealDocumentRoute, findJobs: findDocumentJobsRoute, + findAuditLogs: findDocumentAuditLogsRoute, }, recipient: { update: updateRecipientRoute,