diff --git a/apps/docs/content/docs/developers/api/templates.mdx b/apps/docs/content/docs/developers/api/templates.mdx index b3f52e146..e37e77865 100644 --- a/apps/docs/content/docs/developers/api/templates.mdx +++ b/apps/docs/content/docs/developers/api/templates.mdx @@ -465,6 +465,7 @@ const response = await fetch(`${BASE_URL}/template/use`, { typedSignatureEnabled: true, uploadSignatureEnabled: false, drawSignatureEnabled: true, + qrSignatureEnabled: true, }, distributeDocument: true, }), @@ -485,6 +486,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 5bfaa7a42..2c0446c21 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 | @@ -141,6 +142,7 @@ Triggered when a new document is created. "typedSignatureEnabled": true, "uploadSignatureEnabled": true, "drawSignatureEnabled": true, + "qrSignatureEnabled": true, "language": "en", "distributionMethod": "EMAIL", "emailSettings": null @@ -235,6 +237,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 @@ -435,6 +438,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 @@ -618,6 +622,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..7493bde93 100644 --- a/apps/remix/app/components/dialogs/sign-field-signature-dialog.tsx +++ b/apps/remix/app/components/dialogs/sign-field-signature-dialog.tsx @@ -1,3 +1,4 @@ +import type { TQrSignatureContext } from '@documenso/lib/types/qr-signature'; import { Button } from '@documenso/ui/primitives/button'; import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@documenso/ui/primitives/dialog'; import { SignaturePad } from '@documenso/ui/primitives/signature-pad'; @@ -13,10 +14,21 @@ export type SignFieldSignatureDialogProps = { typedSignatureEnabled?: boolean; uploadSignatureEnabled?: boolean; drawSignatureEnabled?: boolean; + qrSignatureEnabled?: boolean; + qrSignatureContext?: TQrSignatureContext; }; export const SignFieldSignatureDialog = createCallable( - ({ call, fullName, typedSignatureEnabled, uploadSignatureEnabled, drawSignatureEnabled, initialSignature }) => { + ({ + call, + fullName, + typedSignatureEnabled, + uploadSignatureEnabled, + drawSignatureEnabled, + qrSignatureEnabled, + qrSignatureContext, + initialSignature, + }) => { const [localSignature, setLocalSignature] = useState(initialSignature); return ( @@ -36,6 +48,8 @@ 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..884a416b9 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,8 @@ export const EmbedSignDocumentV1ClientPage = ({ typedSignatureEnabled={metadata?.typedSignatureEnabled} uploadSignatureEnabled={metadata?.uploadSignatureEnabled} drawSignatureEnabled={metadata?.drawSignatureEnabled} + qrSignatureEnabled={metadata?.qrSignatureEnabled} + qrSignatureContext={{ type: 'DOCUMENT_SIGNATURE', recipientToken: recipient.token }} /> )} 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 29e566c79..489c8ed3d 100644 --- a/apps/remix/app/components/forms/document-preferences-form.tsx +++ b/apps/remix/app/components/forms/document-preferences-form.tsx @@ -55,6 +55,7 @@ type SettingsSubset = Pick< | 'typedSignatureEnabled' | 'uploadSignatureEnabled' | 'drawSignatureEnabled' + | 'qrSignatureEnabled' | 'defaultRecipients' | 'delegateDocumentOwnership' | 'aiFeaturesEnabled' diff --git a/apps/remix/app/components/forms/profile.tsx b/apps/remix/app/components/forms/profile.tsx index 3449a747f..8554a41d1 100644 --- a/apps/remix/app/components/forms/profile.tsx +++ b/apps/remix/app/components/forms/profile.tsx @@ -111,6 +111,7 @@ export const ProfileForm = ({ className }: ProfileFormProps) => { onChange(v ?? '')} diff --git a/apps/remix/app/components/forms/signup.tsx b/apps/remix/app/components/forms/signup.tsx index ea22b2789..b1781fee7 100644 --- a/apps/remix/app/components/forms/signup.tsx +++ b/apps/remix/app/components/forms/signup.tsx @@ -314,6 +314,7 @@ export const SignUpForm = ({ onChange(v ?? '')} /> 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..644ae59cb 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,8 @@ export const DocumentSigningForm = ({ typedSignatureEnabled={document.documentMeta?.typedSignatureEnabled} uploadSignatureEnabled={document.documentMeta?.uploadSignatureEnabled} drawSignatureEnabled={document.documentMeta?.drawSignatureEnabled} + qrSignatureEnabled={document.documentMeta?.qrSignatureEnabled} + qrSignatureContext={{ type: 'DOCUMENT_SIGNATURE', recipientToken: recipient.token }} /> )} 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..b2514e9c4 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,8 @@ export const DocumentSigningSignatureField = ({ typedSignatureEnabled={typedSignatureEnabled} uploadSignatureEnabled={uploadSignatureEnabled} drawSignatureEnabled={drawSignatureEnabled} + qrSignatureEnabled={qrSignatureEnabled} + qrSignatureContext={{ type: 'DOCUMENT_SIGNATURE', recipientToken: recipient.token }} /> 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..dd8ca03fe 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,8 @@ export default function EnvelopeSignerForm() { typedSignatureEnabled={envelope.documentMeta.typedSignatureEnabled} uploadSignatureEnabled={envelope.documentMeta.uploadSignatureEnabled} drawSignatureEnabled={envelope.documentMeta.drawSignatureEnabled} + qrSignatureEnabled={envelope.documentMeta.qrSignatureEnabled} + qrSignatureContext={{ type: 'DOCUMENT_SIGNATURE', recipientToken: recipient.token }} /> )} 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..9412c9485 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,8 @@ export const EnvelopeSignerPageRenderer = ({ pageData }: { pageData: PageRenderD typedSignatureEnabled: envelope.documentMeta.typedSignatureEnabled, uploadSignatureEnabled: envelope.documentMeta.uploadSignatureEnabled, drawSignatureEnabled: envelope.documentMeta.drawSignatureEnabled, + qrSignatureEnabled: envelope.documentMeta.qrSignatureEnabled, + recipientToken: envelopeData.recipient.token, }) .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 6331e5ece..584b46b79 100644 --- a/apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.document.tsx +++ b/apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.document.tsx @@ -62,6 +62,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, }, 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 e510ad48b..09dc293bc 100644 --- a/apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.document.tsx +++ b/apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.document.tsx @@ -50,11 +50,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/_unauthenticated+/_layout.tsx b/apps/remix/app/routes/_unauthenticated+/_layout.tsx index 1ad6db22e..64a658ed4 100644 --- a/apps/remix/app/routes/_unauthenticated+/_layout.tsx +++ b/apps/remix/app/routes/_unauthenticated+/_layout.tsx @@ -1,21 +1,28 @@ import backgroundPattern from '@documenso/assets/images/background-pattern.png'; -import { Outlet } from 'react-router'; +import { Outlet, useLocation } from 'react-router'; export default function Layout() { + const { pathname } = useLocation(); + + // Todo: Use the layout params to hide instead of hardcoding the pathname. + const hideBackground = pathname.includes('mobile-signature'); + return (
-
- background pattern -
+ {!hideBackground && ( +
+ background pattern +
+ )}
diff --git a/apps/remix/app/routes/_unauthenticated+/mobile-signature.$token.tsx b/apps/remix/app/routes/_unauthenticated+/mobile-signature.$token.tsx new file mode 100644 index 000000000..95025347a --- /dev/null +++ b/apps/remix/app/routes/_unauthenticated+/mobile-signature.$token.tsx @@ -0,0 +1,339 @@ +import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc'; +import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; +import { trpc } from '@documenso/trpc/react'; +import type { + TGetQrSignatureSessionResponse, + TQrSignatureSessionContext, +} from '@documenso/trpc/server/signature-router/qr/get-qr-signature-session.types'; +import { Button } from '@documenso/ui/primitives/button'; +import { Sheet, SheetContent, SheetTitle } from '@documenso/ui/primitives/sheet'; +import { SignaturePadDraw } from '@documenso/ui/primitives/signature-pad/signature-pad-draw'; +import { i18n } from '@lingui/core'; +import { msg } from '@lingui/core/macro'; +import { Trans, useLingui } from '@lingui/react/macro'; +import { CheckCircle2Icon, ClockIcon, FileTextIcon, Loader2Icon, PenLineIcon, XCircleIcon } from 'lucide-react'; +import { useEffect, useMemo, useState } from 'react'; +import { match } from 'ts-pattern'; + +import type { Route } from './+types/mobile-signature.$token'; + +export function meta() { + return [ + { title: i18n._(msg`Sign on mobile - Documenso`) }, + { name: 'robots', content: 'noindex, nofollow, noarchive, nosnippet, noimageindex' }, + ]; +} + +export default function MobileSignaturePage({ params }: Route.ComponentProps) { + const { token } = params; + + const { + data: session, + isError: isSessionError, + isLoading: isSessionLoading, + } = trpc.signature.qr.getSession.useQuery( + { + token, + }, + { + // Do not refetch the session. + staleTime: Number.POSITIVE_INFINITY, + refetchOnWindowFocus: false, + refetchOnReconnect: false, + retry: false, + }, + ); + + if (isSessionLoading || !session) { + return ( +
+ + + Loading + +
+ ); + } + + if (session.status !== 'VALID' || isSessionError) { + return ; + } + + return ( +
+ +
+ ); +} + +type QrSignatureState = 'SIGNING' | 'SUCCESS' | 'EXPIRED' | 'ALREADY_SUBMITTED'; + +type QrSignatureProps = { + token: string; + context: TQrSignatureSessionContext; +}; + +const QrSignature = ({ token, context }: QrSignatureProps) => { + const { t } = useLingui(); + + const [signature, setSignature] = useState(''); + const [hasSubmissionError, setHasSubmissionError] = useState(false); + + const [state, setState] = useState('SIGNING'); + + // Portrait renders the pad in a bottom sheet beneath the document context; + // landscape renders a single card. This component only ever renders on the + // client (behind the session query), so the initial value can be read + // synchronously - no flicker on landscape devices. + const [isPortrait, setIsPortrait] = useState( + () => typeof window === 'undefined' || window.matchMedia('(orientation: portrait)').matches, + ); + + useEffect(() => { + const mediaQuery = window.matchMedia('(orientation: portrait)'); + + setIsPortrait(mediaQuery.matches); + + const onOrientationChange = (event: MediaQueryListEvent) => { + setIsPortrait(event.matches); + }; + + mediaQuery.addEventListener('change', onOrientationChange); + + return () => { + mediaQuery.removeEventListener('change', onOrientationChange); + }; + }, []); + + const { mutateAsync: completeQrSignature, isPending } = trpc.signature.qr.complete.useMutation({ + // The session query must not refetch on completion: it would resolve to + // ALREADY_SUBMITTED and replace the success screen with an error card. + ...DO_NOT_INVALIDATE_QUERY_ON_MUTATION, + }); + + const contextInfo = useMemo( + () => + match(context) + .with({ type: 'DOCUMENT_SIGNATURE' }, (documentContext) => ({ + title: documentContext.documentTitle, + subtitle: `${documentContext.teamName} · ${t`Signature requested`}`, + icon: , + })) + .with({ type: 'PROFILE_SIGNATURE' }, () => ({ + title: t`Your signature`, + subtitle: t`Signature requested`, + icon: , + })) + // Context-less sessions show no subtitle, which would just repeat the title. + .with({ type: 'NONE' }, () => ({ + title: t`Signature requested`, + subtitle: null, + icon: , + })) + .exhaustive(), + [context, t], + ); + + const onSubmitClick = async () => { + setHasSubmissionError(false); + + try { + await completeQrSignature({ + token, + signature, + }); + + setState('SUCCESS'); + } catch (err) { + const error = AppError.parseError(err); + + if (error.code === AppErrorCode.EXPIRED_CODE || error.code === AppErrorCode.NOT_FOUND) { + setState('EXPIRED'); + return; + } + + if (error.code === AppErrorCode.INVALID_REQUEST) { + setState('ALREADY_SUBMITTED'); + return; + } + + setHasSubmissionError(true); + } + }; + + if (state === 'EXPIRED' || state === 'ALREADY_SUBMITTED') { + return ; + } + + if (state === 'SUCCESS') { + return ( +
+ + +

+ Success +

+ +

+ You can now return to your main device to continue. +

+
+ ); + } + + if (isPortrait) { + return ( + <> + {/* Document context hero. */} +
+
+ {contextInfo.icon} +
+ +

{contextInfo.title}

+ + {contextInfo.subtitle &&

{contextInfo.subtitle}

} + +
+ + Connected +
+
+ + {/* Persistent signing sheet - cannot be dismissed. */} + + event.preventDefault()} + onPointerDownOutside={(event) => event.preventDefault()} + onInteractOutside={(event) => event.preventDefault()} + > +
+ + + Draw your signature + + +
+ setSignature(value)} /> +
+ + {hasSubmissionError && ( +

+ Something went wrong. Please try again. +

+ )} + +
+ +
+ + + + ); + } + + // Landscape: a single card, no sheet. + return ( + // Need this to override the parent layout styling. +
+ {/* The column width IS the pad width: all height left beneath the fixed + h-12 header (100svh - 2*p-2 - h-12 - mb-2 = 100svh - 4.5rem) is + converted through the pad's 16/7 aspect ratio, clamped by the + viewport width. The header is w-full of the same column, so it always + matches the pad width exactly. */} +
+
+
+
+ {contextInfo.icon} +
+ +
+

{contextInfo.title}

+ +

{contextInfo.subtitle}

+
+
+ + +
+ +
+ setSignature(value)} /> +
+ + {hasSubmissionError && ( +

+ Something went wrong. Please try again. +

+ )} +
+
+ ); +}; + +type QrSignatureErrorReason = Exclude; + +type QrSignatureErrorProps = { + reason?: QrSignatureErrorReason; +}; + +const QrSignatureError = ({ reason }: QrSignatureErrorProps) => { + const content = match(reason) + .with('EXPIRED', () => ({ + icon: , + title: This link has expired, + description: Generate a new QR code on the original device and scan it again., + })) + .with('ALREADY_SUBMITTED', () => ({ + icon: , + title: Signature already sent, + description: This link has already been used. Return to your computer to continue., + })) + .with('INVALID', () => ({ + icon: , + title: This signing request is invalid, + description: ( + + The request is invalid or no longer exists. Scan the new QR code on the original device to try again. + + ), + })) + .with(undefined, () => ({ + icon: , + title: Something went wrong, + description: We couldn't load this signing request. Please refresh the page to try again., + })) + .exhaustive(); + + return ( +
+ {content.icon} + +

{content.title}

+ +

{content.description}

+
+ ); +}; 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..592161c40 100644 --- a/apps/remix/app/utils/field-signing/signature-field.ts +++ b/apps/remix/app/utils/field-signing/signature-field.ts @@ -12,12 +12,23 @@ type HandleSignatureFieldClickOptions = { typedSignatureEnabled?: boolean; uploadSignatureEnabled?: boolean; drawSignatureEnabled?: boolean; + qrSignatureEnabled?: boolean; + recipientToken?: string; }; export const handleSignatureFieldClick = async ( options: HandleSignatureFieldClickOptions, ): Promise | null> => { - const { field, fullName, signature, typedSignatureEnabled, uploadSignatureEnabled, drawSignatureEnabled } = options; + const { + field, + fullName, + signature, + typedSignatureEnabled, + uploadSignatureEnabled, + drawSignatureEnabled, + qrSignatureEnabled, + recipientToken, + } = options; if (field.type !== FieldType.SIGNATURE) { throw new AppError(AppErrorCode.INVALID_REQUEST, { @@ -40,6 +51,8 @@ export const handleSignatureFieldClick = async ( typedSignatureEnabled, uploadSignatureEnabled, drawSignatureEnabled, + qrSignatureEnabled, + qrSignatureContext: recipientToken ? { type: 'DOCUMENT_SIGNATURE', recipientToken } : undefined, }); } diff --git a/package-lock.json b/package-lock.json index c17553bfc..93ba23ecf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -33049,6 +33049,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/api/v1/schema.ts b/packages/api/v1/schema.ts index 81d969f2f..ee3c3f3ee 100644 --- a/packages/api/v1/schema.ts +++ b/packages/api/v1/schema.ts @@ -170,6 +170,8 @@ export const ZCreateDocumentMutationSchema = z.object({ typedSignatureEnabled: z.boolean().optional().default(true), uploadSignatureEnabled: z.boolean().optional().default(true), drawSignatureEnabled: z.boolean().optional().default(true), + // No default: omission must fall through to team/org settings. + qrSignatureEnabled: z.boolean().optional(), distributionMethod: z.nativeEnum(DocumentDistributionMethod).optional(), emailSettings: ZDocumentEmailSettingsSchema.optional(), }) @@ -340,6 +342,7 @@ export const ZGenerateDocumentFromTemplateMutationSchema = z.object({ typedSignatureEnabled: z.boolean(), uploadSignatureEnabled: z.boolean(), drawSignatureEnabled: z.boolean(), + qrSignatureEnabled: z.boolean(), emailSettings: ZDocumentEmailSettingsSchema, }) .partial() 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 8c03ff72c..8fb69289b 100644 --- a/packages/app-tests/e2e/organisations/organisation-team-preferences.spec.ts +++ b/packages/app-tests/e2e/organisations/organisation-team-preferences.spec.ts @@ -68,6 +68,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`); @@ -102,6 +103,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); @@ -117,6 +119,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..ec7249333 --- /dev/null +++ b/packages/app-tests/e2e/signature/qr-mobile-signature.spec.ts @@ -0,0 +1,220 @@ +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. + // A realistic landscape-phone viewport: the pad sizes itself dynamically to + // the viewport, and the primitive's minimum-coverage check is a percentage + // of the canvas area - a desktop-sized context would demand far more ink + // than the drawn zigzag provides. + const mobileContext = await browser.newContext({ viewport: { width: 844, height: 390 } }); + const mobilePage = await mobileContext.newPage(); + + await mobilePage.goto(handoffUrl ?? ''); + + // The phone page renders the signing card (landscape layout at the default + // test viewport) with Next disabled until a valid signature is drawn. + await expect(mobilePage.getByTestId('signature-pad-draw')).toBeVisible(); + await expect(mobilePage.getByRole('button', { name: 'Next' })).toBeDisabled(); + + await drawOnSignaturePad(mobilePage); + + await mobilePage.getByRole('button', { name: 'Next' }).click(); + + await expect(mobilePage.getByText('Success')).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 5309bd510..7dcdc7d17 100644 --- a/packages/lib/jobs/client.ts +++ b/packages/lib/jobs/client.ts @@ -21,6 +21,7 @@ import { ALERT_ORGANISATION_SEAT_DRIFT_JOB_DEFINITION } from './definitions/inte 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'; @@ -64,6 +65,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, ALERT_ORGANISATION_SEAT_DRIFT_JOB_DEFINITION, diff --git a/packages/lib/jobs/definitions/internal/cleanup-anonymous-tokens.handler.ts b/packages/lib/jobs/definitions/internal/cleanup-anonymous-tokens.handler.ts new file mode 100644 index 000000000..3615d8ce1 --- /dev/null +++ b/packages/lib/jobs/definitions/internal/cleanup-anonymous-tokens.handler.ts @@ -0,0 +1,36 @@ +import { prisma } from '@documenso/prisma'; + +import type { JobRunIO } from '../../client/_internal/job'; +import type { TCleanupAnonymousTokensJobDefinition } from './cleanup-anonymous-tokens'; + +const BATCH_SIZE = 10_000; + +export const run = async ({ io }: { payload: TCleanupAnonymousTokensJobDefinition; io: JobRunIO }) => { + // Snapshot the cutoff so the run is bounded by the rows that were already + // expired when it started, rather than chasing rows expiring mid-run. + const cutoff = new Date(); + + let totalDeleted = 0; + let deleted = 0; + + do { + // Postgres doesn't support DELETE with LIMIT, so batch via ctid to avoid + // long-running transactions that could lock the table. + deleted = await prisma.$executeRaw` + DELETE FROM "AnonymousVerificationToken" + WHERE ctid IN ( + SELECT ctid FROM "AnonymousVerificationToken" + WHERE "expiresAt" < ${cutoff} + LIMIT ${BATCH_SIZE} + ) + `; + + totalDeleted += deleted; + } while (deleted >= BATCH_SIZE); + + if (totalDeleted > 0) { + io.logger.info(`Cleaned up ${totalDeleted} expired anonymous verification tokens`); + } else { + io.logger.info('No expired anonymous verification tokens to clean up'); + } +}; diff --git a/packages/lib/jobs/definitions/internal/cleanup-anonymous-tokens.ts b/packages/lib/jobs/definitions/internal/cleanup-anonymous-tokens.ts new file mode 100644 index 000000000..c9ebc6f29 --- /dev/null +++ b/packages/lib/jobs/definitions/internal/cleanup-anonymous-tokens.ts @@ -0,0 +1,28 @@ +import { z } from 'zod'; + +import type { JobDefinition } from '../../client/_internal/job'; + +const CLEANUP_ANONYMOUS_TOKENS_JOB_DEFINITION_ID = 'internal.cleanup-anonymous-tokens'; + +const CLEANUP_ANONYMOUS_TOKENS_JOB_DEFINITION_SCHEMA = z.object({}); + +export type TCleanupAnonymousTokensJobDefinition = z.infer; + +export const CLEANUP_ANONYMOUS_TOKENS_JOB_DEFINITION = { + id: CLEANUP_ANONYMOUS_TOKENS_JOB_DEFINITION_ID, + name: 'Cleanup Anonymous Verification Tokens', + version: '1.0.0', + trigger: { + name: CLEANUP_ANONYMOUS_TOKENS_JOB_DEFINITION_ID, + schema: CLEANUP_ANONYMOUS_TOKENS_JOB_DEFINITION_SCHEMA, + cron: '0 */2 * * *', // Every 2 hours. + }, + handler: async ({ payload, io }) => { + const handler = await import('./cleanup-anonymous-tokens.handler'); + + await handler.run({ payload, io }); + }, +} as const satisfies JobDefinition< + typeof CLEANUP_ANONYMOUS_TOKENS_JOB_DEFINITION_ID, + TCleanupAnonymousTokensJobDefinition +>; 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 116238f3b..ec104d258 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/rate-limit/rate-limits.ts b/packages/lib/server-only/rate-limit/rate-limits.ts index 5dfa47450..ceb3afeb2 100644 --- a/packages/lib/server-only/rate-limit/rate-limits.ts +++ b/packages/lib/server-only/rate-limit/rate-limits.ts @@ -72,6 +72,21 @@ export const reportSenderRateLimit = createRateLimit({ window: '7d', }); +// ---- Signature (QR mobile handoff) ---- + +export const qrSignatureCreateRateLimit = createRateLimit({ + action: 'signature.qr-create', + max: 20, + window: '15m', +}); + +export const qrSignatureCompleteRateLimit = createRateLimit({ + action: 'signature.qr-complete', + max: 20, + globalMax: 60, + window: '15m', +}); + // ---- Billing ---- export const syncSubscriptionRateLimit = createRateLimit({ 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/qr-signature.ts b/packages/lib/types/qr-signature.ts new file mode 100644 index 000000000..a70eb285f --- /dev/null +++ b/packages/lib/types/qr-signature.ts @@ -0,0 +1,23 @@ +import { z } from 'zod'; + +/** + * The context a QR signature session is created for. + * + * - `PROFILE_SIGNATURE`: a standalone signature, e.g. the profile or signup + * forms. Carries no additional data. + * - `DOCUMENT_SIGNATURE`: a signature for a document signing flow. Carries the + * recipient token so the mobile page can render the document context. + */ +export const ZQrSignatureContextSchema = z.discriminatedUnion('type', [ + z.object({ + type: z.literal('PROFILE_SIGNATURE'), + }), + z.object({ + type: z.literal('DOCUMENT_SIGNATURE'), + recipientToken: z.string().min(1).max(64), + }), +]); + +export type TQrSignatureContext = z.infer; + +export type TQrSignatureContextType = TQrSignatureContext['type']; 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 { + const { token, signature } = input; + + const { ipAddress } = ctx.metadata.requestMetadata; + + const rateLimitResult = await qrSignatureCompleteRateLimit.check({ + ip: ipAddress ?? 'unknown', + identifier: token, + }); + + assertRateLimit(rateLimitResult); + + const qrSignatureSession = await prisma.anonymousVerificationToken.findFirst({ + where: { + token, + type: AnonymousVerificationTokenType.QR_SIGNATURE, + }, + }); + + if (!qrSignatureSession) { + throw new AppError(AppErrorCode.NOT_FOUND, { + message: 'QR signature session not found or expired', + }); + } + + if (qrSignatureSession.expiresAt < new Date()) { + throw new AppError(AppErrorCode.EXPIRED_CODE, { + message: 'QR signature session has expired', + }); + } + + if (qrSignatureSession.value) { + throw new AppError(AppErrorCode.INVALID_REQUEST, { + message: 'A signature has already been submitted for this session', + }); + } + + const { count: updatedCount } = await prisma.anonymousVerificationToken.updateMany({ + where: { + id: qrSignatureSession.id, + type: AnonymousVerificationTokenType.QR_SIGNATURE, + value: null, + }, + data: { + value: signature, + }, + }); + + if (updatedCount === 0) { + throw new AppError(AppErrorCode.INVALID_REQUEST, { + message: 'A signature has already been submitted for this session', + }); + } + }); diff --git a/packages/trpc/server/signature-router/qr/complete-qr-signature.types.ts b/packages/trpc/server/signature-router/qr/complete-qr-signature.types.ts new file mode 100644 index 000000000..287213b3d --- /dev/null +++ b/packages/trpc/server/signature-router/qr/complete-qr-signature.types.ts @@ -0,0 +1,17 @@ +import { isBase64Image } from '@documenso/lib/constants/signatures'; +import { z } from 'zod'; + +export const ZCompleteQrSignatureRequestSchema = z.object({ + token: z.string().min(1).max(64).describe('The QR signature session token'), + signature: z + .string() + .min(1) + .max(1_000_000) + .refine((value) => isBase64Image(value), { + message: 'Signature must be a base64 encoded PNG image', + }), +}); + +export const ZCompleteQrSignatureResponseSchema = z.void(); + +export type TCompleteQrSignatureRequest = z.infer; diff --git a/packages/trpc/server/signature-router/qr/create-qr-signature.ts b/packages/trpc/server/signature-router/qr/create-qr-signature.ts new file mode 100644 index 000000000..6dd9e5727 --- /dev/null +++ b/packages/trpc/server/signature-router/qr/create-qr-signature.ts @@ -0,0 +1,64 @@ +import { QR_SIGNATURE_TOKEN_EXPIRY_MINUTES } from '@documenso/lib/constants/signatures'; +import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; +import { assertRateLimit } from '@documenso/lib/server-only/rate-limit/rate-limit-middleware'; +import { qrSignatureCreateRateLimit } from '@documenso/lib/server-only/rate-limit/rate-limits'; +import { nanoid } from '@documenso/lib/universal/id'; +import { prisma } from '@documenso/prisma'; +import { AnonymousVerificationTokenType } from '@prisma/client'; +import { DateTime } from 'luxon'; + +import { procedure } from '../../trpc'; +import { ZCreateQrSignatureRequestSchema, ZCreateQrSignatureResponseSchema } from './create-qr-signature.types'; + +/** + * NOTE: THIS IS A PUBLIC (UNAUTHENTICATED) PROCEDURE. + * + * Creates a short-lived anonymous session which allows a signature drawn on a + * mobile device to be handed off to the desktop signature pad. The token is + * the sole authorization for the session. + */ +export const createQrSignatureRoute = procedure + .input(ZCreateQrSignatureRequestSchema) + .output(ZCreateQrSignatureResponseSchema) + .mutation(async ({ input, ctx }) => { + const { context } = input; + + const { ipAddress } = ctx.metadata.requestMetadata; + + const rateLimitResult = await qrSignatureCreateRateLimit.check({ + ip: ipAddress ?? 'unknown', + }); + + assertRateLimit(rateLimitResult); + + if (context?.type === 'DOCUMENT_SIGNATURE') { + const recipient = await prisma.recipient.findFirst({ + where: { + token: context.recipientToken, + }, + select: { + id: true, + }, + }); + + if (!recipient) { + throw new AppError(AppErrorCode.NOT_FOUND, { + message: 'Recipient not found for the provided token', + }); + } + } + + const qrSignatureSession = await prisma.anonymousVerificationToken.create({ + data: { + type: AnonymousVerificationTokenType.QR_SIGNATURE, + token: nanoid(), + metadata: context ? { context } : undefined, + expiresAt: DateTime.now().plus({ minutes: QR_SIGNATURE_TOKEN_EXPIRY_MINUTES }).toJSDate(), + }, + }); + + return { + token: qrSignatureSession.token, + expiresAt: qrSignatureSession.expiresAt, + }; + }); diff --git a/packages/trpc/server/signature-router/qr/create-qr-signature.types.ts b/packages/trpc/server/signature-router/qr/create-qr-signature.types.ts new file mode 100644 index 000000000..184d5d7f2 --- /dev/null +++ b/packages/trpc/server/signature-router/qr/create-qr-signature.types.ts @@ -0,0 +1,14 @@ +import { ZQrSignatureContextSchema } from '@documenso/lib/types/qr-signature'; +import { z } from 'zod'; + +export const ZCreateQrSignatureRequestSchema = z.object({ + context: ZQrSignatureContextSchema.nullish(), +}); + +export const ZCreateQrSignatureResponseSchema = z.object({ + token: z.string(), + expiresAt: z.date(), +}); + +export type TCreateQrSignatureRequest = z.infer; +export type TCreateQrSignatureResponse = z.infer; diff --git a/packages/trpc/server/signature-router/qr/get-qr-signature-session.ts b/packages/trpc/server/signature-router/qr/get-qr-signature-session.ts new file mode 100644 index 000000000..d1765a440 --- /dev/null +++ b/packages/trpc/server/signature-router/qr/get-qr-signature-session.ts @@ -0,0 +1,99 @@ +import { ZQrSignatureContextSchema } from '@documenso/lib/types/qr-signature'; +import { prisma } from '@documenso/prisma'; +import { AnonymousVerificationTokenType } from '@prisma/client'; +import { z } from 'zod'; + +import { procedure } from '../../trpc'; +import { + ZGetQrSignatureSessionRequestSchema, + ZGetQrSignatureSessionResponseSchema, +} from './get-qr-signature-session.types'; + +const ZSessionMetadataSchema = z.object({ + context: ZQrSignatureContextSchema, +}); + +/** + * NOTE: THIS IS A PUBLIC (UNAUTHENTICATED) PROCEDURE. + * + * Classify a QR signature session token for the mobile signing page and + * resolve the context stored on the session. + * + * A missing row is indistinguishable from an expired one by design. + * + * Called once per page load; the global trpc rate limit covers it, matching + * the polling `qr.get` route. + */ +export const getQrSignatureSessionRoute = procedure + .input(ZGetQrSignatureSessionRequestSchema) + .output(ZGetQrSignatureSessionResponseSchema) + .query(async ({ input }) => { + const { token } = input; + + const qrSignatureSession = await prisma.anonymousVerificationToken.findUnique({ + where: { + token, + type: AnonymousVerificationTokenType.QR_SIGNATURE, + }, + }); + + if (!qrSignatureSession || qrSignatureSession.expiresAt < new Date()) { + return { status: 'EXPIRED' } as const; + } + + if (qrSignatureSession.value) { + return { status: 'ALREADY_SUBMITTED' } as const; + } + + const parsedMetadata = ZSessionMetadataSchema.nullish().safeParse(qrSignatureSession.metadata); + + if (!parsedMetadata.success) { + return { status: 'INVALID' } as const; + } + + // Sessions created without a context are valid, but generic. + if (!parsedMetadata.data) { + return { status: 'VALID', context: { type: 'NONE' } } as const; + } + + const { context } = parsedMetadata.data; + + if (context.type === 'PROFILE_SIGNATURE') { + return { status: 'VALID', context: { type: context.type } } as const; + } + + if (context.recipientToken.length < 1) { + return { status: 'INVALID' } as const; + } + + const recipient = await prisma.recipient.findFirst({ + where: { + token: context.recipientToken, + }, + select: { + envelope: { + select: { + title: true, + team: { + select: { + name: true, + }, + }, + }, + }, + }, + }); + + if (!recipient) { + return { status: 'INVALID' } as const; + } + + return { + status: 'VALID', + context: { + type: 'DOCUMENT_SIGNATURE', + documentTitle: recipient.envelope.title, + teamName: recipient.envelope.team.name, + }, + } as const; + }); diff --git a/packages/trpc/server/signature-router/qr/get-qr-signature-session.types.ts b/packages/trpc/server/signature-router/qr/get-qr-signature-session.types.ts new file mode 100644 index 000000000..0ef9a1f4d --- /dev/null +++ b/packages/trpc/server/signature-router/qr/get-qr-signature-session.types.ts @@ -0,0 +1,47 @@ +import { z } from 'zod'; + +export const ZGetQrSignatureSessionRequestSchema = z.object({ + token: z.string().min(1).max(64).describe('The QR signature session token'), +}); + +/** + * The resolved context of a valid QR signature session. + * + * `NONE` is a session created without any context, in which case the mobile + * page shows a generic "Signature requested". + */ +export const ZQrSignatureSessionContextSchema = z.discriminatedUnion('type', [ + z.object({ + type: z.literal('NONE'), + }), + z.object({ + type: z.literal('PROFILE_SIGNATURE'), + }), + z.object({ + type: z.literal('DOCUMENT_SIGNATURE'), + documentTitle: z.string(), + teamName: z.string(), + }), +]); + +export const ZGetQrSignatureSessionResponseSchema = z.discriminatedUnion('status', [ + z.object({ + status: z.literal('EXPIRED'), + }), + z.object({ + status: z.literal('ALREADY_SUBMITTED'), + }), + z.object({ + // The session references a signing flow that no longer exists, or carries + // malformed metadata. + status: z.literal('INVALID'), + }), + z.object({ + status: z.literal('VALID'), + context: ZQrSignatureSessionContextSchema, + }), +]); + +export type TGetQrSignatureSessionRequest = z.infer; +export type TGetQrSignatureSessionResponse = z.infer; +export type TQrSignatureSessionContext = z.infer; diff --git a/packages/trpc/server/signature-router/qr/get-qr-signature.ts b/packages/trpc/server/signature-router/qr/get-qr-signature.ts new file mode 100644 index 000000000..7f65e8892 --- /dev/null +++ b/packages/trpc/server/signature-router/qr/get-qr-signature.ts @@ -0,0 +1,57 @@ +import { prisma } from '@documenso/prisma'; +import { AnonymousVerificationTokenType } from '@prisma/client'; + +import { procedure } from '../../trpc'; +import { ZGetQrSignatureRequestSchema, ZGetQrSignatureResponseSchema } from './get-qr-signature.types'; + +/** + * NOTE: THIS IS A PUBLIC (UNAUTHENTICATED) PROCEDURE. + * + * Polled by the desktop signature pad while waiting for a mobile signature. + * + * A missing row is indistinguishable from an expired one by design, so we + * return EXPIRED for both. Once the signature is returned the row is deleted, + * making the token single-use. + */ +export const getQrSignatureRoute = procedure + .input(ZGetQrSignatureRequestSchema) + .output(ZGetQrSignatureResponseSchema) + .query(async ({ input }) => { + const { token } = input; + + const qrSignatureSession = await prisma.anonymousVerificationToken.findUnique({ + where: { + token, + type: AnonymousVerificationTokenType.QR_SIGNATURE, + }, + }); + + if (!qrSignatureSession || qrSignatureSession.expiresAt < new Date()) { + return { + status: 'EXPIRED', + } as const; + } + + if (!qrSignatureSession.value) { + return { + status: 'PENDING', + } as const; + } + + const { count: deletedCount } = await prisma.anonymousVerificationToken.deleteMany({ + where: { + id: qrSignatureSession.id, + }, + }); + + if (deletedCount === 0) { + return { + status: 'EXPIRED', + } as const; + } + + return { + status: 'COMPLETED', + signature: qrSignatureSession.value, + } as const; + }); diff --git a/packages/trpc/server/signature-router/qr/get-qr-signature.types.ts b/packages/trpc/server/signature-router/qr/get-qr-signature.types.ts new file mode 100644 index 000000000..6d5718648 --- /dev/null +++ b/packages/trpc/server/signature-router/qr/get-qr-signature.types.ts @@ -0,0 +1,21 @@ +import { z } from 'zod'; + +export const ZGetQrSignatureRequestSchema = z.object({ + token: z.string().min(1).max(64).describe('The QR signature session token to poll'), +}); + +export const ZGetQrSignatureResponseSchema = z.discriminatedUnion('status', [ + z.object({ + status: z.literal('PENDING'), + }), + z.object({ + status: z.literal('EXPIRED'), + }), + z.object({ + status: z.literal('COMPLETED'), + signature: z.string(), + }), +]); + +export type TGetQrSignatureRequest = z.infer; +export type TGetQrSignatureResponse = z.infer; diff --git a/packages/trpc/server/signature-router/router.ts b/packages/trpc/server/signature-router/router.ts new file mode 100644 index 000000000..79dfe00ba --- /dev/null +++ b/packages/trpc/server/signature-router/router.ts @@ -0,0 +1,14 @@ +import { router } from '../trpc'; +import { completeQrSignatureRoute } from './qr/complete-qr-signature'; +import { createQrSignatureRoute } from './qr/create-qr-signature'; +import { getQrSignatureRoute } from './qr/get-qr-signature'; +import { getQrSignatureSessionRoute } from './qr/get-qr-signature-session'; + +export const signatureRouter = router({ + qr: { + create: createQrSignatureRoute, + get: getQrSignatureRoute, + getSession: getQrSignatureSessionRoute, + complete: completeQrSignatureRoute, + }, +}); diff --git a/packages/trpc/server/team-router/update-team-settings.ts b/packages/trpc/server/team-router/update-team-settings.ts index eea40e90d..201cc9f91 100644 --- a/packages/trpc/server/team-router/update-team-settings.ts +++ b/packages/trpc/server/team-router/update-team-settings.ts @@ -36,6 +36,7 @@ export const updateTeamSettingsRoute = authenticatedProcedure typedSignatureEnabled, uploadSignatureEnabled, drawSignatureEnabled, + qrSignatureEnabled, delegateDocumentOwnership, envelopeExpirationPeriod, reminderSettings, @@ -66,7 +67,12 @@ export const updateTeamSettingsRoute = authenticatedProcedure } // Signatures will only be inherited if all are NULL. - if (typedSignatureEnabled === false && uploadSignatureEnabled === false && drawSignatureEnabled === false) { + if ( + typedSignatureEnabled === false && + uploadSignatureEnabled === false && + drawSignatureEnabled === false && + qrSignatureEnabled === false + ) { throw new AppError(AppErrorCode.INVALID_BODY, { message: 'At least one signature type must be enabled', }); @@ -168,6 +174,7 @@ export const updateTeamSettingsRoute = authenticatedProcedure typedSignatureEnabled, uploadSignatureEnabled, drawSignatureEnabled, + qrSignatureEnabled, delegateDocumentOwnership, envelopeExpirationPeriod: envelopeExpirationPeriod === null ? Prisma.DbNull : envelopeExpirationPeriod, reminderSettings: reminderSettings === null ? Prisma.DbNull : reminderSettings, diff --git a/packages/trpc/server/team-router/update-team-settings.types.ts b/packages/trpc/server/team-router/update-team-settings.types.ts index 72990ce77..062a9b61e 100644 --- a/packages/trpc/server/team-router/update-team-settings.types.ts +++ b/packages/trpc/server/team-router/update-team-settings.types.ts @@ -29,6 +29,7 @@ export const ZUpdateTeamSettingsRequestSchema = z.object({ typedSignatureEnabled: z.boolean().nullish(), uploadSignatureEnabled: z.boolean().nullish(), drawSignatureEnabled: z.boolean().nullish(), + qrSignatureEnabled: z.boolean().nullish(), delegateDocumentOwnership: z.boolean().nullish(), envelopeExpirationPeriod: ZEnvelopeExpirationPeriod.nullish(), reminderSettings: ZEnvelopeReminderSettings.nullish(), diff --git a/packages/trpc/server/template-router/schema.ts b/packages/trpc/server/template-router/schema.ts index 913828b28..74f5e05dd 100644 --- a/packages/trpc/server/template-router/schema.ts +++ b/packages/trpc/server/template-router/schema.ts @@ -9,6 +9,7 @@ import { ZDocumentMetaDrawSignatureEnabledSchema, ZDocumentMetaLanguageSchema, ZDocumentMetaMessageSchema, + ZDocumentMetaQrSignatureEnabledSchema, ZDocumentMetaRedirectUrlSchema, ZDocumentMetaSubjectSchema, ZDocumentMetaTimezoneSchema, @@ -66,6 +67,7 @@ export const ZTemplateMetaUpsertSchema = z.object({ typedSignatureEnabled: ZDocumentMetaTypedSignatureEnabledSchema.optional(), uploadSignatureEnabled: ZDocumentMetaUploadSignatureEnabledSchema.optional(), drawSignatureEnabled: ZDocumentMetaDrawSignatureEnabledSchema.optional(), + qrSignatureEnabled: ZDocumentMetaQrSignatureEnabledSchema.optional(), signingOrder: z.nativeEnum(DocumentSigningOrder).optional(), allowDictateNextSigner: z.boolean().optional(), }); @@ -147,6 +149,7 @@ export const ZCreateDocumentFromTemplateRequestSchema = z.object({ typedSignatureEnabled: ZDocumentMetaTypedSignatureEnabledSchema.optional(), uploadSignatureEnabled: ZDocumentMetaUploadSignatureEnabledSchema.optional(), drawSignatureEnabled: ZDocumentMetaDrawSignatureEnabledSchema.optional(), + qrSignatureEnabled: ZDocumentMetaQrSignatureEnabledSchema.optional(), allowDictateNextSigner: z.boolean().optional(), envelopeExpirationPeriod: ZEnvelopeExpirationPeriod.nullish(), }) diff --git a/packages/ui/package.json b/packages/ui/package.json index 70f51658d..1aa00bbad 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -50,6 +50,7 @@ "tailwind-merge": "^1.14.0", "tailwindcss-animate": "^1.0.7", "ts-pattern": "^5.9.0", + "uqr": "^0.1.2", "zod": "^3.25.76" } } diff --git a/packages/ui/primitives/signature-pad/point.ts b/packages/ui/primitives/signature-pad/point.ts index 651b0a122..00526badb 100644 --- a/packages/ui/primitives/signature-pad/point.ts +++ b/packages/ui/primitives/signature-pad/point.ts @@ -77,9 +77,20 @@ export class Point implements PointLike { let x = Math.min(Math.max(left, clientX), right) - left; let y = Math.min(Math.max(top, clientY), bottom) - top; - // adjust for DPI - x *= dpi; - y *= dpi; + // Adjust for DPI. Canvas bitmaps are sized once at mount, so if the element + // has been resized since (fluid container, device rotation) the nominal dpi + // no longer matches reality — use the actual bitmap / CSS box ratio so the + // ink always lands under the pointer. + let scaleX = dpi; + let scaleY = dpi; + + if (target instanceof HTMLCanvasElement && right - left > 0 && bottom - top > 0) { + scaleX = target.width / (right - left); + scaleY = target.height / (bottom - top); + } + + x *= scaleX; + y *= scaleY; return new Point(x, y); } diff --git a/packages/ui/primitives/signature-pad/signature-pad-dialog.tsx b/packages/ui/primitives/signature-pad/signature-pad-dialog.tsx index 1dfc89048..e156473fd 100644 --- a/packages/ui/primitives/signature-pad/signature-pad-dialog.tsx +++ b/packages/ui/primitives/signature-pad/signature-pad-dialog.tsx @@ -1,3 +1,4 @@ +import type { TQrSignatureContext } from '@documenso/lib/types/qr-signature'; import { parseMessageDescriptor } from '@documenso/lib/utils/i18n'; import { Dialog, DialogClose, DialogContent, DialogFooter } from '@documenso/ui/primitives/dialog'; @@ -22,6 +23,8 @@ export type SignaturePadDialogProps = Omit, 'o typedSignatureEnabled?: boolean; uploadSignatureEnabled?: boolean; drawSignatureEnabled?: boolean; + qrSignatureEnabled?: boolean; + qrSignatureContext?: TQrSignatureContext; }; export const SignaturePadDialog = ({ @@ -34,6 +37,8 @@ export const SignaturePadDialog = ({ typedSignatureEnabled, uploadSignatureEnabled, drawSignatureEnabled, + qrSignatureEnabled, + qrSignatureContext, dialogConfirmText, }: SignaturePadDialogProps) => { const { i18n } = useLingui(); @@ -121,6 +126,8 @@ export const SignaturePadDialog = ({ typedSignatureEnabled={typedSignatureEnabled} uploadSignatureEnabled={uploadSignatureEnabled} drawSignatureEnabled={drawSignatureEnabled} + qrSignatureEnabled={qrSignatureEnabled} + qrSignatureContext={qrSignatureContext} /> diff --git a/packages/ui/primitives/signature-pad/signature-pad-draw.tsx b/packages/ui/primitives/signature-pad/signature-pad-draw.tsx index 19bb0b867..e8da5f9a0 100644 --- a/packages/ui/primitives/signature-pad/signature-pad-draw.tsx +++ b/packages/ui/primitives/signature-pad/signature-pad-draw.tsx @@ -260,14 +260,14 @@ export const SignaturePadDraw = ({ className, value, onChange, ...props }: Signa }); return ( -
+
onMouseMove(event)} onPointerDown={(event) => onMouseDown(event)} onPointerUp={(event) => onMouseUp(event)} diff --git a/packages/ui/primitives/signature-pad/signature-pad-qr.tsx b/packages/ui/primitives/signature-pad/signature-pad-qr.tsx new file mode 100644 index 000000000..67c4a6015 --- /dev/null +++ b/packages/ui/primitives/signature-pad/signature-pad-qr.tsx @@ -0,0 +1,277 @@ +import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app'; +import { SIGNATURE_CANVAS_DPI } from '@documenso/lib/constants/signatures'; +import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc'; +import type { TQrSignatureContext } from '@documenso/lib/types/qr-signature'; +import { trpc } from '@documenso/trpc/react'; + +import { Trans, useLingui } from '@lingui/react/macro'; +import { Loader2Icon, RefreshCwIcon } from 'lucide-react'; +import { useEffect, useRef, useState } from 'react'; +import { renderSVG } from 'uqr'; + +import { cn } from '../../lib/utils'; +import { Button } from '../button'; +import { SignatureRender } from './signature-render'; + +export type QrSignatureSession = { + token: string; + expiresAt: Date; +}; + +/** + * Redraw a signature onto a canvas of the given size, scaled to fit and + * centered. + * + * The phone pad's canvas has different dimensions to the local draw pad, and + * the draw pad renders its value at natural size without scaling - committing + * the phone's PNG directly would make it render smaller (or larger) than the + * preview. Normalising to the local pad's dimensions keeps every consumer of + * the value untouched. + */ +const normalizeSignatureSize = async (dataUrl: string, targetWidth: number, targetHeight: number): Promise => + new Promise((resolve) => { + const img = new Image(); + + img.onload = () => { + const canvas = document.createElement('canvas'); + + canvas.width = targetWidth; + canvas.height = targetHeight; + + const ctx = canvas.getContext('2d'); + + if (!ctx) { + resolve(dataUrl); + return; + } + + ctx.imageSmoothingEnabled = true; + ctx.imageSmoothingQuality = 'high'; + + const scale = Math.min(targetWidth / img.width, targetHeight / img.height); + + const scaledWidth = img.width * scale; + const scaledHeight = img.height * scale; + + ctx.drawImage(img, (targetWidth - scaledWidth) / 2, (targetHeight - scaledHeight) / 2, scaledWidth, scaledHeight); + + resolve(canvas.toDataURL()); + }; + + img.onerror = () => resolve(dataUrl); + + img.src = dataUrl; + }); + +export type SignaturePadQrProps = { + className?: string; + value: string; + onChange: (_signatureDataUrl: string) => void; + session: QrSignatureSession | null; + onSessionChange: (_session: QrSignatureSession | null) => void; + + /** + * What the handoff signature is for. Rendered on the mobile signing page so + * the signer can see the context of what they are signing. When omitted the + * mobile page shows a generic "Signature requested". + */ + context?: TQrSignatureContext; +}; + +/** + * The "Mobile" tab of the signature pad. + * + * Displays a QR code linking to a public mobile drawing page, then polls until + * the phone submits a signature. The received signature is committed as a + * drawn (base64 PNG) signature via `onChange`. + * + * The session lives in the parent so that switching tabs does not invalidate + * an in-flight handoff (tab contents unmount when inactive). + */ +export const SignaturePadQr = ({ + className, + value, + onChange, + session, + onSessionChange, + context, +}: SignaturePadQrProps) => { + const { t } = useLingui(); + + const hasFiredCreateRef = useRef(false); + const $container = useRef(null); + + // Only show the preview for a signature received during this mount - a value + // drawn on another tab renders the QR code so the handoff stays available + // without destroying the committed signature. + const [hasReceivedSignature, setHasReceivedSignature] = useState(false); + + const { mutate: createQrSignatureSession, isError: isCreateSessionError } = trpc.signature.qr.create.useMutation({ + ...DO_NOT_INVALIDATE_QUERY_ON_MUTATION, + onSuccess: (data) => { + onSessionChange(data); + }, + }); + + const { data: qrSignatureData } = trpc.signature.qr.get.useQuery( + { + token: session?.token ?? '', + }, + { + enabled: Boolean(session), + refetchInterval: (query) => + query.state.data?.status === 'COMPLETED' || query.state.data?.status === 'EXPIRED' ? false : 2500, + }, + ); + + useEffect(() => { + if (!session && !hasFiredCreateRef.current) { + hasFiredCreateRef.current = true; + createQrSignatureSession({ context }); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + useEffect(() => { + if (qrSignatureData?.status !== 'COMPLETED') { + return; + } + + // The tab container has the same box as the draw tab, so its measured size + // matches the draw pad's canvas dimensions. + const container = $container.current; + + const targetWidth = container ? Math.round(container.clientWidth * SIGNATURE_CANVAS_DPI) : 0; + const targetHeight = container ? Math.round(container.clientHeight * SIGNATURE_CANVAS_DPI) : 0; + + if (targetWidth <= 0 || targetHeight <= 0) { + onChange(qrSignatureData.signature); + onSessionChange(null); + setHasReceivedSignature(true); + return; + } + + let isCancelled = false; + + void normalizeSignatureSize(qrSignatureData.signature, targetWidth, targetHeight).then((normalizedSignature) => { + if (!isCancelled) { + onChange(normalizedSignature); + onSessionChange(null); + setHasReceivedSignature(true); + } + }); + + return () => { + isCancelled = true; + }; + // Note: `onChange`/`onSessionChange` are fresh closures from the parent each + // render, so including them would re-fire this effect spuriously. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [qrSignatureData]); + + const onGenerateNewCodeClick = () => { + onSessionChange(null); + createQrSignatureSession({ context }); + }; + + const onScanAgainClick = () => { + setHasReceivedSignature(false); + onSessionChange(null); + createQrSignatureSession({ context }); + }; + + // Only a signature received via this tab shows the preview; any other + // value keeps the QR available. + if (value && hasReceivedSignature) { + return ( +
+ + +
+ +
+
+ ); + } + + if (isCreateSessionError || qrSignatureData?.status === 'EXPIRED') { + return ( +
+

+ {isCreateSessionError ? ( + Something went wrong. Please try again. + ) : ( + This QR code has expired. + )} +

+ + +
+ ); + } + + // Session is being created (or is about to be) - show the loader. This must + // never depend on the mutation's isPending, which can be stale after a + // StrictMode double-mount. + if (!session) { + return ( +
+ + + Loading + +
+ ); + } + + const mobileSigningUrl = `${NEXT_PUBLIC_WEBAPP_URL()}/mobile-signature/${session.token}`; + + return ( +
+ {/* The QR absorbs whatever vertical space is left over, so the labels + below always keep their room and can never be pushed out of the pad. */} +
+
+
+ +

+ Scan with your phone to draw your signature +

+ +

+ {mobileSigningUrl} +

+
+ ); +}; diff --git a/packages/ui/primitives/signature-pad/signature-pad.tsx b/packages/ui/primitives/signature-pad/signature-pad.tsx index 734daaa97..8b7880b02 100644 --- a/packages/ui/primitives/signature-pad/signature-pad.tsx +++ b/packages/ui/primitives/signature-pad/signature-pad.tsx @@ -1,15 +1,16 @@ import { DocumentSignatureType } from '@documenso/lib/constants/document'; import { isBase64Image } from '@documenso/lib/constants/signatures'; - +import type { TQrSignatureContext } from '@documenso/lib/types/qr-signature'; import { Trans } from '@lingui/react/macro'; -import { KeyboardIcon, UploadCloudIcon } from 'lucide-react'; +import { KeyboardIcon, SmartphoneIcon, UploadCloudIcon } from 'lucide-react'; import type { HTMLAttributes } from 'react'; import { useState } from 'react'; -import { match } from 'ts-pattern'; - +import { match, P } from 'ts-pattern'; import { SignatureIcon } from '../../icons/signature'; import { cn } from '../../lib/utils'; import { SignaturePadDraw } from './signature-pad-draw'; +import type { QrSignatureSession } from './signature-pad-qr'; +import { SignaturePadQr } from './signature-pad-qr'; import { SignaturePadType } from './signature-pad-type'; import { SignaturePadUpload } from './signature-pad-upload'; import { Tabs, TabsContent, TabsList, TabsTrigger } from './signature-tabs'; @@ -29,6 +30,8 @@ export type SignaturePadProps = Omit, 'onChang typedSignatureEnabled?: boolean; uploadSignatureEnabled?: boolean; drawSignatureEnabled?: boolean; + qrSignatureEnabled?: boolean; + qrSignatureContext?: TQrSignatureContext; onValidityChange?: (isValid: boolean) => void; }; @@ -41,11 +44,15 @@ export const SignaturePad = ({ typedSignatureEnabled = true, uploadSignatureEnabled = true, drawSignatureEnabled = true, + qrSignatureEnabled = true, + qrSignatureContext, }: SignaturePadProps) => { const [imageSignature, setImageSignature] = useState(isBase64Image(value) ? value : ''); const [drawSignature, setDrawSignature] = useState(isBase64Image(value) ? value : ''); const [typedSignature, setTypedSignature] = useState(isBase64Image(value) ? '' : value); + const [qrSession, setQrSession] = useState(null); + /** * This is cooked. * @@ -53,7 +60,7 @@ export const SignaturePad = ({ * the first enabled tab. */ const [tab, setTab] = useState( - ((): 'draw' | 'text' | 'image' => { + ((): 'draw' | 'text' | 'image' | 'qr' => { // First passthrough to check to see if there's a signature for a given tab. if (drawSignatureEnabled && drawSignature) { return 'draw'; @@ -80,6 +87,10 @@ export const SignaturePad = ({ return 'image'; } + if (qrSignatureEnabled) { + return 'qr'; + } + throw new Error('No signature enabled'); })(), ); @@ -111,7 +122,7 @@ export const SignaturePad = ({ }); }; - const onTabChange = (value: 'draw' | 'text' | 'image') => { + const onTabChange = (value: 'draw' | 'text' | 'image' | 'qr') => { if (disabled) { return; } @@ -119,7 +130,7 @@ export const SignaturePad = ({ setTab(value); match(value) - .with('draw', () => { + .with(P.union('draw', 'qr'), () => { onDrawSignatureChange(drawSignature); }) .with('text', () => { @@ -131,7 +142,7 @@ export const SignaturePad = ({ .exhaustive(); }; - if (!drawSignatureEnabled && !typedSignatureEnabled && !uploadSignatureEnabled) { + if (!drawSignatureEnabled && !typedSignatureEnabled && !uploadSignatureEnabled && !qrSignatureEnabled) { return null; } @@ -142,7 +153,7 @@ export const SignaturePad = ({ 'pointer-events-none': disabled, })} // eslint-disable-next-line @typescript-eslint/consistent-type-assertions - onValueChange={(value) => onTabChange(value as 'draw' | 'text' | 'image')} + onValueChange={(value) => onTabChange(value as 'draw' | 'text' | 'image' | 'qr')} > {drawSignatureEnabled && ( @@ -152,6 +163,13 @@ export const SignaturePad = ({ )} + {qrSignatureEnabled && ( + + + Mobile + + )} + {typedSignatureEnabled && ( @@ -174,6 +192,19 @@ export const SignaturePad = ({ + + + +