From 607f22513ac20e9555ab3f72ac217e0ff80d1563 Mon Sep 17 00:00:00 2001 From: Lucas Smith Date: Mon, 19 May 2025 09:10:08 +1000 Subject: [PATCH] feat: allow adding audit logs to compeleted document --- .../forms/team-document-preferences-form.tsx | 36 +++- .../document/document-page-view-button.tsx | 156 +++++++++++++++++- .../emails/send-team-deleted-email.ts | 1 + .../internal/seal-document.handler.ts | 19 +++ .../lib/server-only/document/seal-document.ts | 19 +++ .../htmltopdf/get-audit-log-pdf.ts | 69 ++++++++ .../migration.sql | 2 + packages/prisma/schema.prisma | 12 +- .../update-team-document-settings.ts | 3 + .../update-team-document-settings.types.ts | 1 + packages/ui/primitives/split-button.tsx | 83 ++++++++++ 11 files changed, 391 insertions(+), 10 deletions(-) create mode 100644 packages/lib/server-only/htmltopdf/get-audit-log-pdf.ts create mode 100644 packages/prisma/migrations/20250515060302_add_audit_log_team_preference/migration.sql create mode 100644 packages/ui/primitives/split-button.tsx diff --git a/apps/remix/app/components/forms/team-document-preferences-form.tsx b/apps/remix/app/components/forms/team-document-preferences-form.tsx index 2b4846116..148ecb206 100644 --- a/apps/remix/app/components/forms/team-document-preferences-form.tsx +++ b/apps/remix/app/components/forms/team-document-preferences-form.tsx @@ -44,6 +44,7 @@ const ZTeamDocumentPreferencesFormSchema = z.object({ documentLanguage: z.enum(SUPPORTED_LANGUAGE_CODES), includeSenderDetails: z.boolean(), includeSigningCertificate: z.boolean(), + includeAuditLog: z.boolean(), signatureTypes: z.array(z.nativeEnum(DocumentSignatureType)).min(1, { message: msg`At least one signature type must be enabled`.id, }), @@ -77,6 +78,7 @@ export const TeamDocumentPreferencesForm = ({ : 'en', includeSenderDetails: settings?.includeSenderDetails ?? false, includeSigningCertificate: settings?.includeSigningCertificate ?? true, + includeAuditLog: settings?.includeAuditLog ?? false, signatureTypes: extractTeamSignatureSettings(settings), }, resolver: zodResolver(ZTeamDocumentPreferencesFormSchema), @@ -91,6 +93,7 @@ export const TeamDocumentPreferencesForm = ({ documentLanguage, includeSenderDetails, includeSigningCertificate, + includeAuditLog, signatureTypes, } = data; @@ -101,6 +104,7 @@ export const TeamDocumentPreferencesForm = ({ documentLanguage, includeSenderDetails, includeSigningCertificate, + includeAuditLog, typedSignatureEnabled: signatureTypes.includes(DocumentSignatureType.TYPE), uploadSignatureEnabled: signatureTypes.includes(DocumentSignatureType.UPLOAD), drawSignatureEnabled: signatureTypes.includes(DocumentSignatureType.DRAW), @@ -307,7 +311,7 @@ export const TeamDocumentPreferencesForm = ({ - Controls whether the signing certificate will be included in the document when + Controls whether the signing certificate will be included with the document when it is downloaded. The signing certificate can still be downloaded from the logs page separately. @@ -316,6 +320,36 @@ export const TeamDocumentPreferencesForm = ({ )} /> + ( + + + Include the Audit Log in the Document + + +
+ + + +
+ + + + Controls whether the audit log will be included with the document when it is + downloaded. The audit log can still be downloaded from the logs page separately. + + +
+ )} + /> +
)) .with({ isComplete: true }, () => ( - + + + + Download + + + + + Download Original Document + + + + Download Document Certificate + + + + Download Audit Log + + + )) .otherwise(() => null); }; diff --git a/packages/lib/jobs/definitions/emails/send-team-deleted-email.ts b/packages/lib/jobs/definitions/emails/send-team-deleted-email.ts index 1475a531e..72f7a686c 100644 --- a/packages/lib/jobs/definitions/emails/send-team-deleted-email.ts +++ b/packages/lib/jobs/definitions/emails/send-team-deleted-email.ts @@ -16,6 +16,7 @@ const SEND_TEAM_DELETED_EMAIL_JOB_DEFINITION_SCHEMA = z.object({ documentLanguage: z.string(), includeSenderDetails: z.boolean(), includeSigningCertificate: z.boolean(), + includeAuditLog: z.boolean(), brandingEnabled: z.boolean(), brandingLogo: z.string(), brandingUrl: z.string(), diff --git a/packages/lib/jobs/definitions/internal/seal-document.handler.ts b/packages/lib/jobs/definitions/internal/seal-document.handler.ts index 5726dcf2a..779d31b72 100644 --- a/packages/lib/jobs/definitions/internal/seal-document.handler.ts +++ b/packages/lib/jobs/definitions/internal/seal-document.handler.ts @@ -9,6 +9,7 @@ import { signPdf } from '@documenso/signing'; import { AppError, AppErrorCode } from '../../../errors/app-error'; import { sendCompletedEmail } from '../../../server-only/document/send-completed-email'; import PostHogServerClient from '../../../server-only/feature-flags/get-post-hog-server-client'; +import { getAuditLogPdf } from '../../../server-only/htmltopdf/get-audit-log-pdf'; import { getCertificatePdf } from '../../../server-only/htmltopdf/get-certificate-pdf'; import { addRejectionStampToPdf } from '../../../server-only/pdf/add-rejection-stamp-to-pdf'; import { flattenAnnotations } from '../../../server-only/pdf/flatten-annotations'; @@ -52,6 +53,7 @@ export const run = async ({ teamGlobalSettings: { select: { includeSigningCertificate: true, + includeAuditLog: true, }, }, }, @@ -152,6 +154,13 @@ export const run = async ({ }).catch(() => null) : null; + const auditLogData = + (document.team?.teamGlobalSettings?.includeAuditLog ?? false) + ? await getAuditLogPdf({ + documentId, + }).catch(() => null) + : null; + const newDataId = await io.runTask('decorate-and-sign-pdf', async () => { const pdfDoc = await PDFDocument.load(pdfData); @@ -178,6 +187,16 @@ export const run = async ({ }); } + if (auditLogData) { + const auditLogDoc = await PDFDocument.load(auditLogData); + + const auditLogPages = await pdfDoc.copyPages(auditLogDoc, auditLogDoc.getPageIndices()); + + auditLogPages.forEach((page) => { + pdfDoc.addPage(page); + }); + } + for (const field of fields) { if (field.inserted) { document.useLegacyFieldInsertion diff --git a/packages/lib/server-only/document/seal-document.ts b/packages/lib/server-only/document/seal-document.ts index 507f6ec19..0473a9ded 100644 --- a/packages/lib/server-only/document/seal-document.ts +++ b/packages/lib/server-only/document/seal-document.ts @@ -17,6 +17,7 @@ import type { RequestMetadata } from '../../universal/extract-request-metadata'; import { getFileServerSide } from '../../universal/upload/get-file.server'; import { putPdfFileServerSide } from '../../universal/upload/put-file.server'; import { fieldsContainUnsignedRequiredField } from '../../utils/advanced-fields-helpers'; +import { getAuditLogPdf } from '../htmltopdf/get-audit-log-pdf'; import { getCertificatePdf } from '../htmltopdf/get-certificate-pdf'; import { addRejectionStampToPdf } from '../pdf/add-rejection-stamp-to-pdf'; import { flattenAnnotations } from '../pdf/flatten-annotations'; @@ -53,6 +54,7 @@ export const sealDocument = async ({ teamGlobalSettings: { select: { includeSigningCertificate: true, + includeAuditLog: true, }, }, }, @@ -124,6 +126,13 @@ export const sealDocument = async ({ }).catch(() => null) : null; + const auditLogData = + (document.team?.teamGlobalSettings?.includeAuditLog ?? false) + ? await getAuditLogPdf({ + documentId, + }).catch(() => null) + : null; + const doc = await PDFDocument.load(pdfData); // Normalize and flatten layers that could cause issues with the signature @@ -146,6 +155,16 @@ export const sealDocument = async ({ }); } + if (auditLogData) { + const auditLog = await PDFDocument.load(auditLogData); + + const auditLogPages = await doc.copyPages(auditLog, auditLog.getPageIndices()); + + auditLogPages.forEach((page) => { + doc.addPage(page); + }); + } + for (const field of fields) { document.useLegacyFieldInsertion ? await legacy_insertFieldInPDF(doc, field) diff --git a/packages/lib/server-only/htmltopdf/get-audit-log-pdf.ts b/packages/lib/server-only/htmltopdf/get-audit-log-pdf.ts new file mode 100644 index 000000000..456fb4a4f --- /dev/null +++ b/packages/lib/server-only/htmltopdf/get-audit-log-pdf.ts @@ -0,0 +1,69 @@ +import { DateTime } from 'luxon'; +import type { Browser } from 'playwright'; + +import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app'; +import { type SupportedLanguageCodes, isValidLanguageCode } from '../../constants/i18n'; +import { env } from '../../utils/env'; +import { encryptSecondaryData } from '../crypto/encrypt'; + +export type GetAuditLogPdfOptions = { + documentId: number; + // eslint-disable-next-line @typescript-eslint/ban-types + language?: SupportedLanguageCodes | (string & {}); +}; + +export const getAuditLogPdf = async ({ documentId, language }: GetAuditLogPdfOptions) => { + const { chromium } = await import('playwright'); + + const encryptedId = encryptSecondaryData({ + data: documentId.toString(), + expiresAt: DateTime.now().plus({ minutes: 5 }).toJSDate().valueOf(), + }); + + let browser: Browser; + + const browserlessUrl = env('NEXT_PRIVATE_BROWSERLESS_URL'); + + if (browserlessUrl) { + // !: Use CDP rather than the default `connect` method to avoid coupling to the playwright version. + // !: Previously we would have to keep the playwright version in sync with the browserless version to avoid errors. + browser = await chromium.connectOverCDP(browserlessUrl); + } else { + browser = await chromium.launch(); + } + + if (!browser) { + throw new Error( + 'Failed to establish a browser, please ensure you have either a Browserless.io url or chromium browser installed', + ); + } + + const browserContext = await browser.newContext(); + + const page = await browserContext.newPage(); + + const lang = isValidLanguageCode(language) ? language : 'en'; + + await page.context().addCookies([ + { + name: 'language', + value: lang, + url: NEXT_PUBLIC_WEBAPP_URL(), + }, + ]); + + await page.goto(`${NEXT_PUBLIC_WEBAPP_URL()}/__htmltopdf/audit-log?d=${encryptedId}`, { + waitUntil: 'networkidle', + timeout: 10_000, + }); + + const result = await page.pdf({ + format: 'A4', + }); + + await browserContext.close(); + + void browser.close(); + + return result; +}; diff --git a/packages/prisma/migrations/20250515060302_add_audit_log_team_preference/migration.sql b/packages/prisma/migrations/20250515060302_add_audit_log_team_preference/migration.sql new file mode 100644 index 000000000..5c8a13ef0 --- /dev/null +++ b/packages/prisma/migrations/20250515060302_add_audit_log_team_preference/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "TeamGlobalSettings" ADD COLUMN "includeAuditLog" BOOLEAN NOT NULL DEFAULT false; diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index 56558ad6c..058850567 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -582,11 +582,13 @@ enum TeamMemberInviteStatus { } model TeamGlobalSettings { - teamId Int @unique - documentVisibility DocumentVisibility @default(EVERYONE) - documentLanguage String @default("en") - includeSenderDetails Boolean @default(true) - includeSigningCertificate Boolean @default(true) + teamId Int @unique + documentVisibility DocumentVisibility @default(EVERYONE) + documentLanguage String @default("en") + includeSenderDetails Boolean @default(true) + + includeSigningCertificate Boolean @default(true) + includeAuditLog Boolean @default(false) typedSignatureEnabled Boolean @default(true) uploadSignatureEnabled Boolean @default(true) diff --git a/packages/trpc/server/team-router/update-team-document-settings.ts b/packages/trpc/server/team-router/update-team-document-settings.ts index ce3525b59..f0e9941e3 100644 --- a/packages/trpc/server/team-router/update-team-document-settings.ts +++ b/packages/trpc/server/team-router/update-team-document-settings.ts @@ -23,6 +23,7 @@ export const updateTeamDocumentSettingsRoute = authenticatedProcedure documentLanguage, includeSenderDetails, includeSigningCertificate, + includeAuditLog, typedSignatureEnabled, uploadSignatureEnabled, drawSignatureEnabled, @@ -54,6 +55,7 @@ export const updateTeamDocumentSettingsRoute = authenticatedProcedure documentLanguage, includeSenderDetails, includeSigningCertificate, + includeAuditLog, typedSignatureEnabled, uploadSignatureEnabled, drawSignatureEnabled, @@ -63,6 +65,7 @@ export const updateTeamDocumentSettingsRoute = authenticatedProcedure documentLanguage, includeSenderDetails, includeSigningCertificate, + includeAuditLog, typedSignatureEnabled, uploadSignatureEnabled, drawSignatureEnabled, diff --git a/packages/trpc/server/team-router/update-team-document-settings.types.ts b/packages/trpc/server/team-router/update-team-document-settings.types.ts index 14263ce56..3e0461279 100644 --- a/packages/trpc/server/team-router/update-team-document-settings.types.ts +++ b/packages/trpc/server/team-router/update-team-document-settings.types.ts @@ -14,6 +14,7 @@ export const ZUpdateTeamDocumentSettingsRequestSchema = z.object({ documentLanguage: z.enum(SUPPORTED_LANGUAGE_CODES).optional().default('en'), includeSenderDetails: z.boolean().optional().default(false), includeSigningCertificate: z.boolean().optional().default(true), + includeAuditLog: z.boolean().optional().default(false), typedSignatureEnabled: z.boolean().optional().default(true), uploadSignatureEnabled: z.boolean().optional().default(true), drawSignatureEnabled: z.boolean().optional().default(true), diff --git a/packages/ui/primitives/split-button.tsx b/packages/ui/primitives/split-button.tsx new file mode 100644 index 000000000..9355ce6c0 --- /dev/null +++ b/packages/ui/primitives/split-button.tsx @@ -0,0 +1,83 @@ +import * as React from 'react'; + +import { ChevronDown } from 'lucide-react'; + +import { cn } from '../lib/utils'; +import { Button } from './button'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from './dropdown-menu'; + +const SplitButtonContext = React.createContext<{ + variant?: React.ComponentProps['variant']; + size?: React.ComponentProps['size']; +}>({}); + +const SplitButton = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes & { + variant?: React.ComponentProps['variant']; + size?: React.ComponentProps['size']; + } +>(({ className, children, variant = 'default', size = 'default', ...props }, ref) => { + return ( + +
+ {children} +
+
+ ); +}); +SplitButton.displayName = 'SplitButton'; + +const SplitButtonAction = React.forwardRef< + HTMLButtonElement, + React.ButtonHTMLAttributes +>(({ className, children, ...props }, ref) => { + const { variant, size } = React.useContext(SplitButtonContext); + return ( + + ); +}); + +SplitButtonAction.displayName = 'SplitButtonAction'; + +const SplitButtonDropdown = React.forwardRef>( + ({ children, ...props }, ref) => { + const { variant, size } = React.useContext(SplitButtonContext); + return ( + + + + + + {children} + + + ); + }, +); + +SplitButtonDropdown.displayName = 'SplitButtonDropdown'; + +const SplitButtonDropdownItem = DropdownMenuItem; + +export { SplitButton, SplitButtonAction, SplitButtonDropdown, SplitButtonDropdownItem };