From 110f9bae12fe610dd4235e92457119f9d144d60d Mon Sep 17 00:00:00 2001 From: Mythie Date: Wed, 10 Apr 2024 15:13:18 +0700 Subject: [PATCH] feat: add certificate and audit log pdfs --- .../[id]/logs/document-logs-page-view.tsx | 15 +- .../[id]/logs/download-audit-log-button.tsx | 61 ++++ .../[id]/logs/download-certificate-button.tsx | 65 ++++ .../%5F%5Fhtmltopdf/audit-log/data-table.tsx | 89 +++++ .../%5F%5Fhtmltopdf/audit-log/page.tsx | 141 ++++++++ .../%5F%5Fhtmltopdf/certificate/page.tsx | 315 ++++++++++++++++++ package-lock.json | 42 +++ packages/lib/constants/recipient-roles.ts | 7 + packages/lib/package.json | 3 +- .../server-only/admin/get-entire-document.ts | 8 + .../get-document-certificate-audit-logs.ts | 43 +++ .../lib/server-only/document/seal-document.ts | 11 + .../htmltopdf/get-certificate-pdf.ts | 45 +++ packages/tailwind-config/index.cjs | 3 + .../trpc/server/document-router/router.ts | 66 ++++ .../trpc/server/document-router/schema.ts | 5 + packages/tsconfig/process-env.d.ts | 3 + packages/ui/primitives/table.tsx | 29 +- packages/ui/styles/theme.css | 15 + turbo.json | 24 +- 20 files changed, 963 insertions(+), 27 deletions(-) create mode 100644 apps/web/src/app/(dashboard)/documents/[id]/logs/download-audit-log-button.tsx create mode 100644 apps/web/src/app/(dashboard)/documents/[id]/logs/download-certificate-button.tsx create mode 100644 apps/web/src/app/(internal)/%5F%5Fhtmltopdf/audit-log/data-table.tsx create mode 100644 apps/web/src/app/(internal)/%5F%5Fhtmltopdf/audit-log/page.tsx create mode 100644 apps/web/src/app/(internal)/%5F%5Fhtmltopdf/certificate/page.tsx create mode 100644 packages/lib/server-only/document/get-document-certificate-audit-logs.ts create mode 100644 packages/lib/server-only/htmltopdf/get-certificate-pdf.ts diff --git a/apps/web/src/app/(dashboard)/documents/[id]/logs/document-logs-page-view.tsx b/apps/web/src/app/(dashboard)/documents/[id]/logs/document-logs-page-view.tsx index 33d6cb8fe..2d786b9c9 100644 --- a/apps/web/src/app/(dashboard)/documents/[id]/logs/document-logs-page-view.tsx +++ b/apps/web/src/app/(dashboard)/documents/[id]/logs/document-logs-page-view.tsx @@ -1,7 +1,7 @@ import Link from 'next/link'; import { redirect } from 'next/navigation'; -import { ChevronLeft, DownloadIcon } from 'lucide-react'; +import { ChevronLeft } from 'lucide-react'; import { DateTime } from 'luxon'; import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session'; @@ -10,7 +10,6 @@ import { getLocale } from '@documenso/lib/server-only/headers/get-locale'; import { getRecipientsForDocument } from '@documenso/lib/server-only/recipient/get-recipients-for-document'; import { formatDocumentsPath } from '@documenso/lib/utils/teams'; import type { Recipient, Team } from '@documenso/prisma/client'; -import { Button } from '@documenso/ui/primitives/button'; import { Card } from '@documenso/ui/primitives/card'; import { @@ -19,6 +18,8 @@ import { } from '~/components/formatter/document-status'; import { DocumentLogsDataTable } from './document-logs-data-table'; +import { DownloadAuditLogButton } from './download-audit-log-button'; +import { DownloadCertificateButton } from './download-certificate-button'; export type DocumentLogsPageViewProps = { params: { @@ -132,15 +133,9 @@ export const DocumentLogsPageView = async ({ params, team }: DocumentLogsPageVie
- + - +
diff --git a/apps/web/src/app/(dashboard)/documents/[id]/logs/download-audit-log-button.tsx b/apps/web/src/app/(dashboard)/documents/[id]/logs/download-audit-log-button.tsx new file mode 100644 index 000000000..fce4d4855 --- /dev/null +++ b/apps/web/src/app/(dashboard)/documents/[id]/logs/download-audit-log-button.tsx @@ -0,0 +1,61 @@ +'use client'; + +import { DownloadIcon } from 'lucide-react'; + +import { trpc } from '@documenso/trpc/react'; +import { cn } from '@documenso/ui/lib/utils'; +import { Button } from '@documenso/ui/primitives/button'; + +export type DownloadAuditLogButtonProps = { + className?: string; + documentId: number; +}; + +export const DownloadAuditLogButton = ({ className, documentId }: DownloadAuditLogButtonProps) => { + const { mutateAsync: downloadAuditLogs, isLoading } = + trpc.document.downloadAuditLogs.useMutation(); + + const onDownloadAuditLogsClick = async () => { + const { url } = await downloadAuditLogs({ documentId }); + + const iframe = Object.assign(document.createElement('iframe'), { + src: url, + }); + + Object.assign(iframe.style, { + position: 'fixed', + top: '0', + left: '0', + width: '0', + height: '0', + }); + + const onLoaded = () => { + if (iframe.contentDocument?.readyState === 'complete') { + iframe.contentWindow?.print(); + + iframe.contentWindow?.addEventListener('afterprint', () => { + document.body.removeChild(iframe); + }); + } + }; + + // When the iframe has loaded, print the iframe and remove it from the dom + iframe.addEventListener('load', onLoaded); + + document.body.appendChild(iframe); + + onLoaded(); + }; + + return ( + + ); +}; diff --git a/apps/web/src/app/(dashboard)/documents/[id]/logs/download-certificate-button.tsx b/apps/web/src/app/(dashboard)/documents/[id]/logs/download-certificate-button.tsx new file mode 100644 index 000000000..e0ae395b4 --- /dev/null +++ b/apps/web/src/app/(dashboard)/documents/[id]/logs/download-certificate-button.tsx @@ -0,0 +1,65 @@ +'use client'; + +import { DownloadIcon } from 'lucide-react'; + +import { trpc } from '@documenso/trpc/react'; +import { cn } from '@documenso/ui/lib/utils'; +import { Button } from '@documenso/ui/primitives/button'; + +export type DownloadCertificateButtonProps = { + className?: string; + documentId: number; +}; + +export const DownloadCertificateButton = ({ + className, + documentId, +}: DownloadCertificateButtonProps) => { + const { mutateAsync: downloadCertificate, isLoading } = + trpc.document.downloadCertificate.useMutation(); + + const onDownloadCertificatesClick = async () => { + const { url } = await downloadCertificate({ documentId }); + + const iframe = Object.assign(document.createElement('iframe'), { + src: url, + }); + + Object.assign(iframe.style, { + position: 'fixed', + top: '0', + left: '0', + width: '0', + height: '0', + }); + + const onLoaded = () => { + if (iframe.contentDocument?.readyState === 'complete') { + iframe.contentWindow?.print(); + + iframe.contentWindow?.addEventListener('afterprint', () => { + document.body.removeChild(iframe); + }); + } + }; + + // When the iframe has loaded, print the iframe and remove it from the dom + iframe.addEventListener('load', onLoaded); + + document.body.appendChild(iframe); + + onLoaded(); + }; + + return ( + + ); +}; diff --git a/apps/web/src/app/(internal)/%5F%5Fhtmltopdf/audit-log/data-table.tsx b/apps/web/src/app/(internal)/%5F%5Fhtmltopdf/audit-log/data-table.tsx new file mode 100644 index 000000000..016a64fbb --- /dev/null +++ b/apps/web/src/app/(internal)/%5F%5Fhtmltopdf/audit-log/data-table.tsx @@ -0,0 +1,89 @@ +'use client'; + +import { DateTime } from 'luxon'; +import type { DateTimeFormatOptions } from 'luxon'; +import { UAParser } from 'ua-parser-js'; + +import type { TDocumentAuditLog } from '@documenso/lib/types/document-audit-logs'; +import { formatDocumentAuditLogAction } from '@documenso/lib/utils/document-audit-logs'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@documenso/ui/primitives/table'; + +import { LocaleDate } from '~/components/formatter/locale-date'; + +export type AuditLogDataTableProps = { + logs: TDocumentAuditLog[]; +}; + +const dateFormat: DateTimeFormatOptions = { + ...DateTime.DATETIME_SHORT, + hourCycle: 'h12', +}; + +export const AuditLogDataTable = ({ logs }: AuditLogDataTableProps) => { + const parser = new UAParser(); + + const uppercaseFistLetter = (text: string) => { + return text.charAt(0).toUpperCase() + text.slice(1); + }; + + return ( + + + + Time + User + Action + IP Address + Browser + + + + + {logs.map((log, i) => ( + + + + + + + {log.name || log.email ? ( +
+ {log.name && ( +

+ {log.name} +

+ )} + + {log.email && ( +

+ {log.email} +

+ )} +
+ ) : ( +

N/A

+ )} +
+ + + {uppercaseFistLetter(formatDocumentAuditLogAction(log).description)} + + + {log.ipAddress} + + + {log.userAgent ? parser.setUA(log.userAgent).getBrowser().name : 'N/A'} + +
+ ))} +
+
+ ); +}; diff --git a/apps/web/src/app/(internal)/%5F%5Fhtmltopdf/audit-log/page.tsx b/apps/web/src/app/(internal)/%5F%5Fhtmltopdf/audit-log/page.tsx new file mode 100644 index 000000000..c3bc94789 --- /dev/null +++ b/apps/web/src/app/(internal)/%5F%5Fhtmltopdf/audit-log/page.tsx @@ -0,0 +1,141 @@ +import React from 'react'; + +import { redirect } from 'next/navigation'; + +import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles'; +import { getEntireDocument } from '@documenso/lib/server-only/admin/get-entire-document'; +import { decryptSecondaryData } from '@documenso/lib/server-only/crypto/decrypt'; +import { findDocumentAuditLogs } from '@documenso/lib/server-only/document/find-document-audit-logs'; +import { Card, CardContent } from '@documenso/ui/primitives/card'; + +import { Logo } from '~/components/branding/logo'; +import { LocaleDate } from '~/components/formatter/locale-date'; + +import { AuditLogDataTable } from './data-table'; + +type AuditLogProps = { + searchParams: { + d: string; + }; +}; + +export default async function AuditLog({ searchParams }: AuditLogProps) { + const { d } = searchParams; + + if (typeof d !== 'string' || !d) { + // return redirect('/'); + } + + let rawDocumentId = decryptSecondaryData(d); + + if (!rawDocumentId || isNaN(Number(rawDocumentId))) { + // return redirect('/'); + + rawDocumentId = '31'; + } + + const documentId = Number(rawDocumentId); + + const document = await getEntireDocument({ + id: documentId, + }).catch(() => null); + + if (!document) { + return redirect('/'); + } + + const { data: auditLogs } = await findDocumentAuditLogs({ + documentId: documentId, + userId: document.userId, + perPage: 100_000, + }); + + return ( +
+
+

Version History

+
+ + + +

+ Document ID + + {document.id} +

+ +

+ Enclosed Document + + {document.title} +

+ +

+ Status + + {document.deletedAt ? 'DELETED' : document.status} +

+ +

+ Owner + + + {document.User.name} ({document.User.email}) + +

+ +

+ Created At + + + + +

+ +

+ Last Updated + + + + +

+ +

+ Time Zone + + + {document.documentMeta?.timezone ?? 'N/A'} + +

+ +
+

Recipients

+ +
    + {document.Recipient.map((recipient) => ( +
  • + + [{RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName}] + {' '} + {recipient.name} ({recipient.email}) +
  • + ))} +
+
+
+
+ + + + + + + +
+
+ +
+
+
+ ); +} diff --git a/apps/web/src/app/(internal)/%5F%5Fhtmltopdf/certificate/page.tsx b/apps/web/src/app/(internal)/%5F%5Fhtmltopdf/certificate/page.tsx new file mode 100644 index 000000000..33675f325 --- /dev/null +++ b/apps/web/src/app/(internal)/%5F%5Fhtmltopdf/certificate/page.tsx @@ -0,0 +1,315 @@ +import React from 'react'; + +import { redirect } from 'next/navigation'; + +import { UAParser } from 'ua-parser-js'; + +import { + RECIPIENT_ROLES_DESCRIPTION, + RECIPIENT_ROLE_SIGNING_REASONS, +} from '@documenso/lib/constants/recipient-roles'; +import { getEntireDocument } from '@documenso/lib/server-only/admin/get-entire-document'; +import { decryptSecondaryData } from '@documenso/lib/server-only/crypto/decrypt'; +import { getDocumentCertificateAuditLogs } from '@documenso/lib/server-only/document/get-document-certificate-audit-logs'; +import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs'; +import { + ZDocumentAuthOptionsSchema, + ZRecipientAuthOptionsSchema, +} from '@documenso/lib/types/document-auth'; +import { FieldType } from '@documenso/prisma/client'; +import { Card, CardContent } from '@documenso/ui/primitives/card'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@documenso/ui/primitives/table'; + +import { Logo } from '~/components/branding/logo'; +import { LocaleDate } from '~/components/formatter/locale-date'; + +type SigningCertificateProps = { + searchParams: { + d: string; + }; +}; + +const FRIENDLY_SIGNING_REASONS = { + ['__OWNER__']: 'I am the owner of this document', + ...RECIPIENT_ROLE_SIGNING_REASONS, +}; + +export default async function SigningCertificate({ searchParams }: SigningCertificateProps) { + const { d } = searchParams; + + if (typeof d !== 'string' || !d) { + // return redirect('/'); + } + + let rawDocumentId = decryptSecondaryData(d); + + if (!rawDocumentId || isNaN(Number(rawDocumentId))) { + // return redirect('/'); + + rawDocumentId = '31'; + } + + const documentId = Number(rawDocumentId); + + const document = await getEntireDocument({ + id: documentId, + }).catch(() => null); + + if (!document) { + return redirect('/'); + } + + const auditLogs = await getDocumentCertificateAuditLogs({ + id: documentId, + }); + + const isOwner = (email: string) => { + return email.toLowerCase() === document.User.email.toLowerCase(); + }; + + const getDevice = (userAgent?: string | null) => { + if (!userAgent) { + return 'Unknown'; + } + + const parser = new UAParser(userAgent); + + parser.setUA(userAgent); + + const result = parser.getResult(); + + return `${result.os.name} - ${result.browser.name} ${result.browser.version}`; + }; + + const getAuthenticationLevel = (recipientId: number) => { + const recipient = document.Recipient.find((recipient) => recipient.id === recipientId); + + if (!recipient) { + return 'Unknown'; + } + + const documentAuthOptions = ZDocumentAuthOptionsSchema.parse(document.authOptions); + const recipientAuthOptions = ZRecipientAuthOptionsSchema.parse(recipient.authOptions); + + let authLevel = 'Email'; + + if ( + documentAuthOptions.globalAccessAuth === 'ACCOUNT' || + recipientAuthOptions.accessAuth === 'ACCOUNT' + ) { + authLevel = 'Account Authentication'; + } + + if ( + documentAuthOptions.globalActionAuth === 'ACCOUNT' || + recipientAuthOptions.actionAuth === 'ACCOUNT' + ) { + authLevel = 'Account Re-Authentication'; + } + + if ( + documentAuthOptions.globalActionAuth === 'TWO_FACTOR_AUTH' || + recipientAuthOptions.actionAuth === 'TWO_FACTOR_AUTH' + ) { + authLevel = 'Two Factor Re-Authentication'; + } + + if ( + documentAuthOptions.globalActionAuth === 'PASSKEY' || + recipientAuthOptions.actionAuth === 'PASSKEY' + ) { + authLevel = 'Passkey Re-Authentication'; + } + + if (recipientAuthOptions.actionAuth === 'EXPLICIT_NONE') { + authLevel = 'Email'; + } + + return authLevel; + }; + + const getRecipientAuditLogs = (recipientId: number) => { + return { + [DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT]: auditLogs[DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT].filter( + (log) => + log.type === DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT && log.data.recipientId === recipientId, + ), + [DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_OPENED]: auditLogs[ + DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_OPENED + ].filter( + (log) => + log.type === DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_OPENED && + log.data.recipientId === recipientId, + ), + [DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_COMPLETED]: auditLogs[ + DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_COMPLETED + ].filter( + (log) => + log.type === DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_COMPLETED && + log.data.recipientId === recipientId, + ), + }; + }; + + const getRecipientSignatureField = (recipientId: number) => { + return document.Recipient.find((recipient) => recipient.id === recipientId)?.Field.find( + (field) => field.type === FieldType.SIGNATURE || field.type === FieldType.FREE_SIGNATURE, + ); + }; + + return ( +
+
+

Signing Certificate

+
+ + + + + + + Signer Events + Signature + Details + {/* Security */} + + + + + {document.Recipient.map((recipient, i) => { + const logs = getRecipientAuditLogs(recipient.id); + const signature = getRecipientSignatureField(recipient.id); + + return ( + + +
{recipient.name}
+
{recipient.email}
+

+ {RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName} +

+ +

+ Authentication Level:{' '} + {getAuthenticationLevel(recipient.id)} +

+
+ + + {signature ? ( + <> +
+ Signature +
+ +

+ Signature ID:{' '} + + {signature.secondaryId} + +

+ +

+ IP Address:{' '} + + {logs.DOCUMENT_RECIPIENT_COMPLETED[0].ipAddress} + +

+ +

+ Device:{' '} + + {getDevice(logs.DOCUMENT_RECIPIENT_COMPLETED[0].userAgent)} + +

+ + ) : ( +

N/A

+ )} +
+ + +
+

+ Sent:{' '} + + + +

+ +

+ Viewed:{' '} + + + +

+ +

+ Signed:{' '} + + + +

+ +

+ Reason:{' '} + + {isOwner(recipient.email) + ? FRIENDLY_SIGNING_REASONS['__OWNER__'] + : FRIENDLY_SIGNING_REASONS[recipient.role]} + +

+
+
+ + {/* +

+ Authentication: {''} +

+

IP: {''}

+
*/} +
+ ); + })} +
+
+
+
+ +
+
+

+ Signing certificate provided by: +

+ + +
+
+
+ ); +} diff --git a/package-lock.json b/package-lock.json index 9eb4d3818..e305355ef 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24926,6 +24926,7 @@ "next-auth": "4.24.5", "oslo": "^0.17.0", "pdf-lib": "^1.17.1", + "playwright": "^1.43.0", "react": "18.2.0", "remeda": "^1.27.1", "stripe": "^12.7.0", @@ -24936,6 +24937,19 @@ "@types/luxon": "^3.3.1" } }, + "packages/lib/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "packages/lib/node_modules/nanoid": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-4.0.2.tgz", @@ -24953,6 +24967,34 @@ "node": "^14 || ^16 || >=18" } }, + "packages/lib/node_modules/playwright": { + "version": "1.43.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.43.0.tgz", + "integrity": "sha512-SiOKHbVjTSf6wHuGCbqrEyzlm6qvXcv7mENP+OZon1I07brfZLGdfWV0l/efAzVx7TF3Z45ov1gPEkku9q25YQ==", + "dependencies": { + "playwright-core": "1.43.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=16" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "packages/lib/node_modules/playwright-core": { + "version": "1.43.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.43.0.tgz", + "integrity": "sha512-iWFjyBUH97+pUFiyTqSLd8cDMMOS0r2ZYz2qEsPjH8/bX++sbIJT35MSwKnp1r/OQBAqC5XO99xFbJ9XClhf4w==", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=16" + } + }, "packages/prettier-config": { "name": "@documenso/prettier-config", "version": "0.0.0", diff --git a/packages/lib/constants/recipient-roles.ts b/packages/lib/constants/recipient-roles.ts index ce1037dd9..59af9b3b5 100644 --- a/packages/lib/constants/recipient-roles.ts +++ b/packages/lib/constants/recipient-roles.ts @@ -32,3 +32,10 @@ export const RECIPIENT_ROLE_TO_EMAIL_TYPE = { [RecipientRole.VIEWER]: 'VIEW_REQUEST', [RecipientRole.APPROVER]: 'APPROVE_REQUEST', } as const; + +export const RECIPIENT_ROLE_SIGNING_REASONS = { + [RecipientRole.SIGNER]: 'I am a signer of this document', + [RecipientRole.APPROVER]: 'I am an approver of this document', + [RecipientRole.CC]: 'I am required to recieve a copy of this document', + [RecipientRole.VIEWER]: 'I am a viewer of this document', +} satisfies Record; diff --git a/packages/lib/package.json b/packages/lib/package.json index 7a32b3058..616e391d0 100644 --- a/packages/lib/package.json +++ b/packages/lib/package.json @@ -39,6 +39,7 @@ "next-auth": "4.24.5", "oslo": "^0.17.0", "pdf-lib": "^1.17.1", + "playwright": "^1.43.0", "react": "18.2.0", "remeda": "^1.27.1", "stripe": "^12.7.0", @@ -48,4 +49,4 @@ "devDependencies": { "@types/luxon": "^3.3.1" } -} +} \ No newline at end of file diff --git a/packages/lib/server-only/admin/get-entire-document.ts b/packages/lib/server-only/admin/get-entire-document.ts index e74ee4c7b..8b7650d7b 100644 --- a/packages/lib/server-only/admin/get-entire-document.ts +++ b/packages/lib/server-only/admin/get-entire-document.ts @@ -10,6 +10,14 @@ export const getEntireDocument = async ({ id }: GetEntireDocumentOptions) => { id, }, include: { + documentMeta: true, + User: { + select: { + id: true, + name: true, + email: true, + }, + }, Recipient: { include: { Field: { diff --git a/packages/lib/server-only/document/get-document-certificate-audit-logs.ts b/packages/lib/server-only/document/get-document-certificate-audit-logs.ts new file mode 100644 index 000000000..e517a4608 --- /dev/null +++ b/packages/lib/server-only/document/get-document-certificate-audit-logs.ts @@ -0,0 +1,43 @@ +import { prisma } from '@documenso/prisma'; + +import { DOCUMENT_AUDIT_LOG_TYPE, DOCUMENT_EMAIL_TYPE } from '../../types/document-audit-logs'; +import { parseDocumentAuditLogData } from '../../utils/document-audit-logs'; + +export type GetDocumentCertificateAuditLogsOptions = { + id: number; +}; + +export const getDocumentCertificateAuditLogs = async ({ + id, +}: GetDocumentCertificateAuditLogsOptions) => { + const rawAuditLogs = await prisma.documentAuditLog.findMany({ + where: { + documentId: id, + type: { + in: [ + DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_COMPLETED, + DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_OPENED, + DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT, + ], + }, + }, + }); + + const auditLogs = rawAuditLogs.map((log) => parseDocumentAuditLogData(log)); + + const groupedAuditLogs = { + [DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_COMPLETED]: auditLogs.filter( + (log) => log.type === DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_COMPLETED, + ), + [DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_OPENED]: auditLogs.filter( + (log) => log.type === DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_OPENED, + ), + [DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT]: auditLogs.filter( + (log) => + log.type === DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT && + log.data.emailType !== DOCUMENT_EMAIL_TYPE.DOCUMENT_COMPLETED, + ), + } as const; + + return groupedAuditLogs; +}; diff --git a/packages/lib/server-only/document/seal-document.ts b/packages/lib/server-only/document/seal-document.ts index ec5f93539..3e366dc81 100644 --- a/packages/lib/server-only/document/seal-document.ts +++ b/packages/lib/server-only/document/seal-document.ts @@ -15,6 +15,7 @@ import { signPdf } from '@documenso/signing'; import type { RequestMetadata } from '../../universal/extract-request-metadata'; import { getFile } from '../../universal/upload/get-file'; import { putFile } from '../../universal/upload/put-file'; +import { getCertificatePdf } from '../htmltopdf/get-certificate-pdf'; import { flattenAnnotations } from '../pdf/flatten-annotations'; import { insertFieldInPDF } from '../pdf/insert-field-in-pdf'; import { normalizeSignatureAppearances } from '../pdf/normalize-signature-appearances'; @@ -91,6 +92,10 @@ export const sealDocument = async ({ // !: Need to write the fields onto the document as a hard copy const pdfData = await getFile(documentData); + const certificate = await getCertificatePdf({ documentId }).then(async (doc) => + PDFDocument.load(doc), + ); + const doc = await PDFDocument.load(pdfData); // Normalize and flatten layers that could cause issues with the signature @@ -98,6 +103,12 @@ export const sealDocument = async ({ doc.getForm().flatten(); flattenAnnotations(doc); + const certificatePages = await doc.copyPages(certificate, certificate.getPageIndices()); + + certificatePages.forEach((page) => { + doc.addPage(page); + }); + for (const field of fields) { await insertFieldInPDF(doc, field); } diff --git a/packages/lib/server-only/htmltopdf/get-certificate-pdf.ts b/packages/lib/server-only/htmltopdf/get-certificate-pdf.ts new file mode 100644 index 000000000..a7182410e --- /dev/null +++ b/packages/lib/server-only/htmltopdf/get-certificate-pdf.ts @@ -0,0 +1,45 @@ +import { DateTime } from 'luxon'; +import type { Browser } from 'playwright'; +import { chromium } from 'playwright'; + +import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app'; +import { encryptSecondaryData } from '../crypto/encrypt'; + +export type GetCertificatePdfOptions = { + documentId: number; +}; + +export const getCertificatePdf = async ({ documentId }: GetCertificatePdfOptions) => { + const encryptedId = encryptSecondaryData({ + data: documentId.toString(), + expiresAt: DateTime.now().plus({ minutes: 5 }).toJSDate().valueOf(), + }); + + let browser: Browser; + + if (process.env.NEXT_PRIVATE_BROWSERLESS_URL) { + browser = await chromium.connect(process.env.NEXT_PRIVATE_BROWSERLESS_URL); + } 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 page = await browser.newPage(); + + await page.goto(`${NEXT_PUBLIC_WEBAPP_URL()}/__htmltopdf/certificate?d=${encryptedId}`, { + waitUntil: 'networkidle', + }); + + const result = await page.pdf({ + format: 'A4', + }); + + void browser.close(); + + return result; +}; diff --git a/packages/tailwind-config/index.cjs b/packages/tailwind-config/index.cjs index 92222462f..01e7296d3 100644 --- a/packages/tailwind-config/index.cjs +++ b/packages/tailwind-config/index.cjs @@ -7,6 +7,9 @@ module.exports = { content: ['src/**/*.{ts,tsx}'], theme: { extend: { + screens: { + print: { raw: 'print' }, + }, fontFamily: { sans: ['var(--font-sans)', ...fontFamily.sans], signature: ['var(--font-signature)'], diff --git a/packages/trpc/server/document-router/router.ts b/packages/trpc/server/document-router/router.ts index 6e7e8764f..3cc61bef2 100644 --- a/packages/trpc/server/document-router/router.ts +++ b/packages/trpc/server/document-router/router.ts @@ -1,7 +1,10 @@ import { TRPCError } from '@trpc/server'; +import { DateTime } from 'luxon'; import { getServerLimits } from '@documenso/ee/server-only/limits/server'; +import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app'; import { DOCUMENSO_ENCRYPTION_KEY } from '@documenso/lib/constants/crypto'; +import { encryptSecondaryData } from '@documenso/lib/server-only/crypto/encrypt'; import { upsertDocumentMeta } from '@documenso/lib/server-only/document-meta/upsert-document-meta'; import { createDocument } from '@documenso/lib/server-only/document/create-document'; import { deleteDocument } from '@documenso/lib/server-only/document/delete-document'; @@ -22,6 +25,7 @@ import { authenticatedProcedure, procedure, router } from '../trpc'; import { ZCreateDocumentMutationSchema, ZDeleteDraftDocumentMutationSchema as ZDeleteDocumentMutationSchema, + ZDownloadAuditLogsMutationSchema, ZFindDocumentAuditLogsQuerySchema, ZGetDocumentByIdQuerySchema, ZGetDocumentByTokenQuerySchema, @@ -364,4 +368,66 @@ export const documentRouter = router({ }); } }), + + downloadAuditLogs: authenticatedProcedure + .input(ZDownloadAuditLogsMutationSchema) + .mutation(async ({ input, ctx }) => { + try { + const { documentId, teamId } = input; + + const document = await getDocumentById({ + id: documentId, + userId: ctx.user.id, + teamId, + }); + + const encrypted = encryptSecondaryData({ + data: document.id.toString(), + expiresAt: DateTime.now().plus({ minutes: 5 }).toJSDate().valueOf(), + }); + + return { + url: `${NEXT_PUBLIC_WEBAPP_URL()}/__htmltopdf/audit-log?d=${encrypted}`, + }; + } catch (err) { + console.error(err); + + throw new TRPCError({ + code: 'BAD_REQUEST', + message: + 'We were unable to download the audit logs for this document. Please try again later.', + }); + } + }), + + downloadCertificate: authenticatedProcedure + .input(ZDownloadAuditLogsMutationSchema) + .mutation(async ({ input, ctx }) => { + try { + const { documentId, teamId } = input; + + const document = await getDocumentById({ + id: documentId, + userId: ctx.user.id, + teamId, + }); + + const encrypted = encryptSecondaryData({ + data: document.id.toString(), + expiresAt: DateTime.now().plus({ minutes: 5 }).toJSDate().valueOf(), + }); + + return { + url: `${NEXT_PUBLIC_WEBAPP_URL()}/__htmltopdf/certificate?d=${encrypted}`, + }; + } catch (err) { + console.error(err); + + throw new TRPCError({ + code: 'BAD_REQUEST', + message: + 'We were unable to download the audit logs for this document. Please try again later.', + }); + } + }), }); diff --git a/packages/trpc/server/document-router/schema.ts b/packages/trpc/server/document-router/schema.ts index 6ed6fcc4d..483d32e50 100644 --- a/packages/trpc/server/document-router/schema.ts +++ b/packages/trpc/server/document-router/schema.ts @@ -163,3 +163,8 @@ export type TDeleteDraftDocumentMutationSchema = z.infer>( - ({ className, ...props }, ref) => ( -
- - - ), -); +const Table = React.forwardRef< + HTMLTableElement, + React.HTMLAttributes & { + overflowHidden?: boolean; + } +>(({ className, overflowHidden, ...props }, ref) => ( +
+
+ +)); Table.displayName = 'Table'; @@ -76,11 +79,17 @@ TableHead.displayName = 'TableHead'; const TableCell = React.forwardRef< HTMLTableCellElement, - React.TdHTMLAttributes ->(({ className, ...props }, ref) => ( + React.TdHTMLAttributes & { + truncate?: boolean; + } +>(({ className, truncate = true, ...props }, ref) => (
)); diff --git a/packages/ui/styles/theme.css b/packages/ui/styles/theme.css index cb2d9d5c5..fa9231e5d 100644 --- a/packages/ui/styles/theme.css +++ b/packages/ui/styles/theme.css @@ -97,6 +97,21 @@ } } +/* + * Custom CSS for printing reports + * - Sets page margins to 0.5 inches + * - Hides the header and footer + * - Hides the print button + * - Sets page size to A4 + * - Sets the font size to 12pt + */ +.print-provider { + @page { + margin: 1in; + size: A4; + } +} + .gradient-border-mask::before { mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0); -webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0); diff --git a/turbo.json b/turbo.json index 6579441be..fa89193eb 100644 --- a/turbo.json +++ b/turbo.json @@ -2,8 +2,13 @@ "$schema": "https://turbo.build/schema.json", "pipeline": { "build": { - "dependsOn": ["^build"], - "outputs": [".next/**", "!.next/cache/**"] + "dependsOn": [ + "^build" + ], + "outputs": [ + ".next/**", + "!.next/cache/**" + ] }, "lint": { "cache": false @@ -19,7 +24,9 @@ "persistent": true }, "start": { - "dependsOn": ["^build"], + "dependsOn": [ + "^build" + ], "cache": false, "persistent": true }, @@ -27,11 +34,15 @@ "cache": false }, "test:e2e": { - "dependsOn": ["^build"], + "dependsOn": [ + "^build" + ], "cache": false } }, - "globalDependencies": ["**/.env.*local"], + "globalDependencies": [ + "**/.env.*local" + ], "globalEnv": [ "APP_VERSION", "NEXT_PRIVATE_ENCRYPTION_KEY", @@ -93,6 +104,7 @@ "NEXT_PRIVATE_STRIPE_API_KEY", "NEXT_PRIVATE_STRIPE_WEBHOOK_SECRET", "NEXT_PRIVATE_GITHUB_TOKEN", + "NEXT_PRIVATE_BROWSERLESS_URL", "CI", "VERCEL", "VERCEL_ENV", @@ -110,4 +122,4 @@ "E2E_TEST_AUTHENTICATE_USER_EMAIL", "E2E_TEST_AUTHENTICATE_USER_PASSWORD" ] -} +} \ No newline at end of file