From 695ed418e2c6da29601fdded8237f9bbaa4d16f6 Mon Sep 17 00:00:00 2001 From: Lucas Smith Date: Wed, 4 Jun 2025 23:29:36 +1000 Subject: [PATCH 1/2] fix: documents failing to seal (#1821) During our field rework that makes fields appear more accurately between signing and the completed pdf we swapped to using text fields. Unfortunately as part of that we dropped using the Noto font for the text field causing ANSI encoding issues when encountering certain characters. This change restores the font and handles a nasty issue we had with our form flattening reverting our selected font. --- docker/development/compose.yml | 37 ------------------ .../lib/server-only/document/seal-document.ts | 4 +- packages/lib/server-only/pdf/flatten-form.ts | 15 ++++++- .../server-only/pdf/insert-field-in-pdf.ts | 39 ++++++++++++++++--- 4 files changed, 48 insertions(+), 47 deletions(-) diff --git a/docker/development/compose.yml b/docker/development/compose.yml index 6077668ac..3922ce614 100644 --- a/docker/development/compose.yml +++ b/docker/development/compose.yml @@ -40,43 +40,6 @@ services: entrypoint: sh command: -c 'mkdir -p /data/documenso && minio server /data --console-address ":9001" --address ":9002"' - triggerdotdev: - image: ghcr.io/triggerdotdev/trigger.dev:latest - container_name: triggerdotdev - environment: - - LOGIN_ORIGIN=http://localhost:3030 - - APP_ORIGIN=http://localhost:3030 - - PORT=3030 - - REMIX_APP_PORT=3030 - - MAGIC_LINK_SECRET=secret - - SESSION_SECRET=secret - - ENCRYPTION_KEY=deadbeefcafefeed - - DATABASE_URL=postgresql://trigger:password@triggerdotdev_database:5432/trigger - - DIRECT_URL=postgresql://trigger:password@triggerdotdev_database:5432/trigger - - RUNTIME_PLATFORM=docker-compose - ports: - - 3030:3030 - depends_on: - - triggerdotdev_database - - triggerdotdev_database: - container_name: triggerdotdev_database - image: postgres:15 - volumes: - - triggerdotdev_database:/var/lib/postgresql/data - healthcheck: - test: ['CMD-SHELL', 'pg_isready -U ${POSTGRES_USER}'] - interval: 10s - timeout: 5s - retries: 5 - environment: - - POSTGRES_USER=trigger - - POSTGRES_PASSWORD=password - - POSTGRES_DB=trigger - ports: - - 54321:5432 - volumes: minio: documenso_database: - triggerdotdev_database: diff --git a/packages/lib/server-only/document/seal-document.ts b/packages/lib/server-only/document/seal-document.ts index 507f6ec19..f28274e6d 100644 --- a/packages/lib/server-only/document/seal-document.ts +++ b/packages/lib/server-only/document/seal-document.ts @@ -128,7 +128,7 @@ export const sealDocument = async ({ // Normalize and flatten layers that could cause issues with the signature normalizeSignatureAppearances(doc); - flattenForm(doc); + await flattenForm(doc); flattenAnnotations(doc); // Add rejection stamp if the document is rejected @@ -153,7 +153,7 @@ export const sealDocument = async ({ } // Re-flatten post-insertion to handle fields that create arcoFields - flattenForm(doc); + await flattenForm(doc); const pdfBytes = await doc.save(); diff --git a/packages/lib/server-only/pdf/flatten-form.ts b/packages/lib/server-only/pdf/flatten-form.ts index 0970309e8..ab5b17a1c 100644 --- a/packages/lib/server-only/pdf/flatten-form.ts +++ b/packages/lib/server-only/pdf/flatten-form.ts @@ -1,3 +1,4 @@ +import fontkit from '@pdf-lib/fontkit'; import type { PDFField, PDFWidgetAnnotation } from 'pdf-lib'; import { PDFCheckBox, @@ -13,6 +14,8 @@ import { translate, } from 'pdf-lib'; +import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app'; + export const removeOptionalContentGroups = (document: PDFDocument) => { const context = document.context; const catalog = context.lookup(context.trailerInfo.Root); @@ -21,12 +24,20 @@ export const removeOptionalContentGroups = (document: PDFDocument) => { } }; -export const flattenForm = (document: PDFDocument) => { +export const flattenForm = async (document: PDFDocument) => { removeOptionalContentGroups(document); const form = document.getForm(); - form.updateFieldAppearances(); + const fontNoto = await fetch(`${NEXT_PUBLIC_WEBAPP_URL()}/fonts/noto-sans.ttf`).then( + async (res) => res.arrayBuffer(), + ); + + document.registerFontkit(fontkit); + + const font = await document.embedFont(fontNoto); + + form.updateFieldAppearances(font); for (const field of form.getFields()) { for (const widget of field.acroField.getWidgets()) { diff --git a/packages/lib/server-only/pdf/insert-field-in-pdf.ts b/packages/lib/server-only/pdf/insert-field-in-pdf.ts index 8db5abcfe..a89a3c3f9 100644 --- a/packages/lib/server-only/pdf/insert-field-in-pdf.ts +++ b/packages/lib/server-only/pdf/insert-field-in-pdf.ts @@ -1,8 +1,15 @@ // https://github.com/Hopding/pdf-lib/issues/20#issuecomment-412852821 import fontkit from '@pdf-lib/fontkit'; import { FieldType } from '@prisma/client'; -import type { PDFDocument, PDFFont } from 'pdf-lib'; -import { RotationTypes, TextAlignment, degrees, radiansToDegrees, rgb } from 'pdf-lib'; +import type { PDFDocument, PDFFont, PDFTextField } from 'pdf-lib'; +import { + RotationTypes, + TextAlignment, + degrees, + radiansToDegrees, + rgb, + setFontAndSize, +} from 'pdf-lib'; import { P, match } from 'ts-pattern'; import { @@ -442,6 +449,10 @@ export const insertFieldInPDF = async (pdf: PDFDocument, field: FieldWithSignatu adjustedFieldY = adjustedPosition.yPos; } + // Set properties for the text field + setTextFieldFontSize(textField, font, fontSize); + textField.setText(textToInsert); + // Set the position and size of the text field textField.addToPage(page, { x: adjustedFieldX, @@ -450,6 +461,8 @@ export const insertFieldInPDF = async (pdf: PDFDocument, field: FieldWithSignatu height: adjustedFieldHeight, rotate: degrees(pageRotationInDegrees), + font, + // Hide borders. borderWidth: 0, borderColor: undefined, @@ -457,10 +470,6 @@ export const insertFieldInPDF = async (pdf: PDFDocument, field: FieldWithSignatu ...(isDebugMode ? { borderWidth: 1, borderColor: rgb(0, 0, 1) } : {}), }); - - // Set properties for the text field - textField.setFontSize(fontSize); - textField.setText(textToInsert); }); return pdf; @@ -629,3 +638,21 @@ function breakLongString(text: string, maxWidth: number, font: PDFFont, fontSize return lines.join('\n'); } + +const setTextFieldFontSize = (textField: PDFTextField, font: PDFFont, fontSize: number) => { + textField.defaultUpdateAppearances(font); + textField.updateAppearances(font); + + try { + textField.setFontSize(fontSize); + } catch (err) { + let da = textField.acroField.getDefaultAppearance() ?? ''; + + da += `\n ${setFontAndSize(font.name, fontSize)}`; + + textField.acroField.setDefaultAppearance(da); + } + + textField.defaultUpdateAppearances(font); + textField.updateAppearances(font); +}; From ce66da0055ac36994df92c593ef79387ea68eae0 Mon Sep 17 00:00:00 2001 From: Lucas Smith Date: Thu, 5 Jun 2025 12:58:52 +1000 Subject: [PATCH 2/2] feat: multisign embedding (#1823) Adds the ability to use a multisign embedding for cases where multiple documents need to be signed in a convenient manner. --- .../embed-direct-template-client-page.tsx | 2 +- .../embed/embed-document-signing-page.tsx | 2 +- .../multisign/multi-sign-document-list.tsx | 182 ++++++++ .../multi-sign-document-signing-view.tsx | 394 ++++++++++++++++++ .../routes/embed+/v1+/multisign+/_index.tsx | 327 +++++++++++++++ .../types/embed-multisign-document-schema.ts | 17 + .../client-only/hooks/use-element-bounds.ts | 75 ++++ .../trpc/server/embedding-router/_router.ts | 4 + .../apply-multi-sign-signature.ts | 102 +++++ .../apply-multi-sign-signature.types.ts | 18 + .../get-multi-sign-document.ts | 62 +++ .../get-multi-sign-document.types.ts | 50 +++ packages/ui/components/field/field.tsx | 33 +- packages/ui/primitives/alert.tsx | 2 +- 14 files changed, 1257 insertions(+), 13 deletions(-) create mode 100644 apps/remix/app/components/embed/multisign/multi-sign-document-list.tsx create mode 100644 apps/remix/app/components/embed/multisign/multi-sign-document-signing-view.tsx create mode 100644 apps/remix/app/routes/embed+/v1+/multisign+/_index.tsx create mode 100644 apps/remix/app/types/embed-multisign-document-schema.ts create mode 100644 packages/lib/client-only/hooks/use-element-bounds.ts create mode 100644 packages/trpc/server/embedding-router/apply-multi-sign-signature.ts create mode 100644 packages/trpc/server/embedding-router/apply-multi-sign-signature.types.ts create mode 100644 packages/trpc/server/embedding-router/get-multi-sign-document.ts create mode 100644 packages/trpc/server/embedding-router/get-multi-sign-document.types.ts diff --git a/apps/remix/app/components/embed/embed-direct-template-client-page.tsx b/apps/remix/app/components/embed/embed-direct-template-client-page.tsx index aa780385c..09f6a91d2 100644 --- a/apps/remix/app/components/embed/embed-direct-template-client-page.tsx +++ b/apps/remix/app/components/embed/embed-direct-template-client-page.tsx @@ -332,7 +332,7 @@ export const EmbedDirectTemplateClientPage = ({ {/* Widget */}
diff --git a/apps/remix/app/components/embed/embed-document-signing-page.tsx b/apps/remix/app/components/embed/embed-document-signing-page.tsx index e125b2b4a..ef2eedc1c 100644 --- a/apps/remix/app/components/embed/embed-document-signing-page.tsx +++ b/apps/remix/app/components/embed/embed-document-signing-page.tsx @@ -290,7 +290,7 @@ export const EmbedSignDocumentClientPage = ({ {/* Widget */}
diff --git a/apps/remix/app/components/embed/multisign/multi-sign-document-list.tsx b/apps/remix/app/components/embed/multisign/multi-sign-document-list.tsx new file mode 100644 index 000000000..01476a5ab --- /dev/null +++ b/apps/remix/app/components/embed/multisign/multi-sign-document-list.tsx @@ -0,0 +1,182 @@ +import { Trans } from '@lingui/react/macro'; +import { ReadStatus, RecipientRole, SigningStatus } from '@prisma/client'; +import { ArrowRight, EyeIcon, XCircle } from 'lucide-react'; +import { match } from 'ts-pattern'; + +import type { DocumentAndSender } from '@documenso/lib/server-only/document/get-document-by-token'; +import type { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token'; +import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert'; +import { Badge } from '@documenso/ui/primitives/badge'; +import { Button } from '@documenso/ui/primitives/button'; +import { Progress } from '@documenso/ui/primitives/progress'; + +// Get the return type from getRecipientByToken +type RecipientWithFields = Awaited>; + +interface DocumentEnvelope { + document: DocumentAndSender; + recipient: RecipientWithFields; +} + +interface MultiSignDocumentListProps { + envelopes: DocumentEnvelope[]; + onDocumentSelect: (document: DocumentEnvelope['document']) => void; +} + +export function MultiSignDocumentList({ envelopes, onDocumentSelect }: MultiSignDocumentListProps) { + // Calculate progress + const completedDocuments = envelopes.filter( + (envelope) => envelope.recipient.signingStatus === SigningStatus.SIGNED, + ); + const totalDocuments = envelopes.length; + const progressPercentage = (completedDocuments.length / totalDocuments) * 100; + + // Find next document to sign (first one that's not signed and not rejected) + const nextDocumentToSign = envelopes.find( + (envelope) => + envelope.recipient.signingStatus !== SigningStatus.SIGNED && + envelope.recipient.signingStatus !== SigningStatus.REJECTED, + ); + + const allDocumentsCompleted = completedDocuments.length === totalDocuments; + + const hasAssistantOrCcRecipient = envelopes.some( + (envelope) => + envelope.recipient.role === RecipientRole.ASSISTANT || + envelope.recipient.role === RecipientRole.CC, + ); + + function handleView(doc: DocumentEnvelope['document']) { + onDocumentSelect(doc); + } + + function handleNextDocument() { + if (nextDocumentToSign) { + onDocumentSelect(nextDocumentToSign.document); + } + } + + if (hasAssistantOrCcRecipient) { + return ( +
+
+ +
+ +

+ It looks like we ran into an issue! +

+ +

+ + One of the documents in the current bundle has a signing role that is not compatible + with the current signing experience. + +

+ +

+ + Assistants and Copy roles are currently not compatible with the multi-sign experience. + +

+ +

+ Please contact the site owner for further assistance. +

+
+ ); + } + + return ( +
+

+ Sign Documents +

+ +

+ + You have been requested to sign the following documents. Review each document carefully + and complete the signing process. + +

+ + {/* Progress Section */} +
+
+ + Progress + + + {completedDocuments.length} of {totalDocuments} completed + +
+ +
+ +
+
+ +
+ {envelopes.map((envelope) => ( +
+ + {envelope.document.title} + + + {match(envelope.recipient) + .with({ signingStatus: SigningStatus.SIGNED }, () => ( + + Completed + + )) + .with({ signingStatus: SigningStatus.REJECTED }, () => ( + + Rejected + + )) + .with({ readStatus: ReadStatus.OPENED }, () => ( + + Viewed + + )) + .otherwise(() => null)} + + +
+ ))} +
+ + {/* Next Document Button */} + {!allDocumentsCompleted && nextDocumentToSign && ( +
+ +
+ )} + + {allDocumentsCompleted && ( + + + All documents have been completed! + + + Thank you for completing the signing process. + + + )} +
+ ); +} diff --git a/apps/remix/app/components/embed/multisign/multi-sign-document-signing-view.tsx b/apps/remix/app/components/embed/multisign/multi-sign-document-signing-view.tsx new file mode 100644 index 000000000..f3fc4e4cd --- /dev/null +++ b/apps/remix/app/components/embed/multisign/multi-sign-document-signing-view.tsx @@ -0,0 +1,394 @@ +import { useState } from 'react'; + +import { msg } from '@lingui/core/macro'; +import { useLingui } from '@lingui/react'; +import { Trans } from '@lingui/react/macro'; +import { DocumentStatus, FieldType, SigningStatus } from '@prisma/client'; +import { Loader, LucideChevronDown, LucideChevronUp, X } from 'lucide-react'; +import { P, match } from 'ts-pattern'; + +import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer'; +import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; +import { trpc } from '@documenso/trpc/react'; +import type { + TRemovedSignedFieldWithTokenMutationSchema, + TSignFieldWithTokenMutationSchema, +} from '@documenso/trpc/server/field-router/schema'; +import { DocumentReadOnlyFields } from '@documenso/ui/components/document/document-read-only-fields'; +import { FieldToolTip } from '@documenso/ui/components/field/field-tooltip'; +import { cn } from '@documenso/ui/lib/utils'; +import { Button } from '@documenso/ui/primitives/button'; +import { ElementVisible } from '@documenso/ui/primitives/element-visible'; +import { Input } from '@documenso/ui/primitives/input'; +import { Label } from '@documenso/ui/primitives/label'; +import PDFViewer from '@documenso/ui/primitives/pdf-viewer'; +import { SignaturePadDialog } from '@documenso/ui/primitives/signature-pad/signature-pad-dialog'; +import { useToast } from '@documenso/ui/primitives/use-toast'; + +import { useRequiredDocumentSigningContext } from '../../general/document-signing/document-signing-provider'; +import { DocumentSigningRejectDialog } from '../../general/document-signing/document-signing-reject-dialog'; +import { EmbedDocumentFields } from '../embed-document-fields'; + +interface MultiSignDocumentSigningViewProps { + token: string; + recipientId: number; + onBack: () => void; + onDocumentCompleted?: (data: { token: string; documentId: number; recipientId: number }) => void; + onDocumentRejected?: (data: { + token: string; + documentId: number; + recipientId: number; + reason: string; + }) => void; + onDocumentError?: () => void; + onDocumentReady?: () => void; + isNameLocked?: boolean; + allowDocumentRejection?: boolean; +} + +export const MultiSignDocumentSigningView = ({ + token, + recipientId, + onBack, + onDocumentCompleted, + onDocumentRejected, + onDocumentError, + onDocumentReady, + isNameLocked = false, + allowDocumentRejection = false, +}: MultiSignDocumentSigningViewProps) => { + const { _ } = useLingui(); + const { toast } = useToast(); + + const { fullName, email, signature, setFullName, setSignature } = + useRequiredDocumentSigningContext(); + + const [hasDocumentLoaded, setHasDocumentLoaded] = useState(false); + + const [isExpanded, setIsExpanded] = useState(false); + const [isSubmitting, setIsSubmitting] = useState(false); + const [showPendingFieldTooltip, setShowPendingFieldTooltip] = useState(false); + + const { data: document, isLoading } = trpc.embeddingPresign.getMultiSignDocument.useQuery( + { token }, + { + staleTime: 0, + }, + ); + + const { mutateAsync: signFieldWithToken } = trpc.field.signFieldWithToken.useMutation(); + const { mutateAsync: removeSignedFieldWithToken } = + trpc.field.removeSignedFieldWithToken.useMutation(); + + const { mutateAsync: completeDocumentWithToken } = + trpc.recipient.completeDocumentWithToken.useMutation(); + + const hasSignatureField = document?.fields.some((field) => field.type === FieldType.SIGNATURE); + + const [pendingFields, completedFields] = [ + document?.fields.filter((field) => field.recipient.signingStatus !== SigningStatus.SIGNED) ?? + [], + document?.fields.filter((field) => field.recipient.signingStatus === SigningStatus.SIGNED) ?? + [], + ]; + + const onSignField = async (payload: TSignFieldWithTokenMutationSchema) => { + try { + await signFieldWithToken(payload); + } catch (err) { + const error = AppError.parseError(err); + + if (error.code === AppErrorCode.UNAUTHORIZED) { + throw error; + } + + console.error(err); + + toast({ + title: _(msg`Error`), + description: _(msg`An error occurred while signing the document.`), + variant: 'destructive', + }); + } + }; + + const onUnsignField = async (payload: TRemovedSignedFieldWithTokenMutationSchema) => { + try { + await removeSignedFieldWithToken(payload); + } catch (err) { + const error = AppError.parseError(err); + + if (error.code === AppErrorCode.UNAUTHORIZED) { + throw error; + } + + console.error(err); + } + }; + + const onDocumentComplete = async () => { + try { + setIsSubmitting(true); + + await completeDocumentWithToken({ + documentId: document!.id, + token, + }); + + onBack(); + + onDocumentCompleted?.({ + token, + documentId: document!.id, + recipientId, + }); + } catch (err) { + onDocumentError?.(); + + toast({ + title: 'Error', + description: 'Failed to complete the document. Please try again.', + variant: 'destructive', + }); + } finally { + setIsSubmitting(false); + } + }; + + const onNextFieldClick = () => { + setShowPendingFieldTooltip(true); + + setIsExpanded(false); + }; + + const onRejected = (reason: string) => { + if (onDocumentRejected && document) { + onDocumentRejected({ + token, + documentId: document.id, + recipientId, + reason, + }); + } + }; + + return ( +
+
+ {match({ isLoading, document }) + .with({ isLoading: true }, () => ( +
+
+ +

+ Loading document... +

+
+
+ )) + .with({ isLoading: false, document: undefined }, () => ( +
+

+ Failed to load document +

+
+ )) + .with({ document: P.nonNullable }, ({ document }) => ( + <> +
+
+

{document.title}

+
+ + +
+ + {allowDocumentRejection && ( +
+ +
+ )} + +
+
+ { + setHasDocumentLoaded(true); + onDocumentReady?.(); + }} + /> +
+ + {/* Widget */} + {document.status !== DocumentStatus.COMPLETED && ( +
+
+ {/* Header */} +
+
+

+ Sign document +

+ + +
+
+ +
+

+ Sign the document to complete the process. +

+ +
+
+ + {/* Form */} +
+
+ { + <> +
+ + + !isNameLocked && setFullName(e.target.value)} + /> +
+ +
+ + + +
+ + {hasSignatureField && ( +
+ + + setSignature(v ?? '')} + typedSignatureEnabled={ + document.documentMeta?.typedSignatureEnabled + } + uploadSignatureEnabled={ + document.documentMeta?.uploadSignatureEnabled + } + drawSignatureEnabled={ + document.documentMeta?.drawSignatureEnabled + } + /> +
+ )} + + } +
+
+ +
+ +
+ {pendingFields.length > 0 ? ( + + ) : ( + + )} +
+
+
+ )} +
+ + {hasDocumentLoaded && ( + + {showPendingFieldTooltip && pendingFields.length > 0 && ( + + Click to insert field + + )} + + )} + + {/* Fields */} + {hasDocumentLoaded && ( + + )} + + {/* Completed fields */} + {document.status !== DocumentStatus.COMPLETED && ( + + )} + + )) + .otherwise(() => null)} +
+
+ ); +}; diff --git a/apps/remix/app/routes/embed+/v1+/multisign+/_index.tsx b/apps/remix/app/routes/embed+/v1+/multisign+/_index.tsx new file mode 100644 index 000000000..16ea5448d --- /dev/null +++ b/apps/remix/app/routes/embed+/v1+/multisign+/_index.tsx @@ -0,0 +1,327 @@ +import { useEffect, useLayoutEffect, useState } from 'react'; + +import { SigningStatus } from '@prisma/client'; +import { useRevalidator } from 'react-router'; + +import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session'; +import { isCommunityPlan as isUserCommunityPlan } from '@documenso/ee/server-only/util/is-community-plan'; +import { isUserEnterprise } from '@documenso/ee/server-only/util/is-document-enterprise'; +import { isDocumentPlatform } from '@documenso/ee/server-only/util/is-document-platform'; +import { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document/get-document-by-token'; +import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token'; +import { getTeamById } from '@documenso/lib/server-only/team/get-team'; + +import { BrandingLogo } from '~/components/general/branding-logo'; +import { DocumentSigningAuthProvider } from '~/components/general/document-signing/document-signing-auth-provider'; +import { DocumentSigningProvider } from '~/components/general/document-signing/document-signing-provider'; +import { DocumentSigningRecipientProvider } from '~/components/general/document-signing/document-signing-recipient-provider'; +import { ZSignDocumentEmbedDataSchema } from '~/types/embed-document-sign-schema'; +import { injectCss } from '~/utils/css-vars'; +import { superLoaderJson, useSuperLoaderData } from '~/utils/super-json-loader'; + +import { MultiSignDocumentList } from '../../../../components/embed/multisign/multi-sign-document-list'; +import { MultiSignDocumentSigningView } from '../../../../components/embed/multisign/multi-sign-document-signing-view'; +import type { Route } from './+types/_index'; + +export async function loader({ request }: Route.LoaderArgs) { + const { user } = await getOptionalSession(request); + + const url = new URL(request.url); + + const tokens = url.searchParams.getAll('token'); + + const envelopes = await Promise.all( + tokens.map(async (token) => { + const document = await getDocumentAndSenderByToken({ + token, + }); + + const recipient = await getRecipientByToken({ token }); + + console.log('document', document.id); + + return { document, recipient }; + }), + ); + + // Check the first envelope for whitelabelling settings (assuming all docs are from same team) + const firstDocument = envelopes[0]?.document; + + if (!firstDocument) { + return superLoaderJson({ + envelopes, + user, + hidePoweredBy: false, + allowWhitelabelling: false, + }); + } + + const team = firstDocument.teamId + ? await getTeamById({ teamId: firstDocument.teamId, userId: firstDocument.userId }).catch( + () => null, + ) + : null; + + const [isPlatformDocument, isEnterpriseDocument, isCommunityPlan] = await Promise.all([ + isDocumentPlatform(firstDocument), + isUserEnterprise({ + userId: firstDocument.userId, + teamId: firstDocument.teamId ?? undefined, + }), + isUserCommunityPlan({ + userId: firstDocument.userId, + teamId: firstDocument.teamId ?? undefined, + }), + ]); + + const hidePoweredBy = team?.teamGlobalSettings?.brandingHidePoweredBy ?? false; + const allowWhitelabelling = isCommunityPlan || isPlatformDocument || isEnterpriseDocument; + + return superLoaderJson({ + envelopes, + user, + hidePoweredBy, + allowWhitelabelling, + }); +} + +export default function MultisignPage() { + const { envelopes, user, hidePoweredBy, allowWhitelabelling } = + useSuperLoaderData(); + const revalidator = useRevalidator(); + + const [selectedDocument, setSelectedDocument] = useState< + (typeof envelopes)[number]['document'] | null + >(null); + + // Additional state for embed functionality + const [hasFinishedInit, setHasFinishedInit] = useState(false); + const [isNameLocked, setIsNameLocked] = useState(false); + const [allowDocumentRejection, setAllowDocumentRejection] = useState(false); + const [showOtherRecipientsCompletedFields, setShowOtherRecipientsCompletedFields] = + useState(false); + const [embedFullName, setEmbedFullName] = useState(''); + + // Check if all documents are completed + const isCompleted = envelopes.every( + (envelope) => envelope.recipient.signingStatus === SigningStatus.SIGNED, + ); + + const selectedRecipient = selectedDocument + ? envelopes.find((e) => e.document.id === selectedDocument.id)?.recipient + : null; + + const onSelectDocument = (document: (typeof envelopes)[number]['document']) => { + setSelectedDocument(document); + }; + + const onBackToDocumentList = () => { + setSelectedDocument(null); + // Revalidate to fetch fresh data when returning to document list + void revalidator.revalidate(); + }; + + const onDocumentCompleted = (data: { + token: string; + documentId: number; + recipientId: number; + }) => { + // Send postMessage for individual document completion + if (window.parent) { + window.parent.postMessage( + { + action: 'document-completed', + data: { + token: data.token, + documentId: data.documentId, + recipientId: data.recipientId, + }, + }, + '*', + ); + } + }; + + const onDocumentRejected = (data: { + token: string; + documentId: number; + recipientId: number; + reason: string; + }) => { + // Send postMessage for document rejection + if (window.parent) { + window.parent.postMessage( + { + action: 'document-rejected', + data: { + token: data.token, + documentId: data.documentId, + recipientId: data.recipientId, + reason: data.reason, + }, + }, + '*', + ); + } + }; + + const onDocumentError = () => { + // Send postMessage for document error + if (window.parent) { + window.parent.postMessage( + { + action: 'document-error', + data: null, + }, + '*', + ); + } + }; + + const onDocumentReady = () => { + // Send postMessage when document is ready + if (window.parent) { + window.parent.postMessage( + { + action: 'document-ready', + data: null, + }, + '*', + ); + } + }; + + const onAllDocumentsCompleted = () => { + // Send postMessage for all documents completion + if (window.parent) { + window.parent.postMessage( + { + action: 'all-documents-completed', + data: { + documents: envelopes.map((envelope) => ({ + token: envelope.recipient.token, + documentId: envelope.document.id, + recipientId: envelope.recipient.id, + action: + envelope.recipient.signingStatus === SigningStatus.SIGNED + ? 'document-completed' + : 'document-rejected', + reason: + envelope.recipient.signingStatus === SigningStatus.REJECTED + ? envelope.recipient.rejectionReason + : undefined, + })), + }, + }, + '*', + ); + } + }; + + useEffect(() => { + if ( + envelopes.every((envelope) => envelope.recipient.signingStatus !== SigningStatus.NOT_SIGNED) + ) { + onAllDocumentsCompleted(); + } + }, [envelopes]); + + useLayoutEffect(() => { + const hash = window.location.hash.slice(1); + + try { + const data = ZSignDocumentEmbedDataSchema.parse(JSON.parse(decodeURIComponent(atob(hash)))); + + if (!isCompleted && data.name) { + setEmbedFullName(data.name); + } + + // Since a recipient can be provided a name we can lock it without requiring + // a to be provided by the parent application, unlike direct templates. + setIsNameLocked(!!data.lockName); + setAllowDocumentRejection(!!data.allowDocumentRejection); + setShowOtherRecipientsCompletedFields(!!data.showOtherRecipientsCompletedFields); + + if (data.darkModeDisabled) { + document.documentElement.classList.add('dark-mode-disabled'); + } + + if (allowWhitelabelling) { + injectCss({ + css: data.css, + cssVars: data.cssVars, + }); + } + } catch (err) { + console.error(err); + } + + setHasFinishedInit(true); + + // !: While the two setters are stable we still want to ensure we're avoiding + // !: re-renders. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + // If a document is selected, show the signing view + if (selectedDocument && selectedRecipient) { + // Determine the full name to use - prioritize embed data, then user name, then recipient name + const fullNameToUse = + embedFullName || + (user?.email === selectedRecipient.email ? user?.name : selectedRecipient.name) || + ''; + + return ( +
+ + + + + + + + + {!hidePoweredBy && ( +
+ Powered by + +
+ )} +
+ ); + } + + // Otherwise, show the document list + return ( +
+ + + {!hidePoweredBy && ( +
+ Powered by + +
+ )} +
+ ); +} diff --git a/apps/remix/app/types/embed-multisign-document-schema.ts b/apps/remix/app/types/embed-multisign-document-schema.ts new file mode 100644 index 000000000..43369b688 --- /dev/null +++ b/apps/remix/app/types/embed-multisign-document-schema.ts @@ -0,0 +1,17 @@ +import { z } from 'zod'; + +import { ZBaseEmbedDataSchema } from './embed-base-schemas'; + +export const ZEmbedMultiSignDocumentSchema = ZBaseEmbedDataSchema.extend({ + email: z + .union([z.literal(''), z.string().email()]) + .optional() + .transform((value) => value || undefined), + lockEmail: z.boolean().optional().default(false), + name: z + .string() + .optional() + .transform((value) => value || undefined), + lockName: z.boolean().optional().default(false), + allowDocumentRejection: z.boolean().optional(), +}); diff --git a/packages/lib/client-only/hooks/use-element-bounds.ts b/packages/lib/client-only/hooks/use-element-bounds.ts new file mode 100644 index 000000000..c2f453b51 --- /dev/null +++ b/packages/lib/client-only/hooks/use-element-bounds.ts @@ -0,0 +1,75 @@ +import { useEffect, useState } from 'react'; + +import { getBoundingClientRect } from '@documenso/lib/client-only/get-bounding-client-rect'; + +export const useElementBounds = (elementOrSelector: HTMLElement | string, withScroll = false) => { + const [bounds, setBounds] = useState({ + top: 0, + left: 0, + height: 0, + width: 0, + }); + + const calculateBounds = () => { + const $el = + typeof elementOrSelector === 'string' + ? document.querySelector(elementOrSelector) + : elementOrSelector; + + if (!$el) { + throw new Error('Element not found'); + } + + if (withScroll) { + return getBoundingClientRect($el); + } + + const { top, left, width, height } = $el.getBoundingClientRect(); + + return { + top, + left, + width, + height, + }; + }; + + useEffect(() => { + setBounds(calculateBounds()); + }, [calculateBounds]); + + useEffect(() => { + const onResize = () => { + setBounds(calculateBounds()); + }; + + window.addEventListener('resize', onResize); + + return () => { + window.removeEventListener('resize', onResize); + }; + }, [calculateBounds]); + + useEffect(() => { + const $el = + typeof elementOrSelector === 'string' + ? document.querySelector(elementOrSelector) + : elementOrSelector; + + if (!$el) { + return; + } + + const observer = new ResizeObserver(() => { + setBounds(calculateBounds()); + }); + + observer.observe($el); + + return () => { + observer.disconnect(); + }; + }, [calculateBounds]); + + return bounds; +}; diff --git a/packages/trpc/server/embedding-router/_router.ts b/packages/trpc/server/embedding-router/_router.ts index 030132f99..3699e6712 100644 --- a/packages/trpc/server/embedding-router/_router.ts +++ b/packages/trpc/server/embedding-router/_router.ts @@ -1,7 +1,9 @@ import { router } from '../trpc'; +import { applyMultiSignSignatureRoute } from './apply-multi-sign-signature'; import { createEmbeddingDocumentRoute } from './create-embedding-document'; import { createEmbeddingPresignTokenRoute } from './create-embedding-presign-token'; import { createEmbeddingTemplateRoute } from './create-embedding-template'; +import { getMultiSignDocumentRoute } from './get-multi-sign-document'; import { updateEmbeddingDocumentRoute } from './update-embedding-document'; import { updateEmbeddingTemplateRoute } from './update-embedding-template'; import { verifyEmbeddingPresignTokenRoute } from './verify-embedding-presign-token'; @@ -13,4 +15,6 @@ export const embeddingPresignRouter = router({ createEmbeddingTemplate: createEmbeddingTemplateRoute, updateEmbeddingDocument: updateEmbeddingDocumentRoute, updateEmbeddingTemplate: updateEmbeddingTemplateRoute, + applyMultiSignSignature: applyMultiSignSignatureRoute, + getMultiSignDocument: getMultiSignDocumentRoute, }); diff --git a/packages/trpc/server/embedding-router/apply-multi-sign-signature.ts b/packages/trpc/server/embedding-router/apply-multi-sign-signature.ts new file mode 100644 index 000000000..34d4b082d --- /dev/null +++ b/packages/trpc/server/embedding-router/apply-multi-sign-signature.ts @@ -0,0 +1,102 @@ +import { FieldType, ReadStatus, SigningStatus } from '@prisma/client'; + +import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; +import { getDocumentByToken } from '@documenso/lib/server-only/document/get-document-by-token'; +import { signFieldWithToken } from '@documenso/lib/server-only/field/sign-field-with-token'; +import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token'; +import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth'; +import { prisma } from '@documenso/prisma'; + +import { procedure } from '../trpc'; +import { + ZApplyMultiSignSignatureRequestSchema, + ZApplyMultiSignSignatureResponseSchema, +} from './apply-multi-sign-signature.types'; + +export const applyMultiSignSignatureRoute = procedure + .input(ZApplyMultiSignSignatureRequestSchema) + .output(ZApplyMultiSignSignatureResponseSchema) + .mutation(async ({ input, ctx: { metadata } }) => { + try { + const { tokens, signature, isBase64 } = input; + + // Get all documents and recipients for the tokens + const envelopes = await Promise.all( + tokens.map(async (token) => { + const document = await getDocumentByToken({ token }); + const recipient = await getRecipientByToken({ token }); + + return { document, recipient }; + }), + ); + + // Check if all documents have been viewed + const hasUnviewedDocuments = envelopes.some( + (envelope) => envelope.recipient.readStatus !== ReadStatus.OPENED, + ); + + if (hasUnviewedDocuments) { + throw new AppError(AppErrorCode.INVALID_REQUEST, { + message: 'All documents must be viewed before signing', + }); + } + + // If we require action auth we should abort here for now + for (const envelope of envelopes) { + const derivedRecipientActionAuth = extractDocumentAuthMethods({ + documentAuth: envelope.document.authOptions, + recipientAuth: envelope.recipient.authOptions, + }); + + if ( + derivedRecipientActionAuth.recipientAccessAuthRequired || + derivedRecipientActionAuth.recipientActionAuthRequired + ) { + throw new AppError(AppErrorCode.INVALID_REQUEST, { + message: + 'Documents that require additional authentication cannot be multi signed at the moment', + }); + } + } + + // Sign all signature fields for each document + await Promise.all( + envelopes.map(async (envelope) => { + if (envelope.recipient.signingStatus === SigningStatus.REJECTED) { + return; + } + + const signatureFields = await prisma.field.findMany({ + where: { + documentId: envelope.document.id, + recipientId: envelope.recipient.id, + type: FieldType.SIGNATURE, + inserted: false, + }, + }); + + await Promise.all( + signatureFields.map(async (field) => + signFieldWithToken({ + token: envelope.recipient.token, + fieldId: field.id, + value: signature, + isBase64, + requestMetadata: metadata.requestMetadata, + }), + ), + ); + }), + ); + + return { success: true }; + } catch (error) { + if (error instanceof AppError) { + throw error; + } + + throw new AppError(AppErrorCode.UNKNOWN_ERROR, { + message: 'Failed to apply multi-sign signature', + }); + } + }); diff --git a/packages/trpc/server/embedding-router/apply-multi-sign-signature.types.ts b/packages/trpc/server/embedding-router/apply-multi-sign-signature.types.ts new file mode 100644 index 000000000..b3ff4d890 --- /dev/null +++ b/packages/trpc/server/embedding-router/apply-multi-sign-signature.types.ts @@ -0,0 +1,18 @@ +import { z } from 'zod'; + +export const ZApplyMultiSignSignatureRequestSchema = z.object({ + tokens: z.array(z.string()).min(1, { message: 'At least one token is required' }), + signature: z.string().min(1, { message: 'Signature is required' }), + isBase64: z.boolean().optional().default(false), +}); + +export const ZApplyMultiSignSignatureResponseSchema = z.object({ + success: z.boolean(), +}); + +export type TApplyMultiSignSignatureRequestSchema = z.infer< + typeof ZApplyMultiSignSignatureRequestSchema +>; +export type TApplyMultiSignSignatureResponseSchema = z.infer< + typeof ZApplyMultiSignSignatureResponseSchema +>; diff --git a/packages/trpc/server/embedding-router/get-multi-sign-document.ts b/packages/trpc/server/embedding-router/get-multi-sign-document.ts new file mode 100644 index 000000000..60a6813c0 --- /dev/null +++ b/packages/trpc/server/embedding-router/get-multi-sign-document.ts @@ -0,0 +1,62 @@ +import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; +import { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document/get-document-by-token'; +import { viewedDocument } from '@documenso/lib/server-only/document/viewed-document'; +import { getCompletedFieldsForToken } from '@documenso/lib/server-only/field/get-completed-fields-for-token'; +import { getFieldsForToken } from '@documenso/lib/server-only/field/get-fields-for-token'; +import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token'; + +import { procedure } from '../trpc'; +import { + ZGetMultiSignDocumentRequestSchema, + ZGetMultiSignDocumentResponseSchema, +} from './get-multi-sign-document.types'; + +export const getMultiSignDocumentRoute = procedure + .input(ZGetMultiSignDocumentRequestSchema) + .output(ZGetMultiSignDocumentResponseSchema) + .query(async ({ input, ctx: { metadata } }) => { + try { + const { token } = input; + + const [document, fields, recipient] = await Promise.all([ + getDocumentAndSenderByToken({ + token, + requireAccessAuth: false, + }).catch(() => null), + getFieldsForToken({ token }), + getRecipientByToken({ token }).catch(() => null), + getCompletedFieldsForToken({ token }).catch(() => []), + ]); + + if (!document || !recipient) { + throw new AppError(AppErrorCode.NOT_FOUND, { + message: 'Document or recipient not found', + }); + } + + await viewedDocument({ + token, + requestMetadata: metadata.requestMetadata, + }); + + // Transform fields to match our schema + const transformedFields = fields.map((field) => ({ + ...field, + recipient, + })); + + return { + ...document, + folder: null, + fields: transformedFields, + }; + } catch (error) { + if (error instanceof AppError) { + throw error; + } + + throw new AppError(AppErrorCode.UNKNOWN_ERROR, { + message: 'Failed to get document details', + }); + } + }); diff --git a/packages/trpc/server/embedding-router/get-multi-sign-document.types.ts b/packages/trpc/server/embedding-router/get-multi-sign-document.types.ts new file mode 100644 index 000000000..2ab73db72 --- /dev/null +++ b/packages/trpc/server/embedding-router/get-multi-sign-document.types.ts @@ -0,0 +1,50 @@ +import { z } from 'zod'; + +import { ZDocumentLiteSchema } from '@documenso/lib/types/document'; +import { ZRecipientLiteSchema } from '@documenso/lib/types/recipient'; +import DocumentDataSchema from '@documenso/prisma/generated/zod/modelSchema/DocumentDataSchema'; +import DocumentMetaSchema from '@documenso/prisma/generated/zod/modelSchema/DocumentMetaSchema'; +import FieldSchema from '@documenso/prisma/generated/zod/modelSchema/FieldSchema'; +import SignatureSchema from '@documenso/prisma/generated/zod/modelSchema/SignatureSchema'; + +export const ZGetMultiSignDocumentRequestSchema = z.object({ + token: z.string().min(1, { message: 'Token is required' }), +}); + +export const ZGetMultiSignDocumentResponseSchema = ZDocumentLiteSchema.extend({ + documentData: DocumentDataSchema.pick({ + type: true, + id: true, + data: true, + initialData: true, + }), + documentMeta: DocumentMetaSchema.pick({ + signingOrder: true, + distributionMethod: true, + id: true, + subject: true, + message: true, + timezone: true, + password: true, + dateFormat: true, + documentId: true, + redirectUrl: true, + typedSignatureEnabled: true, + uploadSignatureEnabled: true, + drawSignatureEnabled: true, + allowDictateNextSigner: true, + language: true, + emailSettings: true, + }).nullable(), + fields: z.array( + FieldSchema.extend({ + recipient: ZRecipientLiteSchema, + signature: SignatureSchema.nullable(), + }), + ), +}); + +export type TGetMultiSignDocumentRequestSchema = z.infer; +export type TGetMultiSignDocumentResponseSchema = z.infer< + typeof ZGetMultiSignDocumentResponseSchema +>; diff --git a/packages/ui/components/field/field.tsx b/packages/ui/components/field/field.tsx index e3acf8005..5c2c3f300 100644 --- a/packages/ui/components/field/field.tsx +++ b/packages/ui/components/field/field.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from 'react'; +import React, { useEffect, useMemo, useState } from 'react'; import { type Field, FieldType } from '@prisma/client'; import { createPortal } from 'react-dom'; @@ -20,24 +20,37 @@ export function FieldContainerPortal({ children, className = '', }: FieldContainerPortalProps) { + const alternativePortalRoot = document.getElementById('document-field-portal-root'); + const coords = useFieldPageCoords(field); const isCheckboxOrRadioField = field.type === 'CHECKBOX' || field.type === 'RADIO'; - const style = { - top: `${coords.y}px`, - left: `${coords.x}px`, - ...(!isCheckboxOrRadioField && { - height: `${coords.height}px`, - width: `${coords.width}px`, - }), - }; + const style = useMemo(() => { + const portalBounds = alternativePortalRoot?.getBoundingClientRect(); + + const bounds = { + top: `${coords.y}px`, + left: `${coords.x}px`, + ...(!isCheckboxOrRadioField && { + height: `${coords.height}px`, + width: `${coords.width}px`, + }), + }; + + if (portalBounds) { + bounds.top = `${coords.y - portalBounds.top}px`; + bounds.left = `${coords.x - portalBounds.left}px`; + } + + return bounds; + }, [coords, isCheckboxOrRadioField]); return createPortal(
{children}
, - document.body, + alternativePortalRoot ?? document.body, ); } diff --git a/packages/ui/primitives/alert.tsx b/packages/ui/primitives/alert.tsx index 092fbb2b4..5b5fa770a 100644 --- a/packages/ui/primitives/alert.tsx +++ b/packages/ui/primitives/alert.tsx @@ -58,7 +58,7 @@ const AlertDescription = React.forwardRef< HTMLParagraphElement, React.HTMLAttributes >(({ className, ...props }, ref) => ( -
+
)); AlertDescription.displayName = 'AlertDescription';