diff --git a/apps/docs/content/docs/developers/api/templates.mdx b/apps/docs/content/docs/developers/api/templates.mdx index 8f5c5b667..06e47ebad 100644 --- a/apps/docs/content/docs/developers/api/templates.mdx +++ b/apps/docs/content/docs/developers/api/templates.mdx @@ -463,6 +463,7 @@ const response = await fetch(`${BASE_URL}/template/use`, { typedSignatureEnabled: true, uploadSignatureEnabled: false, drawSignatureEnabled: true, + qrSignatureEnabled: true, }, distributeDocument: true, }), @@ -483,6 +484,7 @@ const response = await fetch(`${BASE_URL}/template/use`, { | `typedSignatureEnabled` | boolean | Allow typed signatures | | `uploadSignatureEnabled` | boolean | Allow uploaded signature images | | `drawSignatureEnabled` | boolean | Allow drawn signatures | +| `qrSignatureEnabled` | boolean | Allow QR code handoff to a mobile device | --- diff --git a/apps/docs/content/docs/developers/embedding/direct-links.mdx b/apps/docs/content/docs/developers/embedding/direct-links.mdx index 87e1ac75c..00af82023 100644 --- a/apps/docs/content/docs/developers/embedding/direct-links.mdx +++ b/apps/docs/content/docs/developers/embedding/direct-links.mdx @@ -390,6 +390,7 @@ const response = await fetch(`${BASE_URL}/template/update`, { typedSignatureEnabled: true, // Allow typed signatures drawSignatureEnabled: true, // Allow drawn signatures uploadSignatureEnabled: false, // Disable uploaded signatures + qrSignatureEnabled: true, // Allow QR code handoff to a mobile device }, }), }); diff --git a/apps/docs/content/docs/developers/webhooks/events.mdx b/apps/docs/content/docs/developers/webhooks/events.mdx index 9f63f78ac..722afced6 100644 --- a/apps/docs/content/docs/developers/webhooks/events.mdx +++ b/apps/docs/content/docs/developers/webhooks/events.mdx @@ -68,6 +68,7 @@ All webhook events share a common structure: | `typedSignatureEnabled` | boolean | Whether typed signatures are allowed | | `uploadSignatureEnabled` | boolean | Whether uploaded signatures are allowed | | `drawSignatureEnabled` | boolean | Whether drawn signatures are allowed | +| `qrSignatureEnabled` | boolean | Whether QR code handoff to a mobile device is allowed | | `language` | string | Document language code | | `distributionMethod` | string | How document is distributed | | `emailSettings` | object? | Custom email settings for this document | @@ -138,6 +139,7 @@ Triggered when a new document is created. "typedSignatureEnabled": true, "uploadSignatureEnabled": true, "drawSignatureEnabled": true, + "qrSignatureEnabled": true, "language": "en", "distributionMethod": "EMAIL", "emailSettings": null @@ -230,6 +232,7 @@ The document status changes to `PENDING` and recipients have `sendStatus: "SENT" "typedSignatureEnabled": true, "uploadSignatureEnabled": true, "drawSignatureEnabled": true, + "qrSignatureEnabled": true, "language": "en", "distributionMethod": "EMAIL", "emailSettings": null @@ -422,6 +425,7 @@ The document status changes to `COMPLETED` and `completedAt` is set. "typedSignatureEnabled": true, "uploadSignatureEnabled": true, "drawSignatureEnabled": true, + "qrSignatureEnabled": true, "language": "en", "distributionMethod": "EMAIL", "emailSettings": null @@ -599,6 +603,7 @@ This event is **not** triggered when a recipient hides a document from their inb "typedSignatureEnabled": true, "uploadSignatureEnabled": true, "drawSignatureEnabled": true, + "qrSignatureEnabled": true, "language": "en", "distributionMethod": "EMAIL", "emailSettings": null diff --git a/apps/remix/app/components/dialogs/sign-field-signature-dialog.tsx b/apps/remix/app/components/dialogs/sign-field-signature-dialog.tsx index 1779cfa6c..e0fba94b3 100644 --- a/apps/remix/app/components/dialogs/sign-field-signature-dialog.tsx +++ b/apps/remix/app/components/dialogs/sign-field-signature-dialog.tsx @@ -13,10 +13,19 @@ export type SignFieldSignatureDialogProps = { typedSignatureEnabled?: boolean; uploadSignatureEnabled?: boolean; drawSignatureEnabled?: boolean; + qrSignatureEnabled?: boolean; }; export const SignFieldSignatureDialog = createCallable( - ({ call, fullName, typedSignatureEnabled, uploadSignatureEnabled, drawSignatureEnabled, initialSignature }) => { + ({ + call, + fullName, + typedSignatureEnabled, + uploadSignatureEnabled, + drawSignatureEnabled, + qrSignatureEnabled, + initialSignature, + }) => { const [localSignature, setLocalSignature] = useState(initialSignature); return ( @@ -36,6 +45,7 @@ export const SignFieldSignatureDialog = createCallable 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 20a0a23fa..c946912be 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 @@ -470,6 +470,7 @@ export const EmbedDirectTemplateClientPage = ({ typedSignatureEnabled={metadata?.typedSignatureEnabled} uploadSignatureEnabled={metadata?.uploadSignatureEnabled} drawSignatureEnabled={metadata?.drawSignatureEnabled} + qrSignatureEnabled={metadata?.qrSignatureEnabled} /> )} diff --git a/apps/remix/app/components/embed/embed-document-fields.tsx b/apps/remix/app/components/embed/embed-document-fields.tsx index d2b4ada91..58a43c934 100644 --- a/apps/remix/app/components/embed/embed-document-fields.tsx +++ b/apps/remix/app/components/embed/embed-document-fields.tsx @@ -33,7 +33,12 @@ export type EmbedDocumentFieldsProps = { fields: Field[]; metadata?: Pick< DocumentMeta, - 'timezone' | 'dateFormat' | 'typedSignatureEnabled' | 'uploadSignatureEnabled' | 'drawSignatureEnabled' + | 'timezone' + | 'dateFormat' + | 'typedSignatureEnabled' + | 'uploadSignatureEnabled' + | 'drawSignatureEnabled' + | 'qrSignatureEnabled' > | null; onSignField?: (value: TSignFieldWithTokenMutationSchema) => Promise | void; onUnsignField?: (value: TRemovedSignedFieldWithTokenMutationSchema) => Promise | void; @@ -53,6 +58,7 @@ export const EmbedDocumentFields = ({ fields, metadata, onSignField, onUnsignFie typedSignatureEnabled={metadata?.typedSignatureEnabled} uploadSignatureEnabled={metadata?.uploadSignatureEnabled} drawSignatureEnabled={metadata?.drawSignatureEnabled} + qrSignatureEnabled={metadata?.qrSignatureEnabled} /> )) .with(FieldType.INITIALS, () => ( diff --git a/apps/remix/app/components/embed/embed-document-signing-page-v1.tsx b/apps/remix/app/components/embed/embed-document-signing-page-v1.tsx index 669f5274c..17b7a9983 100644 --- a/apps/remix/app/components/embed/embed-document-signing-page-v1.tsx +++ b/apps/remix/app/components/embed/embed-document-signing-page-v1.tsx @@ -461,6 +461,7 @@ export const EmbedSignDocumentV1ClientPage = ({ typedSignatureEnabled={metadata?.typedSignatureEnabled} uploadSignatureEnabled={metadata?.uploadSignatureEnabled} drawSignatureEnabled={metadata?.drawSignatureEnabled} + qrSignatureEnabled={metadata?.qrSignatureEnabled} /> )} 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 index b144d1c46..80ab2ab26 100644 --- 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 @@ -313,6 +313,7 @@ export const MultiSignDocumentSigningView = ({ typedSignatureEnabled={document.documentMeta?.typedSignatureEnabled} uploadSignatureEnabled={document.documentMeta?.uploadSignatureEnabled} drawSignatureEnabled={document.documentMeta?.drawSignatureEnabled} + qrSignatureEnabled={document.documentMeta?.qrSignatureEnabled} /> )} diff --git a/apps/remix/app/components/forms/document-preferences-form.tsx b/apps/remix/app/components/forms/document-preferences-form.tsx index cdf39eb51..f777e4d72 100644 --- a/apps/remix/app/components/forms/document-preferences-form.tsx +++ b/apps/remix/app/components/forms/document-preferences-form.tsx @@ -79,6 +79,7 @@ type SettingsSubset = Pick< | 'typedSignatureEnabled' | 'uploadSignatureEnabled' | 'drawSignatureEnabled' + | 'qrSignatureEnabled' | 'defaultRecipients' | 'delegateDocumentOwnership' | 'aiFeaturesEnabled' diff --git a/apps/remix/app/components/general/admin-global-settings-section.tsx b/apps/remix/app/components/general/admin-global-settings-section.tsx index 760684077..4b7cfd077 100644 --- a/apps/remix/app/components/general/admin-global-settings-section.tsx +++ b/apps/remix/app/components/general/admin-global-settings-section.tsx @@ -156,6 +156,10 @@ export const AdminGlobalSettingsSection = ({ + QR signature}> + {booleanValue(settings.qrSignatureEnabled, inheritedSettings?.qrSignatureEnabled)} + + Branding}> {booleanValue(settings.brandingEnabled, inheritedSettings?.brandingEnabled)} diff --git a/apps/remix/app/components/general/direct-template/direct-template-signing-form.tsx b/apps/remix/app/components/general/direct-template/direct-template-signing-form.tsx index 179d3306a..c22c81cfe 100644 --- a/apps/remix/app/components/general/direct-template/direct-template-signing-form.tsx +++ b/apps/remix/app/components/general/direct-template/direct-template-signing-form.tsx @@ -269,6 +269,7 @@ export const DirectTemplateSigningForm = ({ typedSignatureEnabled={template.templateMeta?.typedSignatureEnabled} uploadSignatureEnabled={template.templateMeta?.uploadSignatureEnabled} drawSignatureEnabled={template.templateMeta?.drawSignatureEnabled} + qrSignatureEnabled={template.templateMeta?.qrSignatureEnabled} /> )) .with(FieldType.INITIALS, () => ( @@ -408,6 +409,7 @@ export const DirectTemplateSigningForm = ({ typedSignatureEnabled={template.templateMeta?.typedSignatureEnabled} uploadSignatureEnabled={template.templateMeta?.uploadSignatureEnabled} drawSignatureEnabled={template.templateMeta?.drawSignatureEnabled} + qrSignatureEnabled={template.templateMeta?.qrSignatureEnabled} /> diff --git a/apps/remix/app/components/general/document-signing/document-signing-form.tsx b/apps/remix/app/components/general/document-signing/document-signing-form.tsx index cbe467f36..d7d0f24ac 100644 --- a/apps/remix/app/components/general/document-signing/document-signing-form.tsx +++ b/apps/remix/app/components/general/document-signing/document-signing-form.tsx @@ -254,6 +254,7 @@ export const DocumentSigningForm = ({ typedSignatureEnabled={document.documentMeta?.typedSignatureEnabled} uploadSignatureEnabled={document.documentMeta?.uploadSignatureEnabled} drawSignatureEnabled={document.documentMeta?.drawSignatureEnabled} + qrSignatureEnabled={document.documentMeta?.qrSignatureEnabled} /> )} diff --git a/apps/remix/app/components/general/document-signing/document-signing-page-view-v1.tsx b/apps/remix/app/components/general/document-signing/document-signing-page-view-v1.tsx index 1979c63a2..d5d989e59 100644 --- a/apps/remix/app/components/general/document-signing/document-signing-page-view-v1.tsx +++ b/apps/remix/app/components/general/document-signing/document-signing-page-view-v1.tsx @@ -408,6 +408,7 @@ export const DocumentSigningPageViewV1 = ({ typedSignatureEnabled={documentMeta?.typedSignatureEnabled} uploadSignatureEnabled={documentMeta?.uploadSignatureEnabled} drawSignatureEnabled={documentMeta?.drawSignatureEnabled} + qrSignatureEnabled={documentMeta?.qrSignatureEnabled} /> )) .with(FieldType.INITIALS, () => ) diff --git a/apps/remix/app/components/general/document-signing/document-signing-provider.tsx b/apps/remix/app/components/general/document-signing/document-signing-provider.tsx index 0ea3fc502..3081a25c9 100644 --- a/apps/remix/app/components/general/document-signing/document-signing-provider.tsx +++ b/apps/remix/app/components/general/document-signing/document-signing-provider.tsx @@ -33,6 +33,7 @@ export interface DocumentSigningProviderProps { typedSignatureEnabled?: boolean; uploadSignatureEnabled?: boolean; drawSignatureEnabled?: boolean; + qrSignatureEnabled?: boolean; children: React.ReactNode; } @@ -43,6 +44,7 @@ export const DocumentSigningProvider = ({ typedSignatureEnabled = true, uploadSignatureEnabled = true, drawSignatureEnabled = true, + qrSignatureEnabled = true, children, }: DocumentSigningProviderProps) => { const [fullName, setFullName] = useState(initialFullName || ''); @@ -54,7 +56,7 @@ export const DocumentSigningProvider = ({ const sig = initialSignature || ''; const isBase64 = isBase64Image(sig); - if (isBase64 && (uploadSignatureEnabled || drawSignatureEnabled)) { + if (isBase64 && (uploadSignatureEnabled || drawSignatureEnabled || qrSignatureEnabled)) { return sig; } diff --git a/apps/remix/app/components/general/document-signing/document-signing-signature-field.tsx b/apps/remix/app/components/general/document-signing/document-signing-signature-field.tsx index 3ff8b1d68..fb837c6e4 100644 --- a/apps/remix/app/components/general/document-signing/document-signing-signature-field.tsx +++ b/apps/remix/app/components/general/document-signing/document-signing-signature-field.tsx @@ -34,6 +34,7 @@ export type DocumentSigningSignatureFieldProps = { typedSignatureEnabled?: boolean; uploadSignatureEnabled?: boolean; drawSignatureEnabled?: boolean; + qrSignatureEnabled?: boolean; }; export const DocumentSigningSignatureField = ({ @@ -43,6 +44,7 @@ export const DocumentSigningSignatureField = ({ typedSignatureEnabled, uploadSignatureEnabled, drawSignatureEnabled, + qrSignatureEnabled, }: DocumentSigningSignatureFieldProps) => { const { _ } = useLingui(); const { toast } = useToast(); @@ -279,6 +281,7 @@ export const DocumentSigningSignatureField = ({ typedSignatureEnabled={typedSignatureEnabled} uploadSignatureEnabled={uploadSignatureEnabled} drawSignatureEnabled={drawSignatureEnabled} + qrSignatureEnabled={qrSignatureEnabled} /> 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 f0b00fec9..07335d0bf 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 @@ -172,7 +172,9 @@ export const EnvelopeSigningProvider = ({ if ( !sig && - (envelope.documentMeta.uploadSignatureEnabled || envelope.documentMeta.drawSignatureEnabled) && + (envelope.documentMeta.uploadSignatureEnabled || + envelope.documentMeta.drawSignatureEnabled || + envelope.documentMeta.qrSignatureEnabled) && envelopeData.recipientSignature?.signatureImageAsBase64 ) { return envelopeData.recipientSignature.signatureImageAsBase64; @@ -182,7 +184,12 @@ export const EnvelopeSigningProvider = ({ return envelopeData.recipientSignature.typedSignature; } - if (isBase64 && (envelope.documentMeta.uploadSignatureEnabled || envelope.documentMeta.drawSignatureEnabled)) { + if ( + isBase64 && + (envelope.documentMeta.uploadSignatureEnabled || + envelope.documentMeta.drawSignatureEnabled || + envelope.documentMeta.qrSignatureEnabled) + ) { return sig; } diff --git a/apps/remix/app/components/general/document/document-edit-form.tsx b/apps/remix/app/components/general/document/document-edit-form.tsx index 051ee562c..87d8fc487 100644 --- a/apps/remix/app/components/general/document/document-edit-form.tsx +++ b/apps/remix/app/components/general/document/document-edit-form.tsx @@ -174,6 +174,7 @@ export const DocumentEditForm = ({ className, initialDocument, documentRootPath typedSignatureEnabled: signatureTypes.includes(DocumentSignatureType.TYPE), uploadSignatureEnabled: signatureTypes.includes(DocumentSignatureType.UPLOAD), drawSignatureEnabled: signatureTypes.includes(DocumentSignatureType.DRAW), + qrSignatureEnabled: signatureTypes.includes(DocumentSignatureType.QR), }, }); }; diff --git a/apps/remix/app/components/general/envelope-editor/envelope-editor-settings-dialog.tsx b/apps/remix/app/components/general/envelope-editor/envelope-editor-settings-dialog.tsx index ea60d1819..b839ad9ef 100644 --- a/apps/remix/app/components/general/envelope-editor/envelope-editor-settings-dialog.tsx +++ b/apps/remix/app/components/general/envelope-editor/envelope-editor-settings-dialog.tsx @@ -278,6 +278,7 @@ export const EnvelopeEditorSettingsDialog = ({ trigger, ...props }: EnvelopeEdit drawSignatureEnabled: signatureTypes.includes(DocumentSignatureType.DRAW), typedSignatureEnabled: signatureTypes.includes(DocumentSignatureType.TYPE), uploadSignatureEnabled: signatureTypes.includes(DocumentSignatureType.UPLOAD), + qrSignatureEnabled: signatureTypes.includes(DocumentSignatureType.QR), envelopeExpirationPeriod, reminderSettings, }, 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 010a89fa3..d0e00c3f5 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 @@ -121,6 +121,7 @@ export default function EnvelopeSignerForm() { typedSignatureEnabled={envelope.documentMeta.typedSignatureEnabled} uploadSignatureEnabled={envelope.documentMeta.uploadSignatureEnabled} drawSignatureEnabled={envelope.documentMeta.drawSignatureEnabled} + qrSignatureEnabled={envelope.documentMeta.qrSignatureEnabled} /> )} diff --git a/apps/remix/app/components/general/envelope-signing/envelope-signer-page-renderer.tsx b/apps/remix/app/components/general/envelope-signing/envelope-signer-page-renderer.tsx index 8caab7ac0..42f04029f 100644 --- a/apps/remix/app/components/general/envelope-signing/envelope-signer-page-renderer.tsx +++ b/apps/remix/app/components/general/envelope-signing/envelope-signer-page-renderer.tsx @@ -384,6 +384,7 @@ export const EnvelopeSignerPageRenderer = ({ pageData }: { pageData: PageRenderD typedSignatureEnabled: envelope.documentMeta.typedSignatureEnabled, uploadSignatureEnabled: envelope.documentMeta.uploadSignatureEnabled, drawSignatureEnabled: envelope.documentMeta.drawSignatureEnabled, + qrSignatureEnabled: envelope.documentMeta.qrSignatureEnabled, }) .then(async (payload) => { if (!payload) { diff --git a/apps/remix/app/components/general/template/template-edit-form.tsx b/apps/remix/app/components/general/template/template-edit-form.tsx index ede3dfb6c..4c88462a2 100644 --- a/apps/remix/app/components/general/template/template-edit-form.tsx +++ b/apps/remix/app/components/general/template/template-edit-form.tsx @@ -137,6 +137,7 @@ export const TemplateEditForm = ({ initialTemplate, className, templateRootPath typedSignatureEnabled: signatureTypes.includes(DocumentSignatureType.TYPE), uploadSignatureEnabled: signatureTypes.includes(DocumentSignatureType.UPLOAD), drawSignatureEnabled: signatureTypes.includes(DocumentSignatureType.DRAW), + qrSignatureEnabled: signatureTypes.includes(DocumentSignatureType.QR), language: isValidLanguageCode(data.meta.language) ? data.meta.language : undefined, }, }); diff --git a/apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.document.tsx b/apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.document.tsx index 741519a40..7b8f2e6cf 100644 --- a/apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.document.tsx +++ b/apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.document.tsx @@ -88,6 +88,7 @@ export default function OrganisationSettingsDocumentPage() { typedSignatureEnabled: signatureTypes.includes(DocumentSignatureType.TYPE), uploadSignatureEnabled: signatureTypes.includes(DocumentSignatureType.UPLOAD), drawSignatureEnabled: signatureTypes.includes(DocumentSignatureType.DRAW), + qrSignatureEnabled: signatureTypes.includes(DocumentSignatureType.QR), delegateDocumentOwnership, aiFeaturesEnabled, envelopeExpirationPeriod: envelopeExpirationPeriod ?? undefined, diff --git a/apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.document.tsx b/apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.document.tsx index 027a6f170..1c5bf134c 100644 --- a/apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.document.tsx +++ b/apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.document.tsx @@ -76,11 +76,13 @@ export default function TeamsSettingsPage() { typedSignatureEnabled: null, uploadSignatureEnabled: null, drawSignatureEnabled: null, + qrSignatureEnabled: null, } : { typedSignatureEnabled: signatureTypes.includes(DocumentSignatureType.TYPE), uploadSignatureEnabled: signatureTypes.includes(DocumentSignatureType.UPLOAD), drawSignatureEnabled: signatureTypes.includes(DocumentSignatureType.DRAW), + qrSignatureEnabled: signatureTypes.includes(DocumentSignatureType.QR), }), delegateDocumentOwnership, }, diff --git a/apps/remix/app/routes/_recipient+/d.$token+/_index.tsx b/apps/remix/app/routes/_recipient+/d.$token+/_index.tsx index dff005fe1..70b54a551 100644 --- a/apps/remix/app/routes/_recipient+/d.$token+/_index.tsx +++ b/apps/remix/app/routes/_recipient+/d.$token+/_index.tsx @@ -215,6 +215,7 @@ const DirectSigningPageV1 = ({ data }: { data: Awaited {sessionData?.user && } diff --git a/apps/remix/app/routes/embed+/_v0+/direct.$token.tsx b/apps/remix/app/routes/embed+/_v0+/direct.$token.tsx index b041e01ca..c764acd0c 100644 --- a/apps/remix/app/routes/embed+/_v0+/direct.$token.tsx +++ b/apps/remix/app/routes/embed+/_v0+/direct.$token.tsx @@ -266,6 +266,7 @@ const EmbedDirectTemplatePageV1 = ({ data }: { data: Awaited diff --git a/apps/remix/app/routes/embed+/_v0+/sign.$token.tsx b/apps/remix/app/routes/embed+/_v0+/sign.$token.tsx index 997bf4e4a..d9822cba0 100644 --- a/apps/remix/app/routes/embed+/_v0+/sign.$token.tsx +++ b/apps/remix/app/routes/embed+/_v0+/sign.$token.tsx @@ -354,6 +354,7 @@ const EmbedSignDocumentPageV1 = ({ data }: { data: Awaited ({ name: signer.name, diff --git a/apps/remix/app/routes/embed+/v1+/authoring+/document.edit.$id.tsx b/apps/remix/app/routes/embed+/v1+/authoring+/document.edit.$id.tsx index 98960b34f..832b850e2 100644 --- a/apps/remix/app/routes/embed+/v1+/authoring+/document.edit.$id.tsx +++ b/apps/remix/app/routes/embed+/v1+/authoring+/document.edit.$id.tsx @@ -101,6 +101,10 @@ export default function EmbeddingAuthoringDocumentEditPage() { types.push(DocumentSignatureType.UPLOAD); } + if (document.documentMeta?.qrSignatureEnabled) { + types.push(DocumentSignatureType.QR); + } + return types; }, [document.documentMeta]); @@ -216,6 +220,10 @@ export default function EmbeddingAuthoringDocumentEditPage() { ? configuration.meta.signatureTypes.length === 0 || configuration.meta.signatureTypes.includes(DocumentSignatureType.UPLOAD) : undefined, + qrSignatureEnabled: configuration.meta.signatureTypes + ? configuration.meta.signatureTypes.length === 0 || + configuration.meta.signatureTypes.includes(DocumentSignatureType.QR) + : undefined, }, recipients: configuration.signers.map((signer) => ({ id: signer.nativeId, diff --git a/apps/remix/app/routes/embed+/v1+/authoring+/template.edit.$id.tsx b/apps/remix/app/routes/embed+/v1+/authoring+/template.edit.$id.tsx index 1ef2bee7e..e458a4bb8 100644 --- a/apps/remix/app/routes/embed+/v1+/authoring+/template.edit.$id.tsx +++ b/apps/remix/app/routes/embed+/v1+/authoring+/template.edit.$id.tsx @@ -101,6 +101,10 @@ export default function EmbeddingAuthoringTemplateEditPage() { types.push(DocumentSignatureType.UPLOAD); } + if (template.templateMeta?.qrSignatureEnabled) { + types.push(DocumentSignatureType.QR); + } + return types; }, [template.templateMeta]); @@ -215,6 +219,10 @@ export default function EmbeddingAuthoringTemplateEditPage() { ? configuration.meta.signatureTypes.length === 0 || configuration.meta.signatureTypes.includes(DocumentSignatureType.UPLOAD) : undefined, + qrSignatureEnabled: configuration.meta.signatureTypes + ? configuration.meta.signatureTypes.length === 0 || + configuration.meta.signatureTypes.includes(DocumentSignatureType.QR) + : undefined, }, recipients: configuration.signers.map((signer) => ({ id: signer.nativeId, diff --git a/apps/remix/app/routes/embed+/v1+/multisign+/_index.tsx b/apps/remix/app/routes/embed+/v1+/multisign+/_index.tsx index 4bafee963..96eedf4d9 100644 --- a/apps/remix/app/routes/embed+/v1+/multisign+/_index.tsx +++ b/apps/remix/app/routes/embed+/v1+/multisign+/_index.tsx @@ -238,6 +238,7 @@ export default function MultisignPage() { typedSignatureEnabled={selectedDocument.documentMeta?.typedSignatureEnabled} uploadSignatureEnabled={selectedDocument.documentMeta?.uploadSignatureEnabled} drawSignatureEnabled={selectedDocument.documentMeta?.drawSignatureEnabled} + qrSignatureEnabled={selectedDocument.documentMeta?.qrSignatureEnabled} > { typedSignatureEnabled: envelope.documentMeta.typedSignatureEnabled, // uploadSignatureEnabled: envelope.documentMeta.uploadSignatureEnabled, // drawSignatureEnabled: envelope.documentMeta.drawSignatureEnabled, // + qrSignatureEnabled: envelope.documentMeta.qrSignatureEnabled, // dateFormat: (envelope.documentMeta.dateFormat as TDocumentMetaDateFormat) ?? undefined, language: envelope.documentMeta.language as SupportedLanguageCodes, }, diff --git a/apps/remix/app/utils/field-signing/signature-field.ts b/apps/remix/app/utils/field-signing/signature-field.ts index 57890e549..4fc3d2c67 100644 --- a/apps/remix/app/utils/field-signing/signature-field.ts +++ b/apps/remix/app/utils/field-signing/signature-field.ts @@ -12,12 +12,21 @@ type HandleSignatureFieldClickOptions = { typedSignatureEnabled?: boolean; uploadSignatureEnabled?: boolean; drawSignatureEnabled?: boolean; + qrSignatureEnabled?: boolean; }; export const handleSignatureFieldClick = async ( options: HandleSignatureFieldClickOptions, ): Promise | null> => { - const { field, fullName, signature, typedSignatureEnabled, uploadSignatureEnabled, drawSignatureEnabled } = options; + const { + field, + fullName, + signature, + typedSignatureEnabled, + uploadSignatureEnabled, + drawSignatureEnabled, + qrSignatureEnabled, + } = options; if (field.type !== FieldType.SIGNATURE) { throw new AppError(AppErrorCode.INVALID_REQUEST, { @@ -40,6 +49,7 @@ export const handleSignatureFieldClick = async ( typedSignatureEnabled, uploadSignatureEnabled, drawSignatureEnabled, + qrSignatureEnabled, }); } diff --git a/package-lock.json b/package-lock.json index 0109d8c0f..ca760bdf1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -33965,6 +33965,7 @@ "tailwind-merge": "^1.14.0", "tailwindcss-animate": "^1.0.7", "ts-pattern": "^5.9.0", + "uqr": "^0.1.2", "zod": "^3.25.76" }, "devDependencies": { diff --git a/packages/api/v1/implementation.ts b/packages/api/v1/implementation.ts index 91d6be1df..2a082bc47 100644 --- a/packages/api/v1/implementation.ts +++ b/packages/api/v1/implementation.ts @@ -438,6 +438,7 @@ export const ApiContractV1Implementation = tsr.router(ApiContractV1, { typedSignatureEnabled: body.meta.typedSignatureEnabled, uploadSignatureEnabled: body.meta.uploadSignatureEnabled, drawSignatureEnabled: body.meta.drawSignatureEnabled, + qrSignatureEnabled: body.meta.qrSignatureEnabled, distributionMethod: body.meta.distributionMethod, emailSettings: body.meta.emailSettings, }, diff --git a/packages/app-tests/e2e/api/v2/envelopes-api.spec.ts b/packages/app-tests/e2e/api/v2/envelopes-api.spec.ts index d8ce43169..5149ed375 100644 --- a/packages/app-tests/e2e/api/v2/envelopes-api.spec.ts +++ b/packages/app-tests/e2e/api/v2/envelopes-api.spec.ts @@ -196,6 +196,7 @@ test.describe('API V2 Envelopes', () => { typedSignatureEnabled: true, uploadSignatureEnabled: false, drawSignatureEnabled: false, + qrSignatureEnabled: false, emailReplyTo: userA.email, emailSettings: { recipientSigningRequest: false, @@ -295,6 +296,7 @@ test.describe('API V2 Envelopes', () => { expect(envelope.documentMeta.typedSignatureEnabled).toBe(payload.meta.typedSignatureEnabled); expect(envelope.documentMeta.uploadSignatureEnabled).toBe(payload.meta.uploadSignatureEnabled); expect(envelope.documentMeta.drawSignatureEnabled).toBe(payload.meta.drawSignatureEnabled); + expect(envelope.documentMeta.qrSignatureEnabled).toBe(payload.meta.qrSignatureEnabled); expect(envelope.documentMeta.emailReplyTo).toBe(payload.meta.emailReplyTo); expect(envelope.documentMeta.emailSettings).toEqual(payload.meta.emailSettings); diff --git a/packages/app-tests/e2e/document-flow/autosave-settings-step.spec.ts b/packages/app-tests/e2e/document-flow/autosave-settings-step.spec.ts index 8c07bdb8a..7618d28c9 100644 --- a/packages/app-tests/e2e/document-flow/autosave-settings-step.spec.ts +++ b/packages/app-tests/e2e/document-flow/autosave-settings-step.spec.ts @@ -158,6 +158,7 @@ test.describe('AutoSave Settings Step', () => { expect(retrieved.documentMeta?.drawSignatureEnabled).toBe(false); expect(retrieved.documentMeta?.typedSignatureEnabled).toBe(false); expect(retrieved.documentMeta?.uploadSignatureEnabled).toBe(true); + expect(retrieved.documentMeta?.qrSignatureEnabled).toBe(true); }).toPass(); }); diff --git a/packages/app-tests/e2e/envelope-editor-v2/envelope-settings.spec.ts b/packages/app-tests/e2e/envelope-editor-v2/envelope-settings.spec.ts index 27e038524..b3afda82d 100644 --- a/packages/app-tests/e2e/envelope-editor-v2/envelope-settings.spec.ts +++ b/packages/app-tests/e2e/envelope-editor-v2/envelope-settings.spec.ts @@ -384,6 +384,7 @@ const assertEnvelopeSettingsPersistedInDatabase = async ({ expect(envelope.documentMeta.drawSignatureEnabled).toBe(true); expect(envelope.documentMeta.typedSignatureEnabled).toBe(true); expect(envelope.documentMeta.uploadSignatureEnabled).toBe(false); + expect(envelope.documentMeta.qrSignatureEnabled).toBe(true); expect(envelope.documentMeta.emailSettings).toMatchObject(DB_EXPECTED_VALUES.emailSettings); const authOptions = parseAuthOptions(envelope.authOptions); diff --git a/packages/app-tests/e2e/organisations/organisation-team-preferences.spec.ts b/packages/app-tests/e2e/organisations/organisation-team-preferences.spec.ts index 336222d93..b646e63d3 100644 --- a/packages/app-tests/e2e/organisations/organisation-team-preferences.spec.ts +++ b/packages/app-tests/e2e/organisations/organisation-team-preferences.spec.ts @@ -56,6 +56,7 @@ test('[ORGANISATIONS]: manage document preferences', async ({ page }) => { expect(teamSettings.typedSignatureEnabled).toEqual(true); expect(teamSettings.uploadSignatureEnabled).toEqual(false); expect(teamSettings.drawSignatureEnabled).toEqual(false); + expect(teamSettings.qrSignatureEnabled).toEqual(true); // Edit the team settings await page.goto(`/t/${team.url}/settings/document`); @@ -90,6 +91,7 @@ test('[ORGANISATIONS]: manage document preferences', async ({ page }) => { expect(updatedTeamSettings.typedSignatureEnabled).toEqual(true); expect(updatedTeamSettings.uploadSignatureEnabled).toEqual(false); expect(updatedTeamSettings.drawSignatureEnabled).toEqual(false); + expect(updatedTeamSettings.qrSignatureEnabled).toEqual(true); const document = await seedTeamDocumentWithMeta(team); @@ -105,6 +107,7 @@ test('[ORGANISATIONS]: manage document preferences', async ({ page }) => { expect(documentMeta.typedSignatureEnabled).toEqual(true); expect(documentMeta.uploadSignatureEnabled).toEqual(false); expect(documentMeta.drawSignatureEnabled).toEqual(false); + expect(documentMeta.qrSignatureEnabled).toEqual(true); expect(documentMeta.language).toEqual('pl'); expect(documentMeta.timezone).toEqual('Europe/London'); expect(documentMeta.dateFormat).toEqual('MM/dd/yyyy'); diff --git a/packages/app-tests/e2e/signature/qr-mobile-signature.spec.ts b/packages/app-tests/e2e/signature/qr-mobile-signature.spec.ts new file mode 100644 index 000000000..adc5b03b0 --- /dev/null +++ b/packages/app-tests/e2e/signature/qr-mobile-signature.spec.ts @@ -0,0 +1,213 @@ +import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer'; +import { prisma } from '@documenso/prisma'; +import { AnonymousVerificationTokenType, FieldType } from '@documenso/prisma/client'; +import { seedPendingDocumentWithFullFields } from '@documenso/prisma/seed/documents'; +import { seedUser } from '@documenso/prisma/seed/users'; +import type { Page } from '@playwright/test'; +import { expect, test } from '@playwright/test'; + +test.describe.configure({ mode: 'parallel' }); + +/** + * Draw a zig-zag onto the drawing canvas so that it passes the minimum + * signature coverage threshold. + */ +const drawOnSignaturePad = async (page: Page) => { + const canvas = page.getByTestId('signature-pad-draw'); + + await canvas.waitFor({ state: 'visible' }); + + let capturedBox: { x: number; y: number; width: number; height: number } | null = null; + + // `boundingBox()` can return null if the canvas is replaced mid-hydration, + // so poll until a measurable element is attached, capturing the box inside + // the retry closure so it is never re-fetched (and re-raced) afterwards. + await expect(async () => { + capturedBox = await canvas.boundingBox(); + + expect(capturedBox).not.toBeNull(); + expect(capturedBox?.width ?? 0).toBeGreaterThan(0); + }).toPass({ timeout: 5_000 }); + + // TS cannot see the closure assignment above, so widen the type back out. + const box = capturedBox as { x: number; y: number; width: number; height: number } | null; + + if (!box) { + throw new Error('Signature pad canvas not found'); + } + + await page.mouse.move(box.x + box.width * 0.15, box.y + box.height * 0.5); + await page.mouse.down(); + + for (let i = 0; i < 8; i++) { + await page.mouse.move(box.x + box.width * (0.15 + i * 0.09), box.y + box.height * (i % 2 === 0 ? 0.25 : 0.75), { + steps: 10, + }); + } + + await page.mouse.up(); +}; + +test('[QR_SIGNATURE]: complete signing via mobile qr handoff', async ({ page, browser }) => { + const { user, team } = await seedUser(); + + const { recipients } = await seedPendingDocumentWithFullFields({ + owner: user, + recipients: ['qr-signer@test.documenso.com'], + teamId: team.id, + fields: [FieldType.SIGNATURE], + }); + + const recipient = recipients[0]; + + await page.goto(`/sign/${recipient.token}`); + + // Wait for the client-side PDF render so we know the page has hydrated + // before interacting with the signature pad. + await page.waitForSelector(PDF_VIEWER_PAGE_SELECTOR); + + // Open the signature dialog and switch to the Mobile tab. + await page.getByTestId('signature-pad-dialog-button').click(); + await page.getByRole('tab', { name: 'Mobile' }).click(); + + // Read the handoff URL rendered beneath the QR code. + await expect(page.getByTestId('signature-pad-qr-url')).toBeVisible(); + const handoffUrl = await page.getByTestId('signature-pad-qr-url').textContent(); + + expect(handoffUrl).toContain('/mobile-signature/'); + + // Open the mobile page in a fully isolated browser context (no shared + // cookies or session) to prove the handoff requires no authentication. + const mobileContext = await browser.newContext(); + const mobilePage = await mobileContext.newPage(); + + await mobilePage.goto(handoffUrl ?? ''); + + await expect(mobilePage.getByRole('heading', { name: 'Draw your signature' })).toBeVisible(); + + await drawOnSignaturePad(mobilePage); + + await mobilePage.getByRole('button', { name: 'Submit' }).click(); + + await expect(mobilePage.getByText('Signature sent')).toBeVisible(); + + await mobileContext.close(); + + // The desktop pad should receive the signature within a poll interval. + await expect(page.getByTestId('signature-pad-qr-preview')).toBeVisible({ timeout: 10_000 }); + + // The session is single-use: the desktop pickup deletes the row on read, and + // a missing row is indistinguishable from an expired one by design. So a + // revisit must show the expired page (not "Signature already sent"), which + // proves the deletion happened. + const revisitContext = await browser.newContext(); + const revisitPage = await revisitContext.newPage(); + + await revisitPage.goto(handoffUrl ?? ''); + + await expect(revisitPage.getByRole('heading', { name: 'This link has expired' })).toBeVisible(); + + await revisitContext.close(); + + // Direct proof of consumption: the token row must be gone from the database. + const consumedToken = (handoffUrl ?? '').split('/mobile-signature/')[1]; + + const consumedRow = await prisma.anonymousVerificationToken.findFirst({ + where: { token: consumedToken }, + }); + + expect(consumedRow).toBeNull(); + + // Confirm and finish signing the document. + await page.getByRole('button', { name: 'Next' }).click(); + + await page.locator('[data-field-type="SIGNATURE"]:not([data-readonly="true"])').first().click(); + + await page.getByRole('button', { name: 'Complete' }).click(); + await page.getByRole('button', { name: 'Sign' }).click(); + + await page.waitForURL(`/sign/${recipient.token}/complete`); + await expect(page.getByText('Document Signed')).toBeVisible(); +}); + +test('[QR_SIGNATURE]: mobile tab hidden when qr disabled', async ({ page }) => { + const { user, team } = await seedUser(); + + const { document, recipients } = await seedPendingDocumentWithFullFields({ + owner: user, + recipients: ['qr-disabled-signer@test.documenso.com'], + teamId: team.id, + fields: [FieldType.SIGNATURE], + }); + + // Seeded documents create their meta row with bare column defaults, which + // leave qrSignatureEnabled true, so disable it directly on the meta row. + await prisma.documentMeta.update({ + where: { id: document.documentMetaId }, + data: { qrSignatureEnabled: false }, + }); + + const recipient = recipients[0]; + + await page.goto(`/sign/${recipient.token}`); + + await page.waitForSelector(PDF_VIEWER_PAGE_SELECTOR); + + await page.getByTestId('signature-pad-dialog-button').click(); + + // Waiting on the Draw tab first guarantees the tab list has rendered before + // asserting the Mobile tab is absent. + await expect(page.getByRole('tab', { name: 'Draw' })).toBeVisible(); + await expect(page.getByRole('tab', { name: 'Mobile' })).not.toBeVisible(); +}); + +test('[QR_SIGNATURE]: mobile tab shown when draw disabled but qr enabled', async ({ page }) => { + const { user, team } = await seedUser(); + + const { document, recipients } = await seedPendingDocumentWithFullFields({ + owner: user, + recipients: ['qr-only-signer@test.documenso.com'], + teamId: team.id, + fields: [FieldType.SIGNATURE], + }); + + // qrSignatureEnabled already defaults to true on seeded metas, but set it + // explicitly so the test still documents the required state if defaults change. + await prisma.documentMeta.update({ + where: { id: document.documentMetaId }, + data: { drawSignatureEnabled: false, qrSignatureEnabled: true }, + }); + + const recipient = recipients[0]; + + await page.goto(`/sign/${recipient.token}`); + + await page.waitForSelector(PDF_VIEWER_PAGE_SELECTOR); + + await page.getByTestId('signature-pad-dialog-button').click(); + + await expect(page.getByRole('tab', { name: 'Mobile' })).toBeVisible(); + await expect(page.getByRole('tab', { name: 'Draw' })).not.toBeVisible(); +}); + +test('[QR_SIGNATURE]: unknown token shows expired page', async ({ page }) => { + await page.goto('/mobile-signature/this-token-does-not-exist'); + + await expect(page.getByRole('heading', { name: 'This link has expired' })).toBeVisible(); +}); + +test('[QR_SIGNATURE]: expired token shows expired page', async ({ page }) => { + const expiredToken = `qr-e2e-expired-${Date.now()}-${Math.floor(Math.random() * 100000)}`; + + await prisma.anonymousVerificationToken.create({ + data: { + type: AnonymousVerificationTokenType.QR_SIGNATURE, + token: expiredToken, + expiresAt: new Date(Date.now() - 60_000), + }, + }); + + await page.goto(`/mobile-signature/${expiredToken}`); + + await expect(page.getByRole('heading', { name: 'This link has expired' })).toBeVisible(); +}); diff --git a/packages/app-tests/e2e/teams/team-signature-settings.spec.ts b/packages/app-tests/e2e/teams/team-signature-settings.spec.ts index 078d0cab7..fc88758bf 100644 --- a/packages/app-tests/e2e/teams/team-signature-settings.spec.ts +++ b/packages/app-tests/e2e/teams/team-signature-settings.spec.ts @@ -25,6 +25,7 @@ test('[TEAMS]: check that default team signature settings are all enabled', asyn await expect(page.getByRole('combobox').filter({ hasText: 'Type' })).toBeVisible(); await expect(page.getByRole('combobox').filter({ hasText: 'Upload' })).toBeVisible(); await expect(page.getByRole('combobox').filter({ hasText: 'Draw' })).toBeVisible(); + await expect(page.getByRole('combobox').filter({ hasText: 'QR code' })).toBeVisible(); // Go to document and check that the signatured tabs are correct. await page.goto(`/sign/${document.recipients[0].token}`); @@ -34,6 +35,7 @@ test('[TEAMS]: check that default team signature settings are all enabled', asyn await expect(page.getByRole('tab', { name: 'Type' })).toBeVisible(); await expect(page.getByRole('tab', { name: 'Upload' })).toBeVisible(); await expect(page.getByRole('tab', { name: 'Draw' })).toBeVisible(); + await expect(page.getByRole('tab', { name: 'Mobile' })).toBeVisible(); }); test('[TEAMS]: check signature modes can be disabled', async ({ page }) => { @@ -45,8 +47,11 @@ test('[TEAMS]: check signature modes can be disabled', async ({ page }) => { redirectPath: `/t/${team.url}/settings/document`, }); - const allTabs = ['Type', 'Upload', 'Draw']; - const tabTest = [['Type', 'Upload', 'Draw'], ['Type', 'Upload'], ['Type']]; + // The 'QR code' signature type is surfaced as the 'Mobile' tab on the signing dialog. + const allSignatureOptions = ['Type', 'Upload', 'Draw', 'QR code']; + const tabNameForOption = (option: string) => (option === 'QR code' ? 'Mobile' : option); + + const tabTest = [['Type', 'Upload', 'Draw', 'QR code'], ['Type', 'Upload'], ['Type']]; for (const tabs of tabTest) { await page.goto(`/t/${team.url}/settings/document`); @@ -57,9 +62,10 @@ test('[TEAMS]: check signature modes can be disabled', async ({ page }) => { await expect(page.getByRole('option', { name: 'Type' })).toBeVisible(); await expect(page.getByRole('option', { name: 'Upload' })).toBeVisible(); await expect(page.getByRole('option', { name: 'Draw' })).toBeVisible(); + await expect(page.getByRole('option', { name: 'QR code' })).toBeVisible(); // Clear all selected items. - for (const tab of allTabs) { + for (const tab of allSignatureOptions) { const item = page.getByRole('option', { name: tab }); const isSelected = (await item.innerHTML()).includes('opacity-100'); @@ -90,12 +96,13 @@ test('[TEAMS]: check signature modes can be disabled', async ({ page }) => { await page.waitForSelector('[role="dialog"]'); // Check the tab values - for (const tab of allTabs) { - if (tabs.includes(tab)) { - await expect(page.getByRole('tab', { name: tab })).toBeVisible(); + for (const option of allSignatureOptions) { + const tabName = tabNameForOption(option); + + if (tabs.includes(option)) { + await expect(page.getByRole('tab', { name: tabName })).toBeVisible(); } else { - // await expect(page.getByRole('tab', { name: tab })).not.toBeVisible(); - await expect(page.getByRole('tab', { name: tab })).toHaveCount(0); + await expect(page.getByRole('tab', { name: tabName })).toHaveCount(0); } } } @@ -110,8 +117,8 @@ test('[TEAMS]: check signature modes work for templates', async ({ page }) => { redirectPath: `/t/${team.url}/settings/document`, }); - const allTabs = ['Type', 'Upload', 'Draw']; - const tabTest = [['Type', 'Upload', 'Draw'], ['Type', 'Upload'], ['Type']]; + const allSignatureOptions = ['Type', 'Upload', 'Draw', 'QR code']; + const tabTest = [['Type', 'Upload', 'Draw', 'QR code'], ['Type', 'Upload'], ['Type']]; for (const tabs of tabTest) { await page.goto(`/t/${team.url}/settings/document`); @@ -122,9 +129,10 @@ test('[TEAMS]: check signature modes work for templates', async ({ page }) => { await expect(page.getByRole('option', { name: 'Type' })).toBeVisible(); await expect(page.getByRole('option', { name: 'Upload' })).toBeVisible(); await expect(page.getByRole('option', { name: 'Draw' })).toBeVisible(); + await expect(page.getByRole('option', { name: 'QR code' })).toBeVisible(); // Clear all selected items. - for (const tab of allTabs) { + for (const tab of allSignatureOptions) { const item = page.getByRole('option', { name: tab }); const isSelected = (await item.innerHTML()).includes('opacity-100'); @@ -176,5 +184,6 @@ test('[TEAMS]: check signature modes work for templates', async ({ page }) => { expect(document?.documentMeta?.typedSignatureEnabled).toEqual(tabs.includes('Type')); expect(document?.documentMeta?.uploadSignatureEnabled).toEqual(tabs.includes('Upload')); expect(document?.documentMeta?.drawSignatureEnabled).toEqual(tabs.includes('Draw')); + expect(document?.documentMeta?.qrSignatureEnabled).toEqual(tabs.includes('QR code')); } }); diff --git a/packages/app-tests/e2e/templates-flow/template-autosave-settings-step.spec.ts b/packages/app-tests/e2e/templates-flow/template-autosave-settings-step.spec.ts index 4ef427dc5..d7b79c764 100644 --- a/packages/app-tests/e2e/templates-flow/template-autosave-settings-step.spec.ts +++ b/packages/app-tests/e2e/templates-flow/template-autosave-settings-step.spec.ts @@ -152,6 +152,7 @@ test.describe('AutoSave Settings Step - Templates', () => { expect(retrievedTemplate.templateMeta?.drawSignatureEnabled).toBe(false); expect(retrievedTemplate.templateMeta?.typedSignatureEnabled).toBe(false); expect(retrievedTemplate.templateMeta?.uploadSignatureEnabled).toBe(true); + expect(retrievedTemplate.templateMeta?.qrSignatureEnabled).toBe(true); }).toPass(); }); diff --git a/packages/lib/constants/document.ts b/packages/lib/constants/document.ts index 52d8d2e33..49b39f881 100644 --- a/packages/lib/constants/document.ts +++ b/packages/lib/constants/document.ts @@ -77,4 +77,11 @@ export const DOCUMENT_SIGNATURE_TYPES = { }), value: DocumentSignatureType.UPLOAD, }, + [DocumentSignatureType.QR]: { + label: msg({ + message: `QR code`, + context: `Sign using a mobile phone via QR code`, + }), + value: DocumentSignatureType.QR, + }, } satisfies Record; diff --git a/packages/lib/constants/signatures.ts b/packages/lib/constants/signatures.ts index 0e0090e4f..3bae3ea7b 100644 --- a/packages/lib/constants/signatures.ts +++ b/packages/lib/constants/signatures.ts @@ -2,3 +2,5 @@ export const SIGNATURE_CANVAS_DPI = 2; export const SIGNATURE_MIN_COVERAGE_THRESHOLD = 0.01; export const isBase64Image = (value: string) => value.startsWith('data:image/png;base64,'); + +export const QR_SIGNATURE_TOKEN_EXPIRY_MINUTES = 10; diff --git a/packages/lib/jobs/client.ts b/packages/lib/jobs/client.ts index 397108f61..8462a6412 100644 --- a/packages/lib/jobs/client.ts +++ b/packages/lib/jobs/client.ts @@ -20,6 +20,7 @@ import { ADMIN_DELETE_ORGANISATION_JOB_DEFINITION } from './definitions/internal import { BACKPORT_SUBSCRIPTION_CLAIM_JOB_DEFINITION } from './definitions/internal/backport-subscription-claims'; import { BULK_SEND_TEMPLATE_JOB_DEFINITION } from './definitions/internal/bulk-send-template'; import { CANCEL_ORGANISATION_SUBSCRIPTION_JOB_DEFINITION } from './definitions/internal/cancel-organisation-subscription'; +import { CLEANUP_ANONYMOUS_TOKENS_JOB_DEFINITION } from './definitions/internal/cleanup-anonymous-tokens'; import { CLEANUP_RATE_LIMITS_JOB_DEFINITION } from './definitions/internal/cleanup-rate-limits'; import { EXECUTE_WEBHOOK_JOB_DEFINITION } from './definitions/internal/execute-webhook'; import { EXPIRE_RECIPIENTS_SWEEP_JOB_DEFINITION } from './definitions/internal/expire-recipients-sweep'; @@ -62,6 +63,7 @@ export const jobsClient = new JobClient([ SEND_SIGNING_REMINDERS_SWEEP_JOB_DEFINITION, PROCESS_SIGNING_REMINDER_JOB_DEFINITION, CLEANUP_RATE_LIMITS_JOB_DEFINITION, + CLEANUP_ANONYMOUS_TOKENS_JOB_DEFINITION, SYNC_EMAIL_DOMAINS_JOB_DEFINITION, ADMIN_DELETE_ORGANISATION_JOB_DEFINITION, CANCEL_ORGANISATION_SUBSCRIPTION_JOB_DEFINITION, diff --git a/packages/lib/server-only/auth/create-passkey-signin-options.ts b/packages/lib/server-only/auth/create-passkey-signin-options.ts index 2b7f5aec2..53ea13c47 100644 --- a/packages/lib/server-only/auth/create-passkey-signin-options.ts +++ b/packages/lib/server-only/auth/create-passkey-signin-options.ts @@ -1,4 +1,5 @@ import { prisma } from '@documenso/prisma'; +import { AnonymousVerificationTokenType } from '@prisma/client'; import { generateAuthenticationOptions } from '@simplewebauthn/server'; import { DateTime } from 'luxon'; @@ -24,12 +25,14 @@ export const createPasskeySigninOptions = async ({ sessionId }: CreatePasskeySig id: sessionId, }, update: { + type: AnonymousVerificationTokenType.PASSKEY, token: challenge, expiresAt: DateTime.now().plus({ minutes: 2 }).toJSDate(), createdAt: new Date(), }, create: { id: sessionId, + type: AnonymousVerificationTokenType.PASSKEY, token: challenge, expiresAt: DateTime.now().plus({ minutes: 2 }).toJSDate(), createdAt: new Date(), diff --git a/packages/lib/server-only/document-meta/upsert-document-meta.ts b/packages/lib/server-only/document-meta/upsert-document-meta.ts index 93b4c5240..2d19ad3c3 100644 --- a/packages/lib/server-only/document-meta/upsert-document-meta.ts +++ b/packages/lib/server-only/document-meta/upsert-document-meta.ts @@ -31,6 +31,7 @@ export type CreateDocumentMetaOptions = { typedSignatureEnabled?: boolean; uploadSignatureEnabled?: boolean; drawSignatureEnabled?: boolean; + qrSignatureEnabled?: boolean; language?: SupportedLanguageCodes; requestMetadata: ApiRequestMetadata; }; @@ -53,6 +54,7 @@ export const updateDocumentMeta = async ({ typedSignatureEnabled, uploadSignatureEnabled, drawSignatureEnabled, + qrSignatureEnabled, language, requestMetadata, }: CreateDocumentMetaOptions) => { @@ -132,6 +134,7 @@ export const updateDocumentMeta = async ({ typedSignatureEnabled, uploadSignatureEnabled, drawSignatureEnabled, + qrSignatureEnabled, language, }, }); diff --git a/packages/lib/server-only/envelope/get-envelope-for-recipient-signing.ts b/packages/lib/server-only/envelope/get-envelope-for-recipient-signing.ts index b178f4705..d66a34978 100644 --- a/packages/lib/server-only/envelope/get-envelope-for-recipient-signing.ts +++ b/packages/lib/server-only/envelope/get-envelope-for-recipient-signing.ts @@ -47,6 +47,7 @@ export const ZEnvelopeForSigningResponse = z.object({ typedSignatureEnabled: true, uploadSignatureEnabled: true, drawSignatureEnabled: true, + qrSignatureEnabled: true, allowDictateNextSigner: true, language: true, }), diff --git a/packages/lib/server-only/template/create-document-from-template.ts b/packages/lib/server-only/template/create-document-from-template.ts index 47158c3f0..1e44b129c 100644 --- a/packages/lib/server-only/template/create-document-from-template.ts +++ b/packages/lib/server-only/template/create-document-from-template.ts @@ -112,6 +112,7 @@ export type CreateDocumentFromTemplateOptions = { typedSignatureEnabled?: boolean; uploadSignatureEnabled?: boolean; drawSignatureEnabled?: boolean; + qrSignatureEnabled?: boolean; envelopeExpirationPeriod?: TEnvelopeExpirationPeriod | null; }; @@ -540,6 +541,7 @@ export const createDocumentFromTemplate = async ({ typedSignatureEnabled: override?.typedSignatureEnabled ?? template.documentMeta?.typedSignatureEnabled, uploadSignatureEnabled: override?.uploadSignatureEnabled ?? template.documentMeta?.uploadSignatureEnabled, drawSignatureEnabled: override?.drawSignatureEnabled ?? template.documentMeta?.drawSignatureEnabled, + qrSignatureEnabled: override?.qrSignatureEnabled ?? template.documentMeta?.qrSignatureEnabled, allowDictateNextSigner: override?.allowDictateNextSigner ?? template.documentMeta?.allowDictateNextSigner, envelopeExpirationPeriod: override?.envelopeExpirationPeriod ?? template.documentMeta?.envelopeExpirationPeriod, }, diff --git a/packages/lib/server-only/webhooks/trigger/generate-sample-data.ts b/packages/lib/server-only/webhooks/trigger/generate-sample-data.ts index af902f8ba..2b16a7163 100644 --- a/packages/lib/server-only/webhooks/trigger/generate-sample-data.ts +++ b/packages/lib/server-only/webhooks/trigger/generate-sample-data.ts @@ -46,6 +46,7 @@ export const generateSampleWebhookPayload = (event: WebhookTriggerEvents, webhoo typedSignatureEnabled: true, uploadSignatureEnabled: true, drawSignatureEnabled: true, + qrSignatureEnabled: true, language: 'en', distributionMethod: DocumentDistributionMethod.EMAIL, emailSettings: null, diff --git a/packages/lib/types/document-meta.ts b/packages/lib/types/document-meta.ts index 12c1cf87c..642989ccc 100644 --- a/packages/lib/types/document-meta.ts +++ b/packages/lib/types/document-meta.ts @@ -28,6 +28,7 @@ export const ZDocumentMetaSchema = DocumentMetaSchema.pick({ typedSignatureEnabled: true, uploadSignatureEnabled: true, drawSignatureEnabled: true, + qrSignatureEnabled: true, language: true, emailSettings: true, }); @@ -105,6 +106,10 @@ export const ZDocumentMetaUploadSignatureEnabledSchema = z .boolean() .describe('Whether to allow recipients to sign using an uploaded signature.'); +export const ZDocumentMetaQrSignatureEnabledSchema = z + .boolean() + .describe('Whether to allow recipients to sign using a QR code handoff to a mobile device.'); + /** * Note: Any updates to this will cause public API changes. You will need to update * all corresponding areas where this is used (some places that use this needs to pass @@ -123,6 +128,7 @@ export const ZDocumentMetaCreateSchema = z.object({ typedSignatureEnabled: ZDocumentMetaTypedSignatureEnabledSchema.optional(), uploadSignatureEnabled: ZDocumentMetaUploadSignatureEnabledSchema.optional(), drawSignatureEnabled: ZDocumentMetaDrawSignatureEnabledSchema.optional(), + qrSignatureEnabled: ZDocumentMetaQrSignatureEnabledSchema.optional(), emailId: z.string().nullish(), emailReplyTo: zEmail().nullish(), emailSettings: ZDocumentEmailSettingsSchema.nullish(), diff --git a/packages/lib/types/document.ts b/packages/lib/types/document.ts index 2205d9191..d9f460f1b 100644 --- a/packages/lib/types/document.ts +++ b/packages/lib/types/document.ts @@ -62,6 +62,7 @@ export const ZDocumentSchema = LegacyDocumentSchema.pick({ typedSignatureEnabled: true, uploadSignatureEnabled: true, drawSignatureEnabled: true, + qrSignatureEnabled: true, allowDictateNextSigner: true, language: true, emailSettings: true, diff --git a/packages/lib/types/envelope-editor.ts b/packages/lib/types/envelope-editor.ts index cfa07fae7..40dd8f02d 100644 --- a/packages/lib/types/envelope-editor.ts +++ b/packages/lib/types/envelope-editor.ts @@ -279,6 +279,7 @@ export const ZEditorEnvelopeSchema = EnvelopeSchema.pick({ typedSignatureEnabled: true, uploadSignatureEnabled: true, drawSignatureEnabled: true, + qrSignatureEnabled: true, allowDictateNextSigner: true, language: true, emailSettings: true, diff --git a/packages/lib/types/envelope.ts b/packages/lib/types/envelope.ts index 2692252a0..852d392f2 100644 --- a/packages/lib/types/envelope.ts +++ b/packages/lib/types/envelope.ts @@ -49,6 +49,7 @@ export const ZEnvelopeSchema = EnvelopeSchema.pick({ typedSignatureEnabled: true, uploadSignatureEnabled: true, drawSignatureEnabled: true, + qrSignatureEnabled: true, allowDictateNextSigner: true, language: true, emailSettings: true, diff --git a/packages/lib/types/template.ts b/packages/lib/types/template.ts index bf43851c9..966623e3b 100644 --- a/packages/lib/types/template.ts +++ b/packages/lib/types/template.ts @@ -54,6 +54,7 @@ export const ZTemplateSchema = TemplateSchema.pick({ typedSignatureEnabled: true, uploadSignatureEnabled: true, drawSignatureEnabled: true, + qrSignatureEnabled: true, allowDictateNextSigner: true, distributionMethod: true, redirectUrl: true, diff --git a/packages/lib/types/webhook-payload.ts b/packages/lib/types/webhook-payload.ts index 6820e0958..1aae0f7ce 100644 --- a/packages/lib/types/webhook-payload.ts +++ b/packages/lib/types/webhook-payload.ts @@ -55,6 +55,7 @@ export const ZWebhookDocumentMetaSchema = z.object({ typedSignatureEnabled: z.boolean(), uploadSignatureEnabled: z.boolean(), drawSignatureEnabled: z.boolean(), + qrSignatureEnabled: z.boolean(), language: z.string(), distributionMethod: z.nativeEnum(DocumentDistributionMethod), emailSettings: z.any().nullable(), diff --git a/packages/lib/utils/document.ts b/packages/lib/utils/document.ts index c64a09f67..f87046422 100644 --- a/packages/lib/utils/document.ts +++ b/packages/lib/utils/document.ts @@ -59,6 +59,7 @@ export const extractDerivedDocumentMeta = ( typedSignatureEnabled: meta.typedSignatureEnabled ?? settings.typedSignatureEnabled, uploadSignatureEnabled: meta.uploadSignatureEnabled ?? settings.uploadSignatureEnabled, drawSignatureEnabled: meta.drawSignatureEnabled ?? settings.drawSignatureEnabled, + qrSignatureEnabled: meta.qrSignatureEnabled ?? settings.qrSignatureEnabled, // Email settings. emailId: meta.emailId ?? settings.emailId, diff --git a/packages/lib/utils/organisations.ts b/packages/lib/utils/organisations.ts index b643ba6aa..b816bd90f 100644 --- a/packages/lib/utils/organisations.ts +++ b/packages/lib/utils/organisations.ts @@ -119,6 +119,7 @@ export const generateDefaultOrganisationSettings = (): Omit { @@ -93,10 +94,16 @@ export const extractTeamSignatureSettings = ( typedSignatureEnabled: boolean | null; drawSignatureEnabled: boolean | null; uploadSignatureEnabled: boolean | null; + qrSignatureEnabled: boolean | null; } | null, ) => { if (!settings) { - return [DocumentSignatureType.TYPE, DocumentSignatureType.UPLOAD, DocumentSignatureType.DRAW]; + return [ + DocumentSignatureType.TYPE, + DocumentSignatureType.UPLOAD, + DocumentSignatureType.DRAW, + DocumentSignatureType.QR, + ]; } const signatureTypes: DocumentSignatureType[] = []; @@ -113,6 +120,10 @@ export const extractTeamSignatureSettings = ( signatureTypes.push(DocumentSignatureType.UPLOAD); } + if (settings.qrSignatureEnabled) { + signatureTypes.push(DocumentSignatureType.QR); + } + return signatureTypes; }; @@ -186,6 +197,7 @@ export const generateDefaultTeamSettings = (): Omit, 'o typedSignatureEnabled?: boolean; uploadSignatureEnabled?: boolean; drawSignatureEnabled?: boolean; + qrSignatureEnabled?: boolean; }; export const SignaturePadDialog = ({ @@ -34,6 +35,7 @@ export const SignaturePadDialog = ({ typedSignatureEnabled, uploadSignatureEnabled, drawSignatureEnabled, + qrSignatureEnabled, dialogConfirmText, }: SignaturePadDialogProps) => { const { i18n } = useLingui(); @@ -121,6 +123,7 @@ export const SignaturePadDialog = ({ typedSignatureEnabled={typedSignatureEnabled} uploadSignatureEnabled={uploadSignatureEnabled} drawSignatureEnabled={drawSignatureEnabled} + qrSignatureEnabled={qrSignatureEnabled} />