diff --git a/apps/remix/app/components/forms/document-preferences-form.tsx b/apps/remix/app/components/forms/document-preferences-form.tsx index eb91a4b84..eb3c64e06 100644 --- a/apps/remix/app/components/forms/document-preferences-form.tsx +++ b/apps/remix/app/components/forms/document-preferences-form.tsx @@ -55,6 +55,7 @@ export type TDocumentPreferencesFormSchema = { documentDateFormat: TDocumentMetaDateFormat | null; includeSenderDetails: boolean | null; includeSigningCertificate: boolean | null; + includeAuditLog: boolean | null; signatureTypes: DocumentSignatureType[]; }; @@ -66,6 +67,7 @@ type SettingsSubset = Pick< | 'documentDateFormat' | 'includeSenderDetails' | 'includeSigningCertificate' + | 'includeAuditLog' | 'typedSignatureEnabled' | 'uploadSignatureEnabled' | 'drawSignatureEnabled' @@ -96,6 +98,7 @@ export const DocumentPreferencesForm = ({ documentDateFormat: ZDocumentMetaTimezoneSchema.nullable(), includeSenderDetails: z.boolean().nullable(), includeSigningCertificate: z.boolean().nullable(), + includeAuditLog: z.boolean().nullable(), signatureTypes: z.array(z.nativeEnum(DocumentSignatureType)).min(canInherit ? 0 : 1, { message: msg`At least one signature type must be enabled`.id, }), @@ -112,6 +115,7 @@ export const DocumentPreferencesForm = ({ documentDateFormat: settings.documentDateFormat as TDocumentMetaDateFormat | null, includeSenderDetails: settings.includeSenderDetails, includeSigningCertificate: settings.includeSigningCertificate, + includeAuditLog: settings.includeAuditLog, signatureTypes: extractTeamSignatureSettings({ ...settings }), }, resolver: zodResolver(ZDocumentPreferencesFormSchema), @@ -452,6 +456,56 @@ export const DocumentPreferencesForm = ({ )} /> + ( + + + Include the Audit Logs in the Document + + + + + + + + + Controls whether the audit logs will be included in the document when it is + downloaded. The audit logs can still be downloaded from the logs page + separately. + + + + )} + /> +
); }; diff --git a/apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.document.tsx b/apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.document.tsx index 9c3cfb6ce..17969c628 100644 --- a/apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.document.tsx +++ b/apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.document.tsx @@ -46,6 +46,7 @@ export default function OrganisationSettingsDocumentPage() { documentDateFormat, includeSenderDetails, includeSigningCertificate, + includeAuditLog, signatureTypes, } = data; @@ -54,7 +55,8 @@ export default function OrganisationSettingsDocumentPage() { documentLanguage === null || documentDateFormat === null || includeSenderDetails === null || - includeSigningCertificate === null + includeSigningCertificate === null || + includeAuditLog === null ) { throw new Error('Should not be possible.'); } @@ -68,6 +70,7 @@ export default function OrganisationSettingsDocumentPage() { documentDateFormat, includeSenderDetails, includeSigningCertificate, + includeAuditLog, typedSignatureEnabled: signatureTypes.includes(DocumentSignatureType.TYPE), uploadSignatureEnabled: signatureTypes.includes(DocumentSignatureType.UPLOAD), drawSignatureEnabled: signatureTypes.includes(DocumentSignatureType.DRAW), diff --git a/apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents.$id.logs.tsx b/apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents.$id.logs.tsx index 754b008f3..5e89cc439 100644 --- a/apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents.$id.logs.tsx +++ b/apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents.$id.logs.tsx @@ -170,7 +170,7 @@ export default function DocumentsLogsPage({ loaderData }: Route.ComponentProps) diff --git a/apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.document.tsx b/apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.document.tsx index 45c376077..c669f84f6 100644 --- a/apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.document.tsx +++ b/apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.document.tsx @@ -38,6 +38,7 @@ export default function TeamsSettingsPage() { documentDateFormat, includeSenderDetails, includeSigningCertificate, + includeAuditLog, signatureTypes, } = data; @@ -50,6 +51,7 @@ export default function TeamsSettingsPage() { documentDateFormat, includeSenderDetails, includeSigningCertificate, + includeAuditLog, ...(signatureTypes.length === 0 ? { typedSignatureEnabled: null, diff --git a/apps/remix/app/routes/_internal+/[__htmltopdf]+/audit-log.print.css b/apps/remix/app/routes/_internal+/[__htmltopdf]+/audit-log.print.css new file mode 100644 index 000000000..7825a2791 --- /dev/null +++ b/apps/remix/app/routes/_internal+/[__htmltopdf]+/audit-log.print.css @@ -0,0 +1,5 @@ +@media print { + html { + font-size: 10pt; + } +} diff --git a/apps/remix/app/routes/_internal+/[__htmltopdf]+/audit-log.tsx b/apps/remix/app/routes/_internal+/[__htmltopdf]+/audit-log.tsx index bb9d62c9a..d2f625bbc 100644 --- a/apps/remix/app/routes/_internal+/[__htmltopdf]+/audit-log.tsx +++ b/apps/remix/app/routes/_internal+/[__htmltopdf]+/audit-log.tsx @@ -12,10 +12,17 @@ import { findDocumentAuditLogs } from '@documenso/lib/server-only/document/find- import { getTranslations } from '@documenso/lib/utils/i18n'; import { Card, CardContent } from '@documenso/ui/primitives/card'; +import appStylesheet from '~/app.css?url'; import { BrandingLogo } from '~/components/general/branding-logo'; import { InternalAuditLogTable } from '~/components/tables/internal-audit-log-table'; import type { Route } from './+types/audit-log'; +import auditLogStylesheet from './audit-log.print.css?url'; + +export const links: Route.LinksFunction = () => [ + { rel: 'stylesheet', href: appStylesheet }, + { rel: 'stylesheet', href: auditLogStylesheet }, +]; export async function loader({ request }: Route.LoaderArgs) { const d = new URL(request.url).searchParams.get('d'); @@ -76,8 +83,8 @@ export default function AuditLog({ loaderData }: Route.ComponentProps) { return (
-
-

{_(msg`Version History`)}

+
+

{_(msg`Audit Log`)}

@@ -157,11 +164,9 @@ export default function AuditLog({ loaderData }: Route.ComponentProps) { - - - - - +
+ +
diff --git a/packages/lib/jobs/definitions/internal/seal-document.handler.ts b/packages/lib/jobs/definitions/internal/seal-document.handler.ts index edae27977..8ee0382ba 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 { getAuditLogsPdf } from '../../../server-only/htmltopdf/get-audit-logs-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'; @@ -145,7 +146,24 @@ export const run = async ({ ? await getCertificatePdf({ documentId, language: document.documentMeta?.language, - }).catch(() => null) + }).catch((e) => { + console.log('Failed to get certificate PDF'); + console.error(e); + + return null; + }) + : null; + + const auditLogData = settings.includeAuditLog + ? await getAuditLogsPdf({ + documentId, + language: document.documentMeta?.language, + }).catch((e) => { + console.log('Failed to get audit logs PDF'); + console.error(e); + + return null; + }) : null; const newDataId = await io.runTask('decorate-and-sign-pdf', async () => { @@ -174,6 +192,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/create-document-v2.ts b/packages/lib/server-only/document/create-document-v2.ts index 419bc8935..a381fd238 100644 --- a/packages/lib/server-only/document/create-document-v2.ts +++ b/packages/lib/server-only/document/create-document-v2.ts @@ -212,7 +212,7 @@ export const createDocumentV2 = async ({ }), ); - // Todo: Is it necessary to create a full audit log with all fields and recipients audit logs? + // Todo: Is it necessary to create a full audit logs with all fields and recipients audit logs? await tx.documentAuditLog.create({ data: createDocumentAuditLogData({ diff --git a/packages/lib/server-only/document/seal-document.ts b/packages/lib/server-only/document/seal-document.ts index ae7a851ee..6db44eb73 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 { getAuditLogsPdf } from '../htmltopdf/get-audit-logs-pdf'; import { getCertificatePdf } from '../htmltopdf/get-certificate-pdf'; import { addRejectionStampToPdf } from '../pdf/add-rejection-stamp-to-pdf'; import { flattenAnnotations } from '../pdf/flatten-annotations'; @@ -125,6 +126,18 @@ export const sealDocument = async ({ }) : null; + const auditLogData = settings.includeAuditLog + ? await getAuditLogsPdf({ + documentId, + language: document.documentMeta?.language, + }).catch((e) => { + console.log('Failed to get audit logs PDF'); + console.error(e); + + return null; + }) + : null; + const doc = await PDFDocument.load(pdfData); // Normalize and flatten layers that could cause issues with the signature @@ -147,6 +160,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-logs-pdf.ts b/packages/lib/server-only/htmltopdf/get-audit-logs-pdf.ts new file mode 100644 index 000000000..36465ff4c --- /dev/null +++ b/packages/lib/server-only/htmltopdf/get-audit-logs-pdf.ts @@ -0,0 +1,83 @@ +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 GetAuditLogsPdfOptions = { + documentId: number; + // eslint-disable-next-line @typescript-eslint/ban-types + language?: SupportedLanguageCodes | (string & {}); +}; + +export const getAuditLogsPdf = async ({ documentId, language }: GetAuditLogsPdfOptions) => { + 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, + }); + + // !: This is a workaround to ensure the page is loaded correctly. + // !: It's not clear why but suddenly browserless cdp connections would + // !: cause the page to render blank until a reload is performed. + await page.reload({ + waitUntil: 'networkidle', + timeout: 10_000, + }); + + await page.waitForSelector('h1', { + state: 'visible', + timeout: 10_000, + }); + + const result = await page.pdf({ + format: 'A4', + printBackground: true, + }); + + await browserContext.close(); + + void browser.close(); + + return result; +}; diff --git a/packages/lib/server-only/htmltopdf/get-certificate-pdf.ts b/packages/lib/server-only/htmltopdf/get-certificate-pdf.ts index 09bf07232..19444e041 100644 --- a/packages/lib/server-only/htmltopdf/get-certificate-pdf.ts +++ b/packages/lib/server-only/htmltopdf/get-certificate-pdf.ts @@ -72,6 +72,7 @@ export const getCertificatePdf = async ({ documentId, language }: GetCertificate const result = await page.pdf({ format: 'A4', + printBackground: true, }); await browserContext.close(); diff --git a/packages/lib/utils/organisations.ts b/packages/lib/utils/organisations.ts index f8a41c5f8..01bcc490d 100644 --- a/packages/lib/utils/organisations.ts +++ b/packages/lib/utils/organisations.ts @@ -120,6 +120,7 @@ export const generateDefaultOrganisationSettings = (): Omit< includeSenderDetails: true, includeSigningCertificate: true, + includeAuditLog: false, typedSignatureEnabled: true, uploadSignatureEnabled: true, diff --git a/packages/lib/utils/teams.ts b/packages/lib/utils/teams.ts index ecc8006fe..3665baf33 100644 --- a/packages/lib/utils/teams.ts +++ b/packages/lib/utils/teams.ts @@ -170,6 +170,7 @@ export const generateDefaultTeamSettings = (): Omit