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/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/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/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); +}; 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';