From 110f9bae12fe610dd4235e92457119f9d144d60d Mon Sep 17 00:00:00 2001 From: Mythie Date: Wed, 10 Apr 2024 15:13:18 +0700 Subject: [PATCH 01/14] 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 From c9b4915fc83cd8fea6f12e842685fa7102939ac7 Mon Sep 17 00:00:00 2001 From: Mythie Date: Wed, 10 Apr 2024 15:30:04 +0700 Subject: [PATCH 02/14] fix: remove hardcoded ids --- .../src/app/(internal)/%5F%5Fhtmltopdf/audit-log/page.tsx | 8 +++----- .../app/(internal)/%5F%5Fhtmltopdf/certificate/page.tsx | 8 +++----- 2 files changed, 6 insertions(+), 10 deletions(-) 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 index c3bc94789..1db089495 100644 --- a/apps/web/src/app/(internal)/%5F%5Fhtmltopdf/audit-log/page.tsx +++ b/apps/web/src/app/(internal)/%5F%5Fhtmltopdf/audit-log/page.tsx @@ -23,15 +23,13 @@ export default async function AuditLog({ searchParams }: AuditLogProps) { const { d } = searchParams; if (typeof d !== 'string' || !d) { - // return redirect('/'); + return redirect('/'); } - let rawDocumentId = decryptSecondaryData(d); + const rawDocumentId = decryptSecondaryData(d); if (!rawDocumentId || isNaN(Number(rawDocumentId))) { - // return redirect('/'); - - rawDocumentId = '31'; + return redirect('/'); } const documentId = Number(rawDocumentId); diff --git a/apps/web/src/app/(internal)/%5F%5Fhtmltopdf/certificate/page.tsx b/apps/web/src/app/(internal)/%5F%5Fhtmltopdf/certificate/page.tsx index 33675f325..690f0eb78 100644 --- a/apps/web/src/app/(internal)/%5F%5Fhtmltopdf/certificate/page.tsx +++ b/apps/web/src/app/(internal)/%5F%5Fhtmltopdf/certificate/page.tsx @@ -45,15 +45,13 @@ export default async function SigningCertificate({ searchParams }: SigningCertif const { d } = searchParams; if (typeof d !== 'string' || !d) { - // return redirect('/'); + return redirect('/'); } - let rawDocumentId = decryptSecondaryData(d); + const rawDocumentId = decryptSecondaryData(d); if (!rawDocumentId || isNaN(Number(rawDocumentId))) { - // return redirect('/'); - - rawDocumentId = '31'; + return redirect('/'); } const documentId = Number(rawDocumentId); From 4d4dfd3c5fa4719baa36a693121ca5d6d208017c Mon Sep 17 00:00:00 2001 From: Mythie Date: Wed, 10 Apr 2024 17:38:34 +0700 Subject: [PATCH 03/14] fix: implement review feedback, resolve build errors --- apps/marketing/next.config.js | 2 +- apps/web/next.config.js | 2 +- .../[id]/logs/download-audit-log-button.tsx | 59 +++++---- .../[id]/logs/download-certificate-button.tsx | 59 +++++---- .../%5F%5Fhtmltopdf/certificate/page.tsx | 42 ++----- package-lock.json | 117 ++++++++++-------- package.json | 3 +- packages/lib/package.json | 3 +- 8 files changed, 155 insertions(+), 132 deletions(-) diff --git a/apps/marketing/next.config.js b/apps/marketing/next.config.js index 0f7b7ad5c..c8c89e45d 100644 --- a/apps/marketing/next.config.js +++ b/apps/marketing/next.config.js @@ -22,7 +22,7 @@ const FONT_CAVEAT_BYTES = fs.readFileSync( const config = { experimental: { outputFileTracingRoot: path.join(__dirname, '../../'), - serverComponentsExternalPackages: ['@node-rs/bcrypt', '@documenso/pdf-sign'], + serverComponentsExternalPackages: ['@node-rs/bcrypt', '@documenso/pdf-sign', 'playwright'], serverActions: { bodySizeLimit: '50mb', }, diff --git a/apps/web/next.config.js b/apps/web/next.config.js index af82847c0..85d3097ca 100644 --- a/apps/web/next.config.js +++ b/apps/web/next.config.js @@ -23,7 +23,7 @@ const config = { output: process.env.DOCKER_OUTPUT ? 'standalone' : undefined, experimental: { outputFileTracingRoot: path.join(__dirname, '../../'), - serverComponentsExternalPackages: ['@node-rs/bcrypt', '@documenso/pdf-sign'], + serverComponentsExternalPackages: ['@node-rs/bcrypt', '@documenso/pdf-sign', 'playwright'], serverActions: { bodySizeLimit: '50mb', }, 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 index fce4d4855..0847d63fa 100644 --- 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 @@ -5,6 +5,7 @@ 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'; +import { useToast } from '@documenso/ui/primitives/use-toast'; export type DownloadAuditLogButtonProps = { className?: string; @@ -12,40 +13,52 @@ export type DownloadAuditLogButtonProps = { }; export const DownloadAuditLogButton = ({ className, documentId }: DownloadAuditLogButtonProps) => { + const { toast } = useToast(); + const { mutateAsync: downloadAuditLogs, isLoading } = trpc.document.downloadAuditLogs.useMutation(); const onDownloadAuditLogsClick = async () => { - const { url } = await downloadAuditLogs({ documentId }); + try { + const { url } = await downloadAuditLogs({ documentId }); - const iframe = Object.assign(document.createElement('iframe'), { - src: url, - }); + const iframe = Object.assign(document.createElement('iframe'), { + src: url, + }); - Object.assign(iframe.style, { - position: 'fixed', - top: '0', - left: '0', - width: '0', - height: '0', - }); + Object.assign(iframe.style, { + position: 'fixed', + top: '0', + left: '0', + width: '0', + height: '0', + }); - const onLoaded = () => { - if (iframe.contentDocument?.readyState === 'complete') { - iframe.contentWindow?.print(); + const onLoaded = () => { + if (iframe.contentDocument?.readyState === 'complete') { + iframe.contentWindow?.print(); - iframe.contentWindow?.addEventListener('afterprint', () => { - document.body.removeChild(iframe); - }); - } - }; + 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); + // When the iframe has loaded, print the iframe and remove it from the dom + iframe.addEventListener('load', onLoaded); - document.body.appendChild(iframe); + document.body.appendChild(iframe); - onLoaded(); + onLoaded(); + } catch (error) { + console.error(error); + + toast({ + title: 'Something went wrong', + description: 'Sorry, we were unable to download the audit logs. Please try again later.', + variant: 'destructive', + }); + } }; 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 index e0ae395b4..49a330b94 100644 --- 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 @@ -5,6 +5,7 @@ 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'; +import { useToast } from '@documenso/ui/primitives/use-toast'; export type DownloadCertificateButtonProps = { className?: string; @@ -15,40 +16,52 @@ export const DownloadCertificateButton = ({ className, documentId, }: DownloadCertificateButtonProps) => { + const { toast } = useToast(); + const { mutateAsync: downloadCertificate, isLoading } = trpc.document.downloadCertificate.useMutation(); const onDownloadCertificatesClick = async () => { - const { url } = await downloadCertificate({ documentId }); + try { + const { url } = await downloadCertificate({ documentId }); - const iframe = Object.assign(document.createElement('iframe'), { - src: url, - }); + const iframe = Object.assign(document.createElement('iframe'), { + src: url, + }); - Object.assign(iframe.style, { - position: 'fixed', - top: '0', - left: '0', - width: '0', - height: '0', - }); + Object.assign(iframe.style, { + position: 'fixed', + top: '0', + left: '0', + width: '0', + height: '0', + }); - const onLoaded = () => { - if (iframe.contentDocument?.readyState === 'complete') { - iframe.contentWindow?.print(); + const onLoaded = () => { + if (iframe.contentDocument?.readyState === 'complete') { + iframe.contentWindow?.print(); - iframe.contentWindow?.addEventListener('afterprint', () => { - document.body.removeChild(iframe); - }); - } - }; + 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); + // When the iframe has loaded, print the iframe and remove it from the dom + iframe.addEventListener('load', onLoaded); - document.body.appendChild(iframe); + document.body.appendChild(iframe); - onLoaded(); + onLoaded(); + } catch (error) { + console.error(error); + + toast({ + title: 'Something went wrong', + description: 'Sorry, we were unable to download the certificate. Please try again later.', + variant: 'destructive', + }); + } }; return ( diff --git a/apps/web/src/app/(internal)/%5F%5Fhtmltopdf/certificate/page.tsx b/apps/web/src/app/(internal)/%5F%5Fhtmltopdf/certificate/page.tsx index 690f0eb78..4924e832b 100644 --- a/apps/web/src/app/(internal)/%5F%5Fhtmltopdf/certificate/page.tsx +++ b/apps/web/src/app/(internal)/%5F%5Fhtmltopdf/certificate/page.tsx @@ -12,10 +12,7 @@ import { getEntireDocument } from '@documenso/lib/server-only/admin/get-entire-d 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 { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth'; import { FieldType } from '@documenso/prisma/client'; import { Card, CardContent } from '@documenso/ui/primitives/card'; import { @@ -93,40 +90,30 @@ export default async function SigningCertificate({ searchParams }: SigningCertif return 'Unknown'; } - const documentAuthOptions = ZDocumentAuthOptionsSchema.parse(document.authOptions); - const recipientAuthOptions = ZRecipientAuthOptionsSchema.parse(recipient.authOptions); + const extractedAuthMethods = extractDocumentAuthMethods({ + documentAuth: document.authOptions, + recipientAuth: recipient.authOptions, + }); let authLevel = 'Email'; - if ( - documentAuthOptions.globalAccessAuth === 'ACCOUNT' || - recipientAuthOptions.accessAuth === 'ACCOUNT' - ) { + if (extractedAuthMethods.derivedRecipientAccessAuth === 'ACCOUNT') { authLevel = 'Account Authentication'; } - if ( - documentAuthOptions.globalActionAuth === 'ACCOUNT' || - recipientAuthOptions.actionAuth === 'ACCOUNT' - ) { + if (extractedAuthMethods.derivedRecipientActionAuth === 'ACCOUNT') { authLevel = 'Account Re-Authentication'; } - if ( - documentAuthOptions.globalActionAuth === 'TWO_FACTOR_AUTH' || - recipientAuthOptions.actionAuth === 'TWO_FACTOR_AUTH' - ) { - authLevel = 'Two Factor Re-Authentication'; + if (extractedAuthMethods.derivedRecipientActionAuth === 'TWO_FACTOR_AUTH') { + authLevel = 'Two-Factor Re-Authentication'; } - if ( - documentAuthOptions.globalActionAuth === 'PASSKEY' || - recipientAuthOptions.actionAuth === 'PASSKEY' - ) { + if (extractedAuthMethods.derivedRecipientActionAuth === 'PASSKEY') { authLevel = 'Passkey Re-Authentication'; } - if (recipientAuthOptions.actionAuth === 'EXPLICIT_NONE') { + if (extractedAuthMethods.derivedRecipientActionAuth === 'EXPLICIT_NONE') { authLevel = 'Email'; } @@ -284,13 +271,6 @@ export default async function SigningCertificate({ searchParams }: SigningCertif

- - {/* -

- Authentication: {''} -

-

IP: {''}

-
*/} ); })} diff --git a/package-lock.json b/package-lock.json index e305355ef..fb03b3a67 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,6 +22,7 @@ "eslint-config-custom": "*", "husky": "^9.0.11", "lint-staged": "^15.2.2", + "playwright": "^1.43.0", "prettier": "^2.5.1", "rimraf": "^5.0.1", "turbo": "^1.9.3" @@ -4701,6 +4702,19 @@ "node": ">=14" } }, + "node_modules/@playwright/browser-chromium": { + "version": "1.43.0", + "resolved": "https://registry.npmjs.org/@playwright/browser-chromium/-/browser-chromium-1.43.0.tgz", + "integrity": "sha512-F0S4KIqSqQqm9EgsdtWjaJRpgP8cD2vWZHPSB41YI00PtXUobiv/3AnYISeL7wNuTanND7giaXQ4SIjkcIq3KQ==", + "dev": true, + "hasInstallScript": true, + "dependencies": { + "playwright-core": "1.43.0" + }, + "engines": { + "node": ">=16" + } + }, "node_modules/@playwright/test": { "version": "1.40.0", "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.40.0.tgz", @@ -4716,6 +4730,50 @@ "node": ">=16" } }, + "node_modules/@playwright/test/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/@playwright/test/node_modules/playwright": { + "version": "1.40.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.40.0.tgz", + "integrity": "sha512-gyHAgQjiDf1m34Xpwzaqb76KgfzYrhK7iih+2IzcOCoZWr/8ZqmdBw+t0RU85ZmfJMgtgAiNtBQ/KS2325INXw==", + "dev": true, + "dependencies": { + "playwright-core": "1.40.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=16" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/@playwright/test/node_modules/playwright-core": { + "version": "1.40.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.40.0.tgz", + "integrity": "sha512-fvKewVJpGeca8t0ipM56jkVSU6Eo0RmFvQ/MaCQNDYm+sdvKkMBBWTE1FdeMqIdumRaXXjZChWHvIzCGM/tA/Q==", + "dev": true, + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=16" + } + }, "node_modules/@prisma/client": { "version": "5.4.2", "resolved": "https://registry.npmjs.org/@prisma/client/-/client-5.4.2.tgz", @@ -17615,12 +17673,11 @@ } }, "node_modules/playwright": { - "version": "1.40.0", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.40.0.tgz", - "integrity": "sha512-gyHAgQjiDf1m34Xpwzaqb76KgfzYrhK7iih+2IzcOCoZWr/8ZqmdBw+t0RU85ZmfJMgtgAiNtBQ/KS2325INXw==", - "dev": true, + "version": "1.43.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.43.0.tgz", + "integrity": "sha512-SiOKHbVjTSf6wHuGCbqrEyzlm6qvXcv7mENP+OZon1I07brfZLGdfWV0l/efAzVx7TF3Z45ov1gPEkku9q25YQ==", "dependencies": { - "playwright-core": "1.40.0" + "playwright-core": "1.43.0" }, "bin": { "playwright": "cli.js" @@ -17633,10 +17690,9 @@ } }, "node_modules/playwright-core": { - "version": "1.40.0", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.40.0.tgz", - "integrity": "sha512-fvKewVJpGeca8t0ipM56jkVSU6Eo0RmFvQ/MaCQNDYm+sdvKkMBBWTE1FdeMqIdumRaXXjZChWHvIzCGM/tA/Q==", - "dev": true, + "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" }, @@ -17648,7 +17704,6 @@ "version": "2.3.2", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", - "dev": true, "hasInstallScript": true, "optional": true, "os": [ @@ -24934,22 +24989,10 @@ "zod": "^3.22.4" }, "devDependencies": { + "@playwright/browser-chromium": "^1.43.0", "@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", @@ -24967,34 +25010,6 @@ "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/package.json b/package.json index bafada07a..396b2ecfd 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,7 @@ "eslint-config-custom": "*", "husky": "^9.0.11", "lint-staged": "^15.2.2", + "playwright": "^1.43.0", "prettier": "^2.5.1", "rimraf": "^5.0.1", "turbo": "^1.9.3" @@ -59,4 +60,4 @@ "next": "14.0.3" } } -} +} \ No newline at end of file diff --git a/packages/lib/package.json b/packages/lib/package.json index 616e391d0..1aa7e431e 100644 --- a/packages/lib/package.json +++ b/packages/lib/package.json @@ -47,6 +47,7 @@ "zod": "^3.22.4" }, "devDependencies": { - "@types/luxon": "^3.3.1" + "@types/luxon": "^3.3.1", + "@playwright/browser-chromium": "^1.43.0" } } \ No newline at end of file From 0bc9c590a7b58818f12b965f43bf1980c93b7b8f Mon Sep 17 00:00:00 2001 From: Mythie Date: Wed, 10 Apr 2024 20:01:27 +0700 Subject: [PATCH 04/14] fix: use ts-match --- .../%5F%5Fhtmltopdf/certificate/page.tsx | 32 ++++++++----------- 1 file changed, 13 insertions(+), 19 deletions(-) diff --git a/apps/web/src/app/(internal)/%5F%5Fhtmltopdf/certificate/page.tsx b/apps/web/src/app/(internal)/%5F%5Fhtmltopdf/certificate/page.tsx index 4924e832b..cbdaa451d 100644 --- a/apps/web/src/app/(internal)/%5F%5Fhtmltopdf/certificate/page.tsx +++ b/apps/web/src/app/(internal)/%5F%5Fhtmltopdf/certificate/page.tsx @@ -2,6 +2,7 @@ import React from 'react'; import { redirect } from 'next/navigation'; +import { match } from 'ts-pattern'; import { UAParser } from 'ua-parser-js'; import { @@ -95,26 +96,19 @@ export default async function SigningCertificate({ searchParams }: SigningCertif recipientAuth: recipient.authOptions, }); - let authLevel = 'Email'; + let authLevel = match(extractedAuthMethods.derivedRecipientActionAuth) + .with('ACCOUNT', () => 'Account Re-Authentication') + .with('TWO_FACTOR_AUTH', () => 'Two-Factor Re-Authentication') + .with('PASSKEY', () => 'Passkey Re-Authentication') + .with('EXPLICIT_NONE', () => 'Email') + .with(null, () => null) + .exhaustive(); - if (extractedAuthMethods.derivedRecipientAccessAuth === 'ACCOUNT') { - authLevel = 'Account Authentication'; - } - - if (extractedAuthMethods.derivedRecipientActionAuth === 'ACCOUNT') { - authLevel = 'Account Re-Authentication'; - } - - if (extractedAuthMethods.derivedRecipientActionAuth === 'TWO_FACTOR_AUTH') { - authLevel = 'Two-Factor Re-Authentication'; - } - - if (extractedAuthMethods.derivedRecipientActionAuth === 'PASSKEY') { - authLevel = 'Passkey Re-Authentication'; - } - - if (extractedAuthMethods.derivedRecipientActionAuth === 'EXPLICIT_NONE') { - authLevel = 'Email'; + if (!authLevel) { + authLevel = match(extractedAuthMethods.derivedRecipientAccessAuth) + .with('ACCOUNT', () => 'Account Authentication') + .with(null, () => 'Email') + .exhaustive(); } return authLevel; From e36763a85dbdd93ba2c9a13b5c2c5ecf016ab3b4 Mon Sep 17 00:00:00 2001 From: Catalin Pit <25515812+catalinpit@users.noreply.github.com> Date: Wed, 10 Apr 2024 16:07:14 +0300 Subject: [PATCH 05/14] feat: update marketing banner (#1095) ![CleanShot 2024-04-10 at 15 51 27](https://github.com/documenso/documenso/assets/25515812/d2ad275c-4e68-42f2-8882-a20129c0b0bd) --- apps/marketing/src/app/(marketing)/layout.tsx | 14 ++------------ 1 file changed, 2 insertions(+), 12 deletions(-) diff --git a/apps/marketing/src/app/(marketing)/layout.tsx b/apps/marketing/src/app/(marketing)/layout.tsx index 75c2d177c..97560b80e 100644 --- a/apps/marketing/src/app/(marketing)/layout.tsx +++ b/apps/marketing/src/app/(marketing)/layout.tsx @@ -2,10 +2,8 @@ import React, { useEffect, useState } from 'react'; -import Image from 'next/image'; import { usePathname } from 'next/navigation'; -import launchWeekTwoImage from '@documenso/assets/images/background-lw-2.png'; import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag'; import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app'; import { cn } from '@documenso/ui/lib/utils'; @@ -48,16 +46,8 @@ export default function MarketingLayout({ children }: MarketingLayoutProps) { })} > {showProfilesAnnouncementBar && ( -
-
- Launch Week 2 -
- -
+
+
Claim your documenso public profile username now!{' '} documenso.com/u/yourname
From 12e4bc918dd8638daa515b04313a9ad5690de691 Mon Sep 17 00:00:00 2001 From: Catalin Pit <25515812+catalinpit@users.noreply.github.com> Date: Wed, 10 Apr 2024 16:30:11 +0300 Subject: [PATCH 06/14] fix: marketing header darkmode (#1096) Co-authored-by: Adithya Krishna --- apps/marketing/src/app/(marketing)/layout.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/marketing/src/app/(marketing)/layout.tsx b/apps/marketing/src/app/(marketing)/layout.tsx index 97560b80e..461ea0cae 100644 --- a/apps/marketing/src/app/(marketing)/layout.tsx +++ b/apps/marketing/src/app/(marketing)/layout.tsx @@ -47,7 +47,7 @@ export default function MarketingLayout({ children }: MarketingLayoutProps) { > {showProfilesAnnouncementBar && (
-
+
Claim your documenso public profile username now!{' '} documenso.com/u/yourname
From f7ae3104ea5594829ddb645ae2c38e0b3e5aefd3 Mon Sep 17 00:00:00 2001 From: Thibault Le Ouay Date: Wed, 10 Apr 2024 17:05:22 +0200 Subject: [PATCH 07/14] fix: status widget rerendering --- .../(marketing)/status-widget-container.tsx | 2 +- .../src/components/(marketing)/status-widget.tsx | 12 +++++------- 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/apps/marketing/src/components/(marketing)/status-widget-container.tsx b/apps/marketing/src/components/(marketing)/status-widget-container.tsx index 025c2df56..71fbec9cb 100644 --- a/apps/marketing/src/components/(marketing)/status-widget-container.tsx +++ b/apps/marketing/src/components/(marketing)/status-widget-container.tsx @@ -6,7 +6,7 @@ import { StatusWidget } from './status-widget'; export function StatusWidgetContainer() { return ( }> - + ); } diff --git a/apps/marketing/src/components/(marketing)/status-widget.tsx b/apps/marketing/src/components/(marketing)/status-widget.tsx index 1c94c0707..0b6b8aaa6 100644 --- a/apps/marketing/src/components/(marketing)/status-widget.tsx +++ b/apps/marketing/src/components/(marketing)/status-widget.tsx @@ -1,7 +1,6 @@ -import { use, useMemo } from 'react'; +import { memo, use } from 'react'; -import type { Status } from '@openstatus/react'; -import { getStatus } from '@openstatus/react'; +import { type Status, getStatus } from '@openstatus/react'; import { cn } from '@documenso/ui/lib/utils'; @@ -45,9 +44,8 @@ const getStatusLevel = (level: Status) => { }[level]; }; -export function StatusWidget() { - const getStatusMemoized = useMemo(async () => getStatus('documenso-status'), []); - const { status } = use(getStatusMemoized); +export const StatusWidget = memo(function StatusWidget({ slug }: { slug: string }) { + const { status } = use(getStatus(slug)); const level = getStatusLevel(status); return ( @@ -72,4 +70,4 @@ export function StatusWidget() { ); -} +}); From 93a149d637c010736e2d46e852518235ecf61ba6 Mon Sep 17 00:00:00 2001 From: Mythie Date: Wed, 10 Apr 2024 22:10:20 +0700 Subject: [PATCH 08/14] fix: handle older cert data --- .../%5F%5Fhtmltopdf/certificate/page.tsx | 32 ++++++++++++------- 1 file changed, 21 insertions(+), 11 deletions(-) diff --git a/apps/web/src/app/(internal)/%5F%5Fhtmltopdf/certificate/page.tsx b/apps/web/src/app/(internal)/%5F%5Fhtmltopdf/certificate/page.tsx index cbdaa451d..d096d1a84 100644 --- a/apps/web/src/app/(internal)/%5F%5Fhtmltopdf/certificate/page.tsx +++ b/apps/web/src/app/(internal)/%5F%5Fhtmltopdf/certificate/page.tsx @@ -207,14 +207,16 @@ export default async function SigningCertificate({ searchParams }: SigningCertif

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

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

@@ -229,7 +231,7 @@ export default async function SigningCertificate({ searchParams }: SigningCertif Sent:{' '} @@ -238,20 +240,28 @@ export default async function SigningCertificate({ searchParams }: SigningCertif

Viewed:{' '} - + {logs.DOCUMENT_OPENED[0] ? ( + + ) : ( + 'Unknown' + )}

Signed:{' '} - + {logs.DOCUMENT_RECIPIENT_COMPLETED[0] ? ( + + ) : ( + 'Unknown' + )}

From bfff1234bb76942a412055c6fbf87faa6a50b15e Mon Sep 17 00:00:00 2001 From: Mythie Date: Wed, 10 Apr 2024 22:32:25 +0700 Subject: [PATCH 09/14] fix: handle older cert data --- .../(internal)/%5F%5Fhtmltopdf/certificate/page.tsx | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/apps/web/src/app/(internal)/%5F%5Fhtmltopdf/certificate/page.tsx b/apps/web/src/app/(internal)/%5F%5Fhtmltopdf/certificate/page.tsx index d096d1a84..5b233e47b 100644 --- a/apps/web/src/app/(internal)/%5F%5Fhtmltopdf/certificate/page.tsx +++ b/apps/web/src/app/(internal)/%5F%5Fhtmltopdf/certificate/page.tsx @@ -230,10 +230,14 @@ export default async function SigningCertificate({ searchParams }: SigningCertif

Sent:{' '} - + {logs.EMAIL_SENT[0] ? ( + + ) : ( + 'Unknown' + )}

From 6f3cea52e8af7d4668df83e49a7085c3f11b3948 Mon Sep 17 00:00:00 2001 From: Mythie Date: Wed, 10 Apr 2024 22:34:14 +0700 Subject: [PATCH 10/14] fix: handle older cert data --- .../src/app/(internal)/%5F%5Fhtmltopdf/certificate/page.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/apps/web/src/app/(internal)/%5F%5Fhtmltopdf/certificate/page.tsx b/apps/web/src/app/(internal)/%5F%5Fhtmltopdf/certificate/page.tsx index 5b233e47b..447e4ad72 100644 --- a/apps/web/src/app/(internal)/%5F%5Fhtmltopdf/certificate/page.tsx +++ b/apps/web/src/app/(internal)/%5F%5Fhtmltopdf/certificate/page.tsx @@ -214,9 +214,7 @@ export default async function SigningCertificate({ searchParams }: SigningCertif

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

From a4967f19e8d8d856a4caac42fb2e7897799cfa09 Mon Sep 17 00:00:00 2001 From: Timur Ercan Date: Wed, 10 Apr 2024 18:22:46 +0200 Subject: [PATCH 11/14] fix: remove status widget for now --- apps/marketing/src/components/(marketing)/footer.tsx | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/apps/marketing/src/components/(marketing)/footer.tsx b/apps/marketing/src/components/(marketing)/footer.tsx index 8d2e0c1d4..550febfb6 100644 --- a/apps/marketing/src/components/(marketing)/footer.tsx +++ b/apps/marketing/src/components/(marketing)/footer.tsx @@ -13,8 +13,6 @@ import LogoImage from '@documenso/assets/logo.png'; import { cn } from '@documenso/ui/lib/utils'; import { ThemeSwitcher } from '@documenso/ui/primitives/theme-switcher'; -import { StatusWidgetContainer } from './status-widget-container'; - export type FooterProps = HTMLAttributes; const SOCIAL_LINKS = [ @@ -65,9 +63,9 @@ export const Footer = ({ className, ...props }: FooterProps) => { ))}
-
+ {/*
-
+
*/}
From a82975fd78535f5794856ee36023d53944418725 Mon Sep 17 00:00:00 2001 From: Timur Ercan Date: Wed, 10 Apr 2024 18:24:32 +0200 Subject: [PATCH 12/14] chore: keep import until fix or complete remove --- apps/marketing/src/components/(marketing)/footer.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/marketing/src/components/(marketing)/footer.tsx b/apps/marketing/src/components/(marketing)/footer.tsx index 550febfb6..e9a08049c 100644 --- a/apps/marketing/src/components/(marketing)/footer.tsx +++ b/apps/marketing/src/components/(marketing)/footer.tsx @@ -13,6 +13,8 @@ import LogoImage from '@documenso/assets/logo.png'; import { cn } from '@documenso/ui/lib/utils'; import { ThemeSwitcher } from '@documenso/ui/primitives/theme-switcher'; +// import { StatusWidgetContainer } from './status-widget-container'; + export type FooterProps = HTMLAttributes; const SOCIAL_LINKS = [ From 8b58f10cbe71ad4f311c66803c36c855b83a9485 Mon Sep 17 00:00:00 2001 From: Catalin Pit <25515812+catalinpit@users.noreply.github.com> Date: Thu, 11 Apr 2024 10:09:04 +0300 Subject: [PATCH 13/14] feat: add cta on complete page (#1028) ![CleanShot 2024-03-18 at 11 45 40](https://github.com/documenso/documenso/assets/25515812/ae3b88de-359d-4019-866a-a76097bbb0fe) ![CleanShot 2024-03-18 at 11 46 25](https://github.com/documenso/documenso/assets/25515812/b5ff7078-623e-476c-8800-17d14bc8efa9) ## Summary by CodeRabbit - **New Features** - Introduced a "Claim Account" feature allowing new users to sign up by providing their name, email, and password. - Enhanced user experience for both logged-in and non-logged-in users with improved UI/UX and additional functionality. - **Enhancements** - Implemented form validation and error handling for a smoother sign-up process. - Integrated analytics to track user actions during account claiming. --------- Co-authored-by: Lucas Smith Co-authored-by: David Nguyen --- .../sign/[token]/complete/claim-account.tsx | 155 +++++++++++++++ .../(signing)/sign/[token]/complete/page.tsx | 186 ++++++++++-------- .../document-flow/stepper-component.spec.ts | 2 +- packages/trpc/server/auth-router/router.ts | 2 +- packages/trpc/server/auth-router/schema.ts | 2 +- 5 files changed, 266 insertions(+), 81 deletions(-) create mode 100644 apps/web/src/app/(signing)/sign/[token]/complete/claim-account.tsx diff --git a/apps/web/src/app/(signing)/sign/[token]/complete/claim-account.tsx b/apps/web/src/app/(signing)/sign/[token]/complete/claim-account.tsx new file mode 100644 index 000000000..b5f7c0ca8 --- /dev/null +++ b/apps/web/src/app/(signing)/sign/[token]/complete/claim-account.tsx @@ -0,0 +1,155 @@ +'use client'; + +import { useRouter } from 'next/navigation'; + +import { zodResolver } from '@hookform/resolvers/zod'; +import { useForm } from 'react-hook-form'; +import { z } from 'zod'; + +import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics'; +import { TRPCClientError } from '@documenso/trpc/client'; +import { trpc } from '@documenso/trpc/react'; +import { ZPasswordSchema } from '@documenso/trpc/server/auth-router/schema'; +import { Button } from '@documenso/ui/primitives/button'; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@documenso/ui/primitives/form/form'; +import { Input } from '@documenso/ui/primitives/input'; +import { PasswordInput } from '@documenso/ui/primitives/password-input'; +import { useToast } from '@documenso/ui/primitives/use-toast'; + +export type ClaimAccountProps = { + defaultName: string; + defaultEmail: string; + trigger?: React.ReactNode; +}; + +export const ZClaimAccountFormSchema = z + .object({ + name: z.string().trim().min(1, { message: 'Please enter a valid name.' }), + email: z.string().email().min(1), + password: ZPasswordSchema, + }) + .refine( + (data) => { + const { name, email, password } = data; + return !password.includes(name) && !password.includes(email.split('@')[0]); + }, + { + message: 'Password should not be common or based on personal information', + path: ['password'], + }, + ); + +export type TClaimAccountFormSchema = z.infer; + +export const ClaimAccount = ({ defaultName, defaultEmail }: ClaimAccountProps) => { + const analytics = useAnalytics(); + const { toast } = useToast(); + const router = useRouter(); + + const { mutateAsync: signup } = trpc.auth.signup.useMutation(); + + const form = useForm({ + values: { + name: defaultName ?? '', + email: defaultEmail, + password: '', + }, + resolver: zodResolver(ZClaimAccountFormSchema), + }); + + const onFormSubmit = async ({ name, email, password }: TClaimAccountFormSchema) => { + try { + await signup({ name, email, password }); + + router.push(`/unverified-account`); + + toast({ + title: 'Registration Successful', + description: + 'You have successfully registered. Please verify your account by clicking on the link you received in the email.', + duration: 5000, + }); + + analytics.capture('App: User Claim Account', { + email, + timestamp: new Date().toISOString(), + }); + } catch (error) { + if (error instanceof TRPCClientError && error.data?.code === 'BAD_REQUEST') { + toast({ + title: 'An error occurred', + description: error.message, + variant: 'destructive', + }); + } else { + toast({ + title: 'An unknown error occurred', + description: + 'We encountered an unknown error while attempting to sign you up. Please try again later.', + variant: 'destructive', + }); + } + } + }; + + return ( +
+
+ +
+ ( + + Name + + + + + + )} + /> + ( + + Email address + + + + + + )} + /> + ( + + Set a password + + + + + + )} + /> + + +
+
+ +
+ ); +}; diff --git a/apps/web/src/app/(signing)/sign/[token]/complete/page.tsx b/apps/web/src/app/(signing)/sign/[token]/complete/page.tsx index c13d8636b..cfed976e5 100644 --- a/apps/web/src/app/(signing)/sign/[token]/complete/page.tsx +++ b/apps/web/src/app/(signing)/sign/[token]/complete/page.tsx @@ -3,6 +3,7 @@ import { notFound } from 'next/navigation'; import { CheckCircle2, Clock8 } from 'lucide-react'; import { getServerSession } from 'next-auth'; +import { env } from 'next-runtime-env'; import { match } from 'ts-pattern'; import signingCelebration from '@documenso/assets/images/signing-celebration.png'; @@ -16,10 +17,13 @@ import { DocumentStatus, FieldType, RecipientRole } from '@documenso/prisma/clie import { DocumentDownloadButton } from '@documenso/ui/components/document/document-download-button'; import { DocumentShareButton } from '@documenso/ui/components/document/document-share-button'; import { SigningCard3D } from '@documenso/ui/components/signing-card'; +import { cn } from '@documenso/ui/lib/utils'; +import { Badge } from '@documenso/ui/primitives/badge'; import { truncateTitle } from '~/helpers/truncate-title'; import { SigningAuthPageView } from '../signing-auth-page'; +import { ClaimAccount } from './claim-account'; import { DocumentPreviewButton } from './document-preview-button'; export type CompletedSigningPageProps = { @@ -31,6 +35,8 @@ export type CompletedSigningPageProps = { export default async function CompletedSigningPage({ params: { token }, }: CompletedSigningPageProps) { + const NEXT_PUBLIC_DISABLE_SIGNUP = env('NEXT_PUBLIC_DISABLE_SIGNUP'); + if (!token) { return notFound(); } @@ -79,96 +85,120 @@ export default async function CompletedSigningPage({ const sessionData = await getServerSession(); const isLoggedIn = !!sessionData?.user; + const canSignUp = !isLoggedIn && NEXT_PUBLIC_DISABLE_SIGNUP !== 'true'; return ( -
- {/* Card with recipient */} - +
+
+
+ + {truncatedTitle} + -
- {match({ status: document.status, deletedAt: document.deletedAt }) - .with({ status: DocumentStatus.COMPLETED }, () => ( -
- - Everyone has signed -
- )) - .with({ deletedAt: null }, () => ( -
- - Waiting for others to sign -
- )) - .otherwise(() => ( -
- - Document no longer available to sign -
- ))} + {/* Card with recipient */} + -

- You have - {recipient.role === RecipientRole.SIGNER && ' signed '} - {recipient.role === RecipientRole.VIEWER && ' viewed '} - {recipient.role === RecipientRole.APPROVER && ' approved '} - "{truncatedTitle}" -

+

+ Document + {recipient.role === RecipientRole.SIGNER && ' Signed '} + {recipient.role === RecipientRole.VIEWER && ' Viewed '} + {recipient.role === RecipientRole.APPROVER && ' Approved '} +

- {match({ status: document.status, deletedAt: document.deletedAt }) - .with({ status: DocumentStatus.COMPLETED }, () => ( -

- Everyone has signed! You will receive an Email copy of the signed document. -

- )) - .with({ deletedAt: null }, () => ( -

- You will receive an Email copy of the signed document once everyone has signed. -

- )) - .otherwise(() => ( -

- This document has been cancelled by the owner and is no longer available for others to - sign. -

- ))} + {match({ status: document.status, deletedAt: document.deletedAt }) + .with({ status: DocumentStatus.COMPLETED }, () => ( +
+ + Everyone has signed +
+ )) + .with({ deletedAt: null }, () => ( +
+ + Waiting for others to sign +
+ )) + .otherwise(() => ( +
+ + Document no longer available to sign +
+ ))} -
- + {match({ status: document.status, deletedAt: document.deletedAt }) + .with({ status: DocumentStatus.COMPLETED }, () => ( +

+ Everyone has signed! You will receive an Email copy of the signed document. +

+ )) + .with({ deletedAt: null }, () => ( +

+ You will receive an Email copy of the signed document once everyone has signed. +

+ )) + .otherwise(() => ( +

+ This document has been cancelled by the owner and is no longer available for others + to sign. +

+ ))} - {document.status === DocumentStatus.COMPLETED ? ( - - ) : ( - - )} +
+ + + {document.status === DocumentStatus.COMPLETED ? ( + + ) : ( + + )} +
- {isLoggedIn ? ( + {canSignUp && ( +
+

+ Need to sign documents? +

+ +

+ Create your account and start using state-of-the-art document signing. +

+ + +
+ )} + + {isLoggedIn && ( Go Back Home - ) : ( -

- Want to send slick signing links like this one?{' '} - - Check out Documenso. - -

)}
diff --git a/packages/app-tests/e2e/document-flow/stepper-component.spec.ts b/packages/app-tests/e2e/document-flow/stepper-component.spec.ts index ee6b160cc..c2ae0618c 100644 --- a/packages/app-tests/e2e/document-flow/stepper-component.spec.ts +++ b/packages/app-tests/e2e/document-flow/stepper-component.spec.ts @@ -254,7 +254,7 @@ test('[DOCUMENT_FLOW]: should be able to create, send and sign a document', asyn await page.getByRole('button', { name: 'Sign' }).click(); await page.waitForURL(`/sign/${token}/complete`); - await expect(page.getByText('You have signed')).toBeVisible(); + await expect(page.getByText('Document Signed')).toBeVisible(); // Check if document has been signed const { status: completedStatus } = await getDocumentByToken(token); diff --git a/packages/trpc/server/auth-router/router.ts b/packages/trpc/server/auth-router/router.ts index f9a1795d7..645690905 100644 --- a/packages/trpc/server/auth-router/router.ts +++ b/packages/trpc/server/auth-router/router.ts @@ -56,7 +56,7 @@ export const authRouter = router({ return user; } catch (err) { - console.log(err); + console.error(err); const error = AppError.parseError(err); diff --git a/packages/trpc/server/auth-router/schema.ts b/packages/trpc/server/auth-router/schema.ts index b84c5e1c9..71734d734 100644 --- a/packages/trpc/server/auth-router/schema.ts +++ b/packages/trpc/server/auth-router/schema.ts @@ -23,7 +23,7 @@ export const ZSignUpMutationSchema = z.object({ name: z.string().min(1), email: z.string().email(), password: ZPasswordSchema, - signature: z.string().min(1, { message: 'A signature is required.' }), + signature: z.string().nullish(), url: z .string() .trim() From 7705dbae0cc448f66bb79c3cf829176c61da50e1 Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Thu, 11 Apr 2024 15:04:36 +0700 Subject: [PATCH 14/14] feat: add document log page link (#1099) ## Description Adds a link from the document page view to the document page log view image --- .../[id]/document-page-view-dropdown.tsx | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/apps/web/src/app/(dashboard)/documents/[id]/document-page-view-dropdown.tsx b/apps/web/src/app/(dashboard)/documents/[id]/document-page-view-dropdown.tsx index 3e108aed5..7b6bb8a91 100644 --- a/apps/web/src/app/(dashboard)/documents/[id]/document-page-view-dropdown.tsx +++ b/apps/web/src/app/(dashboard)/documents/[id]/document-page-view-dropdown.tsx @@ -4,7 +4,16 @@ import { useState } from 'react'; import Link from 'next/link'; -import { Copy, Download, Edit, Loader, MoreHorizontal, Share, Trash2 } from 'lucide-react'; +import { + Copy, + Download, + Edit, + Loader, + MoreHorizontal, + ScrollTextIcon, + Share, + Trash2, +} from 'lucide-react'; import { useSession } from 'next-auth/react'; import { downloadPDF } from '@documenso/lib/client-only/download-pdf'; @@ -106,6 +115,13 @@ export const DocumentPageViewDropdown = ({ document, team }: DocumentPageViewDro )} + + + + Logs + + + setDuplicateDialogOpen(true)}> Duplicate