From c40471281a816ce44d8c8cd80a39aa2c6aa0f135 Mon Sep 17 00:00:00 2001 From: Lucas Smith Date: Thu, 6 Nov 2025 23:43:41 +1100 Subject: [PATCH] chore: update embeds for v2 envelopes --- ...tsx => embed-document-signing-page-v1.tsx} | 6 +- .../embed/embed-document-signing-page-v2.tsx | 232 +++++++++++ .../embed/embed-signing-context.tsx | 101 +++++ .../document-signing-complete-dialog.tsx | 11 +- .../document-signing-mobile-widget.tsx | 14 +- .../document-signing-page-view-v2.tsx | 65 ++- .../envelope-signing-provider.tsx | 13 +- .../envelope-file-selector.tsx | 2 +- .../envelope-signing/envelope-signer-form.tsx | 9 +- .../envelope-signer-header.tsx | 11 +- .../envelope-signer-page-renderer.tsx | 18 +- .../envelope-signing-complete-dialog.tsx | 92 +++- apps/remix/app/routes/embed+/_v0+/_layout.tsx | 14 + .../app/routes/embed+/_v0+/direct.$token.tsx | 332 +++++++++++++++ .../app/routes/embed+/_v0+/direct.$url.tsx | 138 ------ .../app/routes/embed+/_v0+/sign.$token.tsx | 394 ++++++++++++++++++ .../app/routes/embed+/_v0+/sign.$url.tsx | 181 -------- .../routes/embed+/v1+/multisign+/_index.tsx | 1 + .../document/get-document-by-token.ts | 4 +- ...et-envelope-for-direct-template-signing.ts | 2 +- .../get-envelope-for-recipient-signing.ts | 1 + .../create-document-from-direct-template.ts | 2 + .../trpc/server/embedding-router/_router.ts | 3 +- 23 files changed, 1271 insertions(+), 375 deletions(-) rename apps/remix/app/components/embed/{embed-document-signing-page.tsx => embed-document-signing-page-v1.tsx} (99%) create mode 100644 apps/remix/app/components/embed/embed-document-signing-page-v2.tsx create mode 100644 apps/remix/app/components/embed/embed-signing-context.tsx create mode 100644 apps/remix/app/routes/embed+/_v0+/direct.$token.tsx delete mode 100644 apps/remix/app/routes/embed+/_v0+/direct.$url.tsx create mode 100644 apps/remix/app/routes/embed+/_v0+/sign.$token.tsx delete mode 100644 apps/remix/app/routes/embed+/_v0+/sign.$url.tsx diff --git a/apps/remix/app/components/embed/embed-document-signing-page.tsx b/apps/remix/app/components/embed/embed-document-signing-page-v1.tsx similarity index 99% rename from apps/remix/app/components/embed/embed-document-signing-page.tsx rename to apps/remix/app/components/embed/embed-document-signing-page-v1.tsx index 726a9475a..59bd442ec 100644 --- a/apps/remix/app/components/embed/embed-document-signing-page.tsx +++ b/apps/remix/app/components/embed/embed-document-signing-page-v1.tsx @@ -40,7 +40,7 @@ import { EmbedDocumentCompleted } from './embed-document-completed'; import { EmbedDocumentFields } from './embed-document-fields'; import { EmbedDocumentRejected } from './embed-document-rejected'; -export type EmbedSignDocumentClientPageProps = { +export type EmbedSignDocumentV1ClientPageProps = { token: string; documentId: number; envelopeId: string; @@ -55,7 +55,7 @@ export type EmbedSignDocumentClientPageProps = { allRecipients?: RecipientWithFields[]; }; -export const EmbedSignDocumentClientPage = ({ +export const EmbedSignDocumentV1ClientPage = ({ token, documentId, envelopeId, @@ -68,7 +68,7 @@ export const EmbedSignDocumentClientPage = ({ hidePoweredBy = false, allowWhitelabelling = false, allRecipients = [], -}: EmbedSignDocumentClientPageProps) => { +}: EmbedSignDocumentV1ClientPageProps) => { const { _ } = useLingui(); const { toast } = useToast(); diff --git a/apps/remix/app/components/embed/embed-document-signing-page-v2.tsx b/apps/remix/app/components/embed/embed-document-signing-page-v2.tsx new file mode 100644 index 000000000..a6069e286 --- /dev/null +++ b/apps/remix/app/components/embed/embed-document-signing-page-v2.tsx @@ -0,0 +1,232 @@ +import { useEffect, useLayoutEffect, useState } from 'react'; + +import { useLingui } from '@lingui/react'; + +import { mapSecondaryIdToDocumentId } from '@documenso/lib/utils/envelope'; + +import { ZSignDocumentEmbedDataSchema } from '~/types/embed-document-sign-schema'; +import { injectCss } from '~/utils/css-vars'; + +import { DocumentSigningPageViewV2 } from '../general/document-signing/document-signing-page-view-v2'; +import { useRequiredEnvelopeSigningContext } from '../general/document-signing/envelope-signing-provider'; +import { EmbedClientLoading } from './embed-client-loading'; +import { EmbedDocumentCompleted } from './embed-document-completed'; +import { EmbedDocumentRejected } from './embed-document-rejected'; +import { EmbedSigningProvider } from './embed-signing-context'; + +export type EmbedSignDocumentV2ClientPageProps = { + hidePoweredBy?: boolean; + allowWhitelabelling?: boolean; +}; + +export const EmbedSignDocumentV2ClientPage = ({ + hidePoweredBy = false, + allowWhitelabelling = false, +}: EmbedSignDocumentV2ClientPageProps) => { + const { _ } = useLingui(); + + const { envelope, recipient, envelopeData, setFullName, fullName } = + useRequiredEnvelopeSigningContext(); + + const { isCompleted, isRejected, recipientSignature } = envelopeData; + + // !: Not used at the moment, may be removed in the future. + // const [hasDocumentLoaded, setHasDocumentLoaded] = useState(false); + const [hasFinishedInit, setHasFinishedInit] = useState(false); + const [allowDocumentRejection, setAllowDocumentRejection] = useState(false); + const [isNameLocked, setIsNameLocked] = useState(false); + + const onDocumentCompleted = (data: { + token: string; + documentId: number; + envelopeId: string; + recipientId: number; + }) => { + if (window.parent) { + window.parent.postMessage( + { + action: 'document-completed', + data, + }, + '*', + ); + } + }; + + const onDocumentError = () => { + if (window.parent) { + window.parent.postMessage( + { + action: 'document-error', + data: null, + }, + '*', + ); + } + }; + + const onDocumentReady = () => { + if (window.parent) { + window.parent.postMessage( + { + action: 'document-ready', + data: null, + }, + '*', + ); + } + }; + + const onFieldSigned = (data: { fieldId?: number; value?: string; isBase64?: boolean }) => { + if (window.parent) { + window.parent.postMessage( + { + action: 'field-signed', + data, + }, + '*', + ); + } + }; + + const onFieldUnsigned = (data: { fieldId?: number }) => { + if (window.parent) { + window.parent.postMessage( + { + action: 'field-unsigned', + data, + }, + '*', + ); + } + }; + + const onDocumentRejected = (data: { + token: string; + documentId: number; + envelopeId: string; + recipientId: number; + reason?: string; + }) => { + if (window.parent) { + window.parent.postMessage( + { + action: 'document-rejected', + data, + }, + '*', + ); + } + }; + + useLayoutEffect(() => { + const hash = window.location.hash.slice(1); + + try { + const data = ZSignDocumentEmbedDataSchema.parse(JSON.parse(decodeURIComponent(atob(hash)))); + + if (!isCompleted && data.name) { + setFullName(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); + + 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 setters are stable we still want to ensure we're avoiding + // !: re-renders. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [allowWhitelabelling]); + + useEffect(() => { + if (hasFinishedInit) { + onDocumentReady(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [hasFinishedInit]); + + // Listen for document completion events from the envelope signing context + useEffect(() => { + if (isCompleted) { + onDocumentCompleted({ + token: recipient.token, + envelopeId: envelope.id, + documentId: mapSecondaryIdToDocumentId(envelope.secondaryId), + recipientId: recipient.id, + }); + } + }, [isCompleted, envelope.id, recipient.id, recipient.token]); + + // Listen for document rejection events + useEffect(() => { + if (isRejected) { + onDocumentRejected({ + token: recipient.token, + envelopeId: envelope.id, + documentId: mapSecondaryIdToDocumentId(envelope.secondaryId), + recipientId: recipient.id, + }); + } + }, [isRejected, envelope.id, recipient.id, recipient.token]); + + if (isRejected) { + return ; + } + + if (isCompleted) { + return ( + + ); + } + + return ( + +
+ {!hasFinishedInit && } + + +
+
+ ); +}; diff --git a/apps/remix/app/components/embed/embed-signing-context.tsx b/apps/remix/app/components/embed/embed-signing-context.tsx new file mode 100644 index 000000000..408bdff49 --- /dev/null +++ b/apps/remix/app/components/embed/embed-signing-context.tsx @@ -0,0 +1,101 @@ +import { createContext, useContext } from 'react'; + +export type EmbedSigningContextValue = { + isEmbed: true; + allowDocumentRejection: boolean; + isNameLocked: boolean; + isEmailLocked: boolean; + hidePoweredBy: boolean; + onDocumentCompleted: (data: { + token: string; + documentId: number; + envelopeId: string; + recipientId: number; + }) => void; + onDocumentError: () => void; + onDocumentRejected: (data: { + token: string; + documentId: number; + envelopeId: string; + recipientId: number; + reason?: string; + }) => void; + onDocumentReady: () => void; + onFieldSigned: (data: { fieldId?: number; value?: string; isBase64?: boolean }) => void; + onFieldUnsigned: (data: { fieldId?: number }) => void; +}; + +const EmbedSigningContext = createContext(null); + +export const useEmbedSigningContext = () => { + return useContext(EmbedSigningContext); +}; + +export const useRequiredEmbedSigningContext = () => { + const context = useEmbedSigningContext(); + + if (!context) { + throw new Error('useRequiredEmbedSigningContext must be used within EmbedSigningProvider'); + } + + return context; +}; + +export type EmbedSigningProviderProps = { + allowDocumentRejection?: boolean; + isNameLocked?: boolean; + isEmailLocked?: boolean; + hidePoweredBy?: boolean; + onDocumentCompleted: (data: { + token: string; + documentId: number; + envelopeId: string; + recipientId: number; + }) => void; + onDocumentError: () => void; + onDocumentRejected: (data: { + token: string; + documentId: number; + envelopeId: string; + recipientId: number; + reason?: string; + }) => void; + onDocumentReady: () => void; + onFieldSigned: (data: { fieldId?: number; value?: string; isBase64?: boolean }) => void; + onFieldUnsigned: (data: { fieldId?: number }) => void; + children: React.ReactNode; +}; + +export const EmbedSigningProvider = ({ + allowDocumentRejection = false, + isNameLocked = false, + isEmailLocked = true, + hidePoweredBy = false, + onDocumentCompleted, + onDocumentError, + onDocumentRejected, + onDocumentReady, + onFieldSigned, + onFieldUnsigned, + children, +}: EmbedSigningProviderProps) => { + return ( + + {children} + + ); +}; diff --git a/apps/remix/app/components/general/document-signing/document-signing-complete-dialog.tsx b/apps/remix/app/components/general/document-signing/document-signing-complete-dialog.tsx index e910b7060..42a81408a 100644 --- a/apps/remix/app/components/general/document-signing/document-signing-complete-dialog.tsx +++ b/apps/remix/app/components/general/document-signing/document-signing-complete-dialog.tsx @@ -34,6 +34,7 @@ import { } from '@documenso/ui/primitives/form/form'; import { Input } from '@documenso/ui/primitives/input'; +import { useEmbedSigningContext } from '~/components/embed/embed-signing-context'; import { AccessAuth2FAForm } from '~/components/general/document-signing/access-auth-2fa-form'; import { DocumentSigningDisclosure } from '~/components/general/document-signing/document-signing-disclosure'; @@ -102,6 +103,8 @@ export const DocumentSigningCompleteDialog = ({ const { derivedRecipientAccessAuth } = useRequiredDocumentSigningAuthContext(); + const { isNameLocked, isEmailLocked } = useEmbedSigningContext() || {}; + const form = useForm({ resolver: allowDictateNextSigner ? zodResolver(ZNextSignerFormSchema) : undefined, defaultValues: { @@ -267,7 +270,12 @@ export const DocumentSigningCompleteDialog = ({ Your Name - + @@ -289,6 +297,7 @@ export const DocumentSigningCompleteDialog = ({ type="email" className="mt-2" placeholder={t`Enter your email`} + disabled={!!field.value && isEmailLocked} /> diff --git a/apps/remix/app/components/general/document-signing/document-signing-mobile-widget.tsx b/apps/remix/app/components/general/document-signing/document-signing-mobile-widget.tsx index c066de401..91e58edf3 100644 --- a/apps/remix/app/components/general/document-signing/document-signing-mobile-widget.tsx +++ b/apps/remix/app/components/general/document-signing/document-signing-mobile-widget.tsx @@ -8,6 +8,9 @@ import { match } from 'ts-pattern'; import { Button } from '@documenso/ui/primitives/button'; +import { useEmbedSigningContext } from '~/components/embed/embed-signing-context'; + +import { BrandingLogo } from '../branding-logo'; import EnvelopeSignerForm from '../envelope-signing/envelope-signer-form'; import { EnvelopeSignerCompleteDialog } from '../envelope-signing/envelope-signing-complete-dialog'; import { useRequiredEnvelopeSigningContext } from './envelope-signing-provider'; @@ -15,6 +18,8 @@ import { useRequiredEnvelopeSigningContext } from './envelope-signing-provider'; export const DocumentSigningMobileWidget = () => { const [isExpanded, setIsExpanded] = useState(false); + const { hidePoweredBy = true } = useEmbedSigningContext() || {}; + const { recipientFieldsRemaining, recipient, requiredRecipientFields } = useRequiredEnvelopeSigningContext(); @@ -29,7 +34,7 @@ export const DocumentSigningMobileWidget = () => { return (
-
+
{/* Main Header Bar */}
@@ -114,6 +119,13 @@ export const DocumentSigningMobileWidget = () => { {isExpanded && (
+ + {!hidePoweredBy && ( +
+ Powered by + +
+ )}
)}
diff --git a/apps/remix/app/components/general/document-signing/document-signing-page-view-v2.tsx b/apps/remix/app/components/general/document-signing/document-signing-page-view-v2.tsx index 6b1a695ff..5b0c30102 100644 --- a/apps/remix/app/components/general/document-signing/document-signing-page-view-v2.tsx +++ b/apps/remix/app/components/general/document-signing/document-signing-page-view-v2.tsx @@ -22,7 +22,9 @@ import { SignFieldNameDialog } from '~/components/dialogs/sign-field-name-dialog import { SignFieldNumberDialog } from '~/components/dialogs/sign-field-number-dialog'; import { SignFieldSignatureDialog } from '~/components/dialogs/sign-field-signature-dialog'; import { SignFieldTextDialog } from '~/components/dialogs/sign-field-text-dialog'; +import { useEmbedSigningContext } from '~/components/embed/embed-signing-context'; +import { BrandingLogo } from '../branding-logo'; import { DocumentSigningAttachmentsPopover } from '../document-signing/document-signing-attachments-popover'; import { EnvelopeItemSelector } from '../envelope-editor/envelope-file-selector'; import EnvelopeSignerForm from '../envelope-signing/envelope-signer-form'; @@ -48,6 +50,13 @@ export const DocumentSigningPageViewV2 = () => { selectedAssistantRecipientFields, } = useRequiredEnvelopeSigningContext(); + const { + isEmbed = false, + allowDocumentRejection = true, + hidePoweredBy = true, + onDocumentRejected, + } = useEmbedSigningContext() || {}; + /** * The total remaining fields remaining for the current recipient or selected assistant recipient. * @@ -77,7 +86,7 @@ export const DocumentSigningPageViewV2 = () => { {/* Main Content Area */}
{/* Left Section - Step Navigation */} -
+

{match(recipient.role) @@ -107,7 +116,7 @@ export const DocumentSigningPageViewV2 = () => { />

-
+
@@ -116,7 +125,7 @@ export const DocumentSigningPageViewV2 = () => { {/* Quick Actions. */} {!isDirectTemplate && ( -
+

Actions

@@ -145,10 +154,21 @@ export const DocumentSigningPageViewV2 = () => { } /> - {envelope.type === EnvelopeType.DOCUMENT && ( + {envelope.type === EnvelopeType.DOCUMENT && allowDocumentRejection && ( + onDocumentRejected({ + token: recipient.token, + documentId: mapSecondaryIdToDocumentId(envelope.secondaryId), + envelopeId: envelope.id, + recipientId: recipient.id, + reason, + })) + } trigger={
)} - {/* Footer of left sidebar. */} -
- +
+ {/* Footer of left sidebar. */} + {!isEmbed && ( +
+ +
+ )}
-
+
{/* Horizontal envelope item selector */} {envelopeItems.length > 1 && ( @@ -202,7 +226,7 @@ export const DocumentSigningPageViewV2 = () => { )} {/* Document View */} -
+
{currentEnvelopeItem ? ( { )} {/* Mobile widget - Additional padding to allow users to scroll */} -
+
+ + {!hidePoweredBy && ( + + Powered by + + + )}
diff --git a/apps/remix/app/components/general/document-signing/envelope-signing-provider.tsx b/apps/remix/app/components/general/document-signing/envelope-signing-provider.tsx index eb6e6275f..ddaf3c355 100644 --- a/apps/remix/app/components/general/document-signing/envelope-signing-provider.tsx +++ b/apps/remix/app/components/general/document-signing/envelope-signing-provider.tsx @@ -56,7 +56,7 @@ export type EnvelopeSigningContextValue = { _fieldId: number, _value: TSignEnvelopeFieldValue, authOptions?: TRecipientActionAuth, - ) => Promise; + ) => Promise>; }; const EnvelopeSigningContext = createContext(null); @@ -296,16 +296,19 @@ export const EnvelopeSigningProvider = ({ ) => { // Set the field locally for direct templates. if (isDirectTemplate) { - handleDirectTemplateFieldInsertion(fieldId, fieldValue); - return; + const signedField = handleDirectTemplateFieldInsertion(fieldId, fieldValue); + + return signedField; } - await signEnvelopeField({ + const { signedField } = await signEnvelopeField({ token: envelopeData.recipient.token, fieldId, fieldValue, authOptions, }); + + return signedField; }; const handleDirectTemplateFieldInsertion = ( @@ -363,6 +366,8 @@ export const EnvelopeSigningProvider = ({ fields: prev.recipient.fields.map((field) => (field.id === fieldId ? updatedField : field)), }, })); + + return updatedField; }; return ( diff --git a/apps/remix/app/components/general/envelope-editor/envelope-file-selector.tsx b/apps/remix/app/components/general/envelope-editor/envelope-file-selector.tsx index 1d570bed7..144a2bc41 100644 --- a/apps/remix/app/components/general/envelope-editor/envelope-file-selector.tsx +++ b/apps/remix/app/components/general/envelope-editor/envelope-file-selector.tsx @@ -29,7 +29,7 @@ export const EnvelopeItemSelector = ({ {...buttonProps} >
diff --git a/apps/remix/app/components/general/envelope-signing/envelope-signer-form.tsx b/apps/remix/app/components/general/envelope-signing/envelope-signer-form.tsx index 98a9173ee..e010e5821 100644 --- a/apps/remix/app/components/general/envelope-signing/envelope-signer-form.tsx +++ b/apps/remix/app/components/general/envelope-signing/envelope-signer-form.tsx @@ -8,6 +8,8 @@ import { Label } from '@documenso/ui/primitives/label'; import { RadioGroup, RadioGroupItem } from '@documenso/ui/primitives/radio-group'; import { SignaturePadDialog } from '@documenso/ui/primitives/signature-pad/signature-pad-dialog'; +import { useEmbedSigningContext } from '~/components/embed/embed-signing-context'; + import { useRequiredEnvelopeSigningContext } from '../document-signing/envelope-signing-provider'; export default function EnvelopeSignerForm() { @@ -25,6 +27,8 @@ export default function EnvelopeSignerForm() { setSelectedAssistantRecipientId, } = useRequiredEnvelopeSigningContext(); + const { isNameLocked, isEmailLocked } = useEmbedSigningContext() || {}; + const hasSignatureField = useMemo(() => { return recipientFields.some((field) => field.type === FieldType.SIGNATURE); }, [recipientFields]); @@ -37,7 +41,7 @@ export default function EnvelopeSignerForm() { if (recipient.role === RecipientRole.ASSISTANT) { return ( -
+
setFullName(e.target.value.trimStart())} + disabled={isNameLocked} + onChange={(e) => !isNameLocked && setFullName(e.target.value.trimStart())} />
diff --git a/apps/remix/app/components/general/envelope-signing/envelope-signer-header.tsx b/apps/remix/app/components/general/envelope-signing/envelope-signer-header.tsx index b48ee0c55..bf7b2a971 100644 --- a/apps/remix/app/components/general/envelope-signing/envelope-signer-header.tsx +++ b/apps/remix/app/components/general/envelope-signing/envelope-signer-header.tsx @@ -16,6 +16,7 @@ import { import { Separator } from '@documenso/ui/primitives/separator'; import { EnvelopeDownloadDialog } from '~/components/dialogs/envelope-download-dialog'; +import { useEmbedSigningContext } from '~/components/embed/embed-signing-context'; import { BrandingLogo } from '~/components/general/branding-logo'; import { BrandingLogoIcon } from '../branding-logo-icon'; @@ -28,7 +29,7 @@ export const EnvelopeSignerHeader = () => { useRequiredEnvelopeSigningContext(); return ( -