Compare commits

...
Author SHA1 Message Date
David Nguyen 9825ea88b1 feat: add qr signatures 2026-08-18 11:49:15 +10:00
Lucas Smith 779de01fe8 feat: migrate to react 19 (#3107) 2026-08-17 16:58:11 +10:00
Lucas Smith 283c6d274b fix: use documenso fork of skia-canvas for rendering (#3214)
Use our fork of `skia-canvas` for rendering which handles
encoding characters correctly with the caveat font and other
similar fonts that can group glyphs like ligatures.

This resolves issues with pdf text extraction where characters
were unable to be extracted due to lacking any data within the cmaps.
2026-08-17 15:23:19 +10:00
Catalin Pit 688ef2fdf3 fix: docker healtcheck (#3176) 2026-08-14 13:02:48 +03:00
Konrad a5e37af3e8 chore: update Polish translations (#3210) 2026-08-14 10:40:42 +10:00
132 changed files with 3409 additions and 2627 deletions
+1 -1
View File
@@ -1,3 +1,3 @@
legacy-peer-deps = true
prefer-dedupe = true
# min-release-age = 7
min-release-age = 7
@@ -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 |
---
@@ -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
},
}),
});
@@ -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
@@ -81,7 +81,7 @@ services:
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD:?err}
- POSTGRES_DB=${POSTGRES_DB:?err}
healthcheck:
test: ['CMD-SHELL', 'pg_isready -U ${POSTGRES_USER}']
test: ['CMD-SHELL', 'pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}']
interval: 10s
timeout: 5s
retries: 5
-1
View File
@@ -10,7 +10,6 @@
"postinstall": "fumadocs-mdx"
},
"dependencies": {
"@radix-ui/react-tabs": "^1.1.13",
"fumadocs-core": "16.5.0",
"fumadocs-mdx": "14.2.6",
"fumadocs-ui": "16.5.0",
+1 -1
View File
@@ -16,7 +16,7 @@
},
"devDependencies": {
"@types/node": "^20",
"@types/react": "18.3.27",
"@types/react": "^19.2.17",
"typescript": "5.6.2"
}
}
@@ -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<SignFieldSignatureDialogProps, string | null>(
({ 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<SignFieldSignatureDialogP
typedSignatureEnabled={typedSignatureEnabled}
uploadSignatureEnabled={uploadSignatureEnabled}
drawSignatureEnabled={drawSignatureEnabled}
qrSignatureEnabled={qrSignatureEnabled}
qrSignatureContext={qrSignatureContext}
/>
</div>
@@ -470,6 +470,7 @@ export const EmbedDirectTemplateClientPage = ({
typedSignatureEnabled={metadata?.typedSignatureEnabled}
uploadSignatureEnabled={metadata?.uploadSignatureEnabled}
drawSignatureEnabled={metadata?.drawSignatureEnabled}
qrSignatureEnabled={metadata?.qrSignatureEnabled}
/>
</div>
)}
@@ -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> | void;
onUnsignField?: (value: TRemovedSignedFieldWithTokenMutationSchema) => Promise<void> | 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, () => (
@@ -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 }}
/>
</div>
)}
@@ -313,6 +313,7 @@ export const MultiSignDocumentSigningView = ({
typedSignatureEnabled={document.documentMeta?.typedSignatureEnabled}
uploadSignatureEnabled={document.documentMeta?.uploadSignatureEnabled}
drawSignatureEnabled={document.documentMeta?.drawSignatureEnabled}
qrSignatureEnabled={document.documentMeta?.qrSignatureEnabled}
/>
</div>
)}
@@ -55,6 +55,7 @@ type SettingsSubset = Pick<
| 'typedSignatureEnabled'
| 'uploadSignatureEnabled'
| 'drawSignatureEnabled'
| 'qrSignatureEnabled'
| 'defaultRecipients'
| 'delegateDocumentOwnership'
| 'aiFeaturesEnabled'
@@ -111,6 +111,7 @@ export const ProfileForm = ({ className }: ProfileFormProps) => {
<FormControl>
<SignaturePadDialog
disabled={isSubmitting}
qrSignatureContext={{ type: 'PROFILE_SIGNATURE' }}
fullName={user.name ?? ''}
value={value}
onChange={(v) => onChange(v ?? '')}
@@ -314,6 +314,7 @@ export const SignUpForm = ({
<FormControl>
<SignaturePadDialog
disabled={isSubmitting}
qrSignatureContext={{ type: 'PROFILE_SIGNATURE' }}
value={value}
onChange={(v) => onChange(v ?? '')}
/>
@@ -156,6 +156,10 @@ export const AdminGlobalSettingsSection = ({
</DetailsValue>
</DetailsCard>
<DetailsCard label={<Trans>QR signature</Trans>}>
<DetailsValue>{booleanValue(settings.qrSignatureEnabled, inheritedSettings?.qrSignatureEnabled)}</DetailsValue>
</DetailsCard>
<DetailsCard label={<Trans>Branding</Trans>}>
<DetailsValue>{booleanValue(settings.brandingEnabled, inheritedSettings?.brandingEnabled)}</DetailsValue>
</DetailsCard>
@@ -19,7 +19,7 @@ import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { keepPreviousData } from '@tanstack/react-query';
import { commandScore } from 'cmdk/dist/command-score';
import { defaultFilter as commandScore } from 'cmdk';
import {
ArrowLeftIcon,
CheckIcon,
@@ -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}
/>
</div>
</div>
@@ -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 }}
/>
</div>
)}
@@ -408,6 +408,7 @@ export const DocumentSigningPageViewV1 = ({
typedSignatureEnabled={documentMeta?.typedSignatureEnabled}
uploadSignatureEnabled={documentMeta?.uploadSignatureEnabled}
drawSignatureEnabled={documentMeta?.drawSignatureEnabled}
qrSignatureEnabled={documentMeta?.qrSignatureEnabled}
/>
))
.with(FieldType.INITIALS, () => <DocumentSigningInitialsField key={field.id} field={field} />)
@@ -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;
}
@@ -146,14 +146,17 @@ export const DocumentSigningRadioField = ({ field, onSignField, onUnsignField }:
{isLoading && <DocumentSigningFieldsLoader />}
{!field.inserted && (
<RadioGroup onValueChange={(value) => handleSelectItem(value)} className="z-10 my-0.5 gap-y-1">
<RadioGroup
value={selectedOption}
onValueChange={(value) => handleSelectItem(value)}
className="z-10 my-0.5 gap-y-1"
>
{values?.map((item, index) => (
<div key={index} className="flex items-center">
<RadioGroupItem
className="h-3 w-3 shrink-0"
value={item.value}
id={`option-${field.id}-${item.id}`}
checked={item.checked}
disabled={isReadOnly}
/>
{!item.value.includes('empty-value-') && item.value && (
@@ -167,14 +170,13 @@ export const DocumentSigningRadioField = ({ field, onSignField, onUnsignField }:
)}
{field.inserted && (
<RadioGroup className="my-0.5 gap-y-1">
<RadioGroup value={field.customText ?? ''} className="my-0.5 gap-y-1">
{values?.map((item, index) => (
<div key={index} className="flex items-center">
<RadioGroupItem
className="h-3 w-3"
value={item.value}
id={`option-${field.id}-${item.id}`}
checked={item.value === field.customText}
disabled={isReadOnly}
/>
{!item.value.includes('empty-value-') && item.value && (
@@ -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 }}
/>
<DocumentSigningDisclosure />
@@ -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;
}
@@ -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),
},
});
};
@@ -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,
},
@@ -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 }}
/>
</div>
)}
@@ -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) {
@@ -215,7 +215,7 @@ export default function PDFViewer({
type VirtualizedPageListProps = {
scrollParentRef: ScrollTarget;
constraintRef: React.RefObject<HTMLDivElement>;
constraintRef: React.RefObject<HTMLDivElement | null>;
pages: PageMeta[];
numPages: number;
pdf: pdfjsLib.PDFDocumentProxy;
@@ -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,
},
});
+21
View File
@@ -31,6 +31,26 @@ function initPosthog() {
}
}
/**
* Surfaces hydration recoveries (React 19 discards the server HTML and
* re-renders on the client instead of dying) so we can track how often
* extensions/early clicks interfere with hydration in the wild.
*/
function onRecoverableError(error: unknown, errorInfo: { componentStack?: string }) {
console.error('[hydration] recovered from error', error, errorInfo.componentStack);
if (extractPostHogConfig()) {
void import('posthog-js').then(({ default: posthog }) => {
if (posthog.__loaded) {
posthog.capture('$hydration_recoverable_error', {
message: error instanceof Error ? error.message : String(error),
componentStack: errorInfo.componentStack,
});
}
});
}
}
async function main() {
const locale = detect(fromHtmlTag('lang')) || 'en';
@@ -44,6 +64,7 @@ async function main() {
<HydratedRouter />
</I18nProvider>
</StrictMode>,
{ onRecoverableError },
);
});
@@ -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,
},
@@ -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,
},
@@ -215,6 +215,7 @@ const DirectSigningPageV1 = ({ data }: { data: Awaited<ReturnType<typeof handleV
typedSignatureEnabled={template.templateMeta?.typedSignatureEnabled}
uploadSignatureEnabled={template.templateMeta?.uploadSignatureEnabled}
drawSignatureEnabled={template.templateMeta?.drawSignatureEnabled}
qrSignatureEnabled={template.templateMeta?.qrSignatureEnabled}
>
<DocumentSigningAuthProvider
documentAuthOptions={template.authOptions}
@@ -474,6 +474,7 @@ const SigningPageV1 = ({ data }: { data: Awaited<ReturnType<typeof handleV1Loade
typedSignatureEnabled={document.documentMeta?.typedSignatureEnabled}
uploadSignatureEnabled={document.documentMeta?.uploadSignatureEnabled}
drawSignatureEnabled={document.documentMeta?.drawSignatureEnabled}
qrSignatureEnabled={document.documentMeta?.qrSignatureEnabled}
>
<DocumentSigningAuthProvider documentAuthOptions={document.authOptions} recipient={recipient} user={user}>
{sessionData?.user && <AuthenticatedHeader />}
@@ -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 (
<main className="relative flex min-h-screen flex-col items-center justify-center overflow-hidden px-4 py-12 md:p-12 lg:p-24">
<div>
<div className="absolute -inset-[min(600px,max(400px,60vw))] -z-[1] flex items-center justify-center opacity-70">
<img
src={backgroundPattern}
alt="background pattern"
className="dark:brightness-95 dark:contrast-[70%] dark:invert dark:sepia"
style={{
mask: 'radial-gradient(rgba(255, 255, 255, 1) 0%, transparent 80%)',
WebkitMask: 'radial-gradient(rgba(255, 255, 255, 1) 0%, transparent 80%)',
}}
/>
</div>
{!hideBackground && (
<div className="absolute -inset-[min(600px,max(400px,60vw))] -z-[1] flex items-center justify-center opacity-70">
<img
src={backgroundPattern}
alt="background pattern"
className="dark:brightness-95 dark:contrast-[70%] dark:invert dark:sepia"
style={{
mask: 'radial-gradient(rgba(255, 255, 255, 1) 0%, transparent 80%)',
WebkitMask: 'radial-gradient(rgba(255, 255, 255, 1) 0%, transparent 80%)',
}}
/>
</div>
)}
<div className="relative w-full">
<Outlet />
@@ -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 (
<div className="flex w-full flex-col items-center text-center">
<Loader2Icon className="size-8 animate-spin text-muted-foreground" />
<span className="sr-only">
<Trans>Loading</Trans>
</span>
</div>
);
}
if (session.status !== 'VALID' || isSessionError) {
return <QrSignatureError reason={session.status !== 'VALID' ? session.status : undefined} />;
}
return (
<div className="w-screen max-w-lg select-none px-4">
<QrSignature token={token} context={session.context} />
</div>
);
}
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<QrSignatureState>('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: <FileTextIcon className="size-6 text-primary" />,
}))
.with({ type: 'PROFILE_SIGNATURE' }, () => ({
title: t`Your signature`,
subtitle: t`Signature requested`,
icon: <PenLineIcon className="size-6 text-primary" />,
}))
// Context-less sessions show no subtitle, which would just repeat the title.
.with({ type: 'NONE' }, () => ({
title: t`Signature requested`,
subtitle: null,
icon: <PenLineIcon className="size-6 text-primary" />,
}))
.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 <QrSignatureError reason={state} />;
}
if (state === 'SUCCESS') {
return (
<div className="flex w-full flex-col items-center text-center">
<CheckCircle2Icon className="size-10 text-primary" />
<h1 className="mt-4 font-semibold text-2xl">
<Trans>Success</Trans>
</h1>
<p className="mt-2 text-muted-foreground text-sm">
<Trans>You can now return to your main device to continue.</Trans>
</p>
</div>
);
}
if (isPortrait) {
return (
<>
{/* Document context hero. */}
<div className="flex flex-col items-center pb-[45svh] text-center">
<div className="flex size-14 items-center justify-center rounded-xl border border-primary/30 bg-primary/10">
{contextInfo.icon}
</div>
<h1 className="mt-4 font-semibold text-2xl">{contextInfo.title}</h1>
{contextInfo.subtitle && <p className="mt-2 text-muted-foreground text-sm">{contextInfo.subtitle}</p>}
<div className="mt-4 flex items-center gap-2 rounded-md border border-border bg-background px-3 py-1.5 text-muted-foreground text-xs">
<span className="size-2 rounded-full bg-primary" />
<Trans>Connected</Trans>
</div>
</div>
{/* Persistent signing sheet - cannot be dismissed. */}
<Sheet open>
<SheetContent
position="bottom"
size="content"
showOverlay={false}
className="h-auto select-none rounded-t-2xl border-t px-4 pt-4 pb-6 [&>button:last-child]:hidden"
onEscapeKeyDown={(event) => event.preventDefault()}
onPointerDownOutside={(event) => event.preventDefault()}
onInteractOutside={(event) => event.preventDefault()}
>
<div className="mx-auto mb-3 h-1 w-10 rounded-full bg-muted" />
<SheetTitle className="font-semibold text-lg">
<Trans>Draw your signature</Trans>
</SheetTitle>
<div className="relative mt-3 flex aspect-signature-pad items-center justify-center rounded-md border border-border bg-muted/25">
<SignaturePadDraw className="h-full w-full" value={signature} onChange={(value) => setSignature(value)} />
</div>
{hasSubmissionError && (
<p className="mt-2 text-destructive text-sm">
<Trans>Something went wrong. Please try again.</Trans>
</p>
)}
<div className="mt-4 flex">
<Button
type="button"
className="flex-1"
disabled={!signature}
loading={isPending}
onClick={() => void onSubmitClick()}
>
<Trans>Next</Trans>
</Button>
</div>
</SheetContent>
</Sheet>
</>
);
}
// Landscape: a single card, no sheet.
return (
// Need this to override the parent layout styling.
<div className="fixed inset-0 z-50 flex select-none items-center justify-center bg-background p-2">
{/* 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. */}
<div className="flex max-h-full w-[min(100%,calc((100svh-4.5rem)*16/7))] max-w-lg flex-col">
<div className="mb-2 flex h-12 w-full shrink-0 items-center justify-between rounded-lg border border-border bg-muted/25 px-2">
<div className="flex min-w-0 items-center gap-3">
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg border border-primary/30 bg-primary/10">
{contextInfo.icon}
</div>
<div className="min-w-0">
<h1 className="truncate font-semibold text-sm">{contextInfo.title}</h1>
<p className="truncate text-muted-foreground text-xs">{contextInfo.subtitle}</p>
</div>
</div>
<Button
type="button"
size="sm"
className="ml-2 flex-shrink-0 px-6"
disabled={!signature}
loading={isPending}
onClick={() => void onSubmitClick()}
>
<Trans>Next</Trans>
</Button>
</div>
<div className="relative flex aspect-signature-pad w-full items-center justify-center rounded-md border border-border bg-muted/25">
<SignaturePadDraw className="h-full w-full" value={signature} onChange={(value) => setSignature(value)} />
</div>
{hasSubmissionError && (
<p className="mt-2 text-destructive text-sm">
<Trans>Something went wrong. Please try again.</Trans>
</p>
)}
</div>
</div>
);
};
type QrSignatureErrorReason = Exclude<TGetQrSignatureSessionResponse['status'], 'VALID'>;
type QrSignatureErrorProps = {
reason?: QrSignatureErrorReason;
};
const QrSignatureError = ({ reason }: QrSignatureErrorProps) => {
const content = match(reason)
.with('EXPIRED', () => ({
icon: <ClockIcon className="size-10 text-yellow-500" />,
title: <Trans>This link has expired</Trans>,
description: <Trans>Generate a new QR code on the original device and scan it again.</Trans>,
}))
.with('ALREADY_SUBMITTED', () => ({
icon: <CheckCircle2Icon className="size-10 text-primary" />,
title: <Trans>Signature already sent</Trans>,
description: <Trans>This link has already been used. Return to your computer to continue.</Trans>,
}))
.with('INVALID', () => ({
icon: <XCircleIcon className="size-10 text-muted-foreground" />,
title: <Trans>This signing request is invalid</Trans>,
description: (
<Trans>
The request is invalid or no longer exists. Scan the new QR code on the original device to try again.
</Trans>
),
}))
.with(undefined, () => ({
icon: <XCircleIcon className="size-10 text-muted-foreground" />,
title: <Trans>Something went wrong</Trans>,
description: <Trans>We couldn't load this signing request. Please refresh the page to try again.</Trans>,
}))
.exhaustive();
return (
<div className="flex w-full flex-col items-center text-center">
{content.icon}
<h1 className="mt-2 font-semibold text-2xl">{content.title}</h1>
<p className="mt-2 text-muted-foreground text-sm">{content.description}</p>
</div>
);
};
@@ -266,6 +266,7 @@ const EmbedDirectTemplatePageV1 = ({ data }: { data: Awaited<ReturnType<typeof h
typedSignatureEnabled={template.templateMeta?.typedSignatureEnabled}
uploadSignatureEnabled={template.templateMeta?.uploadSignatureEnabled}
drawSignatureEnabled={template.templateMeta?.drawSignatureEnabled}
qrSignatureEnabled={template.templateMeta?.qrSignatureEnabled}
>
<DocumentSigningAuthProvider documentAuthOptions={template.authOptions} recipient={recipient} user={user}>
<DocumentSigningRecipientProvider recipient={recipient}>
@@ -354,6 +354,7 @@ const EmbedSignDocumentPageV1 = ({ data }: { data: Awaited<ReturnType<typeof han
typedSignatureEnabled={document.documentMeta?.typedSignatureEnabled}
uploadSignatureEnabled={document.documentMeta?.uploadSignatureEnabled}
drawSignatureEnabled={document.documentMeta?.drawSignatureEnabled}
qrSignatureEnabled={document.documentMeta?.qrSignatureEnabled}
>
<DocumentSigningAuthProvider documentAuthOptions={document.authOptions} recipient={recipient} user={user}>
<EmbedSignDocumentV1ClientPage
@@ -83,6 +83,7 @@ export default function EmbeddingAuthoringDocumentCreatePage() {
drawSignatureEnabled: signatureTypes.length === 0 || signatureTypes.includes(DocumentSignatureType.DRAW),
typedSignatureEnabled: signatureTypes.length === 0 || signatureTypes.includes(DocumentSignatureType.TYPE),
uploadSignatureEnabled: signatureTypes.length === 0 || signatureTypes.includes(DocumentSignatureType.UPLOAD),
qrSignatureEnabled: signatureTypes.length === 0 || signatureTypes.includes(DocumentSignatureType.QR),
},
recipients: configuration.signers.map((signer) => ({
name: signer.name,
@@ -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,
@@ -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,
@@ -238,6 +238,7 @@ export default function MultisignPage() {
typedSignatureEnabled={selectedDocument.documentMeta?.typedSignatureEnabled}
uploadSignatureEnabled={selectedDocument.documentMeta?.uploadSignatureEnabled}
drawSignatureEnabled={selectedDocument.documentMeta?.drawSignatureEnabled}
qrSignatureEnabled={selectedDocument.documentMeta?.qrSignatureEnabled}
>
<DocumentSigningAuthProvider
documentAuthOptions={selectedDocument.authOptions}
@@ -224,6 +224,7 @@ const EnvelopeCreatePage = ({ embedAuthoringOptions }: EnvelopeCreatePageProps)
typedSignatureEnabled: envelope.documentMeta.typedSignatureEnabled ?? undefined,
uploadSignatureEnabled: envelope.documentMeta.uploadSignatureEnabled ?? undefined,
drawSignatureEnabled: envelope.documentMeta.drawSignatureEnabled ?? undefined,
qrSignatureEnabled: envelope.documentMeta.qrSignatureEnabled ?? undefined,
dateFormat: (envelope.documentMeta.dateFormat as TDocumentMetaDateFormat) ?? undefined,
language: envelope.documentMeta.language as SupportedLanguageCodes,
},
@@ -239,6 +239,7 @@ const EnvelopeEditPage = ({ embedAuthoringOptions }: EnvelopeEditPageProps) => {
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,
},
@@ -12,12 +12,23 @@ type HandleSignatureFieldClickOptions = {
typedSignatureEnabled?: boolean;
uploadSignatureEnabled?: boolean;
drawSignatureEnabled?: boolean;
qrSignatureEnabled?: boolean;
recipientToken?: string;
};
export const handleSignatureFieldClick = async (
options: HandleSignatureFieldClickOptions,
): Promise<Extract<TSignEnvelopeFieldValue, { type: typeof FieldType.SIGNATURE }> | 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,
});
}
+4 -4
View File
@@ -57,9 +57,9 @@
"papaparse": "^5.5.3",
"posthog-js": "^1.297.2",
"posthog-node": "4.18.0",
"react": "^18",
"react": "^19.2.7",
"react-call": "^1.8.1",
"react-dom": "^18",
"react-dom": "^19.2.7",
"react-dropzone": "^14.3.8",
"react-hook-form": "^7.66.1",
"react-hotkeys-hook": "^4.6.2",
@@ -93,8 +93,8 @@
"@types/luxon": "^3.7.1",
"@types/node": "^20",
"@types/papaparse": "^5.5.0",
"@types/react": "18.3.27",
"@types/react-dom": "^18",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"@types/ua-parser-js": "^0.7.39",
"cross-env": "^10.1.0",
"esbuild": "^0.27.0",
+1 -1
View File
@@ -121,7 +121,7 @@ export default defineConfig({
'nodemailer',
/playwright/,
'@playwright/browser-chromium',
'skia-canvas',
'@documenso/skia-canvas',
],
},
},
+1 -1
View File
@@ -7,7 +7,7 @@ services:
volumes:
- documenso_database:/var/lib/postgresql/data
healthcheck:
test: ['CMD-SHELL', 'pg_isready -U ${POSTGRES_USER}']
test: ['CMD-SHELL', 'pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB']
interval: 10s
timeout: 5s
retries: 5
+1 -1
View File
@@ -8,7 +8,7 @@ services:
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD:?err}
- POSTGRES_DB=${POSTGRES_DB:?err}
healthcheck:
test: ['CMD-SHELL', 'pg_isready -U ${POSTGRES_USER}']
test: ['CMD-SHELL', 'pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}']
interval: 10s
timeout: 5s
retries: 5
+1 -1
View File
@@ -8,7 +8,7 @@ services:
- POSTGRES_PASSWORD=password
- POSTGRES_DB=documenso
healthcheck:
test: ['CMD-SHELL', 'pg_isready -U documenso']
test: ['CMD-SHELL', 'pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB']
interval: 1s
timeout: 5s
retries: 5
+1382 -2280
View File
File diff suppressed because it is too large Load Diff
+39 -4
View File
@@ -48,14 +48,17 @@
},
"devDependencies": {
"@biomejs/biome": "2.4.8",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"@commitlint/cli": "^20.1.0",
"@commitlint/config-conventional": "^20.0.0",
"@datadog/pprof": "^5.13.5",
"@documenso/skia-canvas": "^3.0.8-documenso.3",
"@lingui/cli": "^5.6.0",
"@prisma/client": "^6.19.0",
"@trpc/client": "11.8.1",
"@trpc/react-query": "11.8.1",
"@trpc/server": "11.8.1",
"@trpc/client": "11.17.0",
"@trpc/react-query": "11.17.0",
"@trpc/server": "11.17.0",
"@ts-rest/core": "^3.52.1",
"@ts-rest/open-api": "^3.52.1",
"@ts-rest/serverless": "^3.52.1",
@@ -92,13 +95,41 @@
"@lingui/conf": "^5.6.0",
"@lingui/core": "^5.6.0",
"@prisma/extension-read-replicas": "^0.4.1",
"@radix-ui/react-accordion": "^1.2.16",
"@radix-ui/react-alert-dialog": "^1.1.19",
"@radix-ui/react-aspect-ratio": "^1.1.11",
"@radix-ui/react-avatar": "^1.2.2",
"@radix-ui/react-checkbox": "^1.3.7",
"@radix-ui/react-collapsible": "^1.1.16",
"@radix-ui/react-context-menu": "^2.3.3",
"@radix-ui/react-dialog": "^1.1.19",
"@radix-ui/react-dropdown-menu": "^2.1.20",
"@radix-ui/react-hover-card": "^1.1.19",
"@radix-ui/react-label": "^2.1.11",
"@radix-ui/react-menubar": "^1.1.20",
"@radix-ui/react-navigation-menu": "^1.2.18",
"@radix-ui/react-popover": "^1.1.19",
"@radix-ui/react-progress": "^1.1.12",
"@radix-ui/react-radio-group": "^1.4.3",
"@radix-ui/react-scroll-area": "^1.2.14",
"@radix-ui/react-select": "^2.3.3",
"@radix-ui/react-separator": "^1.1.11",
"@radix-ui/react-slider": "^1.4.3",
"@radix-ui/react-slot": "^1.3.0",
"@radix-ui/react-switch": "^1.3.3",
"@radix-ui/react-tabs": "^1.1.17",
"@radix-ui/react-toast": "^1.2.19",
"@radix-ui/react-toggle": "^1.1.14",
"@radix-ui/react-toggle-group": "^1.1.15",
"@radix-ui/react-tooltip": "^1.2.12",
"ai": "^5.0.104",
"cron-parser": "^5.5.0",
"fflate": "^0.8.3",
"luxon": "^3.7.2",
"patch-package": "^8.0.1",
"posthog-node": "4.18.0",
"react": "^18",
"react": "^19.2.7",
"react-dom": "^19.2.7",
"typescript": "5.6.2",
"@marsidev/react-turnstile": "^1.5.0",
"zod": "^3.25.76"
@@ -107,6 +138,10 @@
"lodash": "4.18.1",
"pdfjs-dist": "5.4.296",
"postcss": "^8.5.19",
"react": "$react",
"react-dom": "$react-dom",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"typescript": "5.6.2",
"zod": "$zod",
"fumadocs-mdx": {
+1
View File
@@ -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,
},
+3
View File
@@ -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()
@@ -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);
@@ -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();
});
@@ -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);
@@ -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');
@@ -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();
});
@@ -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'));
}
});
@@ -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();
});
+1 -1
View File
@@ -19,7 +19,7 @@
"arctic": "^3.7.0",
"hono": "^4.12.14",
"luxon": "^3.7.2",
"react": "^18",
"react": "^19.2.7",
"ts-pattern": "^5.9.0",
"zod": "^3.25.76"
}
+19 -17
View File
@@ -1,17 +1,19 @@
export { Body } from '@react-email/body';
export { Button } from '@react-email/button';
export { Column } from '@react-email/column';
export { Container } from '@react-email/container';
export { Font } from '@react-email/font';
export { Head } from '@react-email/head';
export { Heading } from '@react-email/heading';
export { Hr } from '@react-email/hr';
export { Html } from '@react-email/html';
export { Img } from '@react-email/img';
export { Link } from '@react-email/link';
export { Preview } from '@react-email/preview';
export { render } from '@react-email/render';
export { Row } from '@react-email/row';
export { Section } from '@react-email/section';
export { Tailwind } from '@react-email/tailwind';
export { Text } from '@react-email/text';
export {
Body,
Button,
Column,
Container,
Font,
Head,
Heading,
Hr,
Html,
Img,
Link,
Preview,
Row,
render,
Section,
Tailwind,
Text,
} from 'react-email';
+2 -20
View File
@@ -19,27 +19,9 @@
"dependencies": {
"@documenso/nodemailer-resend": "5.0.0",
"@documenso/tailwind-config": "*",
"@react-email/body": "0.2.0",
"@react-email/button": "0.2.0",
"@react-email/code-block": "0.2.0",
"@react-email/code-inline": "0.0.5",
"@react-email/column": "0.0.13",
"@react-email/container": "0.0.15",
"@react-email/font": "0.0.9",
"@react-email/head": "0.0.12",
"@react-email/heading": "0.0.15",
"@react-email/hr": "0.0.11",
"@react-email/html": "0.0.11",
"@react-email/img": "0.0.11",
"@react-email/link": "0.0.12",
"@react-email/preview": "0.0.13",
"@react-email/render": "2.0.0",
"@react-email/row": "0.0.12",
"@react-email/section": "0.0.16",
"@react-email/tailwind": "^2.0.1",
"@react-email/text": "0.1.5",
"@react-email/render": "2.1.0",
"nodemailer": "^9.0.0",
"react-email": "^5.0.6",
"react-email": "^6.9.0",
"resend": "^6.5.2"
},
"devDependencies": {
@@ -8,7 +8,7 @@ type SaveRequest<T, R> = {
export const useAutoSave = <T, R = void>(onSave: (data: T) => Promise<R>, options: { delay?: number } = {}) => {
const { delay = 2000 } = options;
const saveTimeoutRef = useRef<NodeJS.Timeout>();
const saveTimeoutRef = useRef<NodeJS.Timeout | undefined>(undefined);
const saveQueueRef = useRef<SaveRequest<T, R>[]>([]);
const isProcessingRef = useRef(false);
@@ -15,7 +15,7 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
import { useLingui } from '@lingui/react/macro';
import { EnvelopeType, Prisma, ReadStatus, SendStatus, SigningStatus } from '@prisma/client';
import type React from 'react';
import { createContext, useCallback, useContext, useMemo, useRef, useState } from 'react';
import { createContext, useCallback, useContext, useMemo, useRef, useState, useSyncExternalStore } from 'react';
import { useSearchParams } from 'react-router';
import type { TDocumentEmailSettings } from '../../types/document-email';
@@ -107,7 +107,39 @@ export const EnvelopeEditorProvider = ({
const [_searchParams, setSearchParams] = useSearchParams();
const [envelope, _setEnvelope] = useState(initialEnvelope);
/**
* The envelope is kept in a ref-backed external store instead of useState so
* that async consumers (debounced autosave callbacks, flushAutosave, resetForms)
* can synchronously read the latest value via `getEnvelope`.
*
* React subscribes to the store through useSyncExternalStore, keeping renders in
* sync without maintaining a separate copy of the state.
*/
const envelopeStoreRef = useRef(initialEnvelope);
const envelopeStoreSubscribersRef = useRef(new Set<() => void>());
const subscribeToEnvelopeStore = useCallback((onStoreChange: () => void) => {
envelopeStoreSubscribersRef.current.add(onStoreChange);
return () => {
envelopeStoreSubscribersRef.current.delete(onStoreChange);
};
}, []);
const getEnvelope = useCallback(() => envelopeStoreRef.current, []);
const setEnvelope = useCallback((action: React.SetStateAction<TEditorEnvelope>) => {
const next = typeof action === 'function' ? action(envelopeStoreRef.current) : action;
envelopeStoreRef.current = next;
for (const onStoreChange of envelopeStoreSubscribersRef.current) {
onStoreChange();
}
}, []);
const envelope = useSyncExternalStore(subscribeToEnvelopeStore, getEnvelope, getEnvelope);
const [autosaveError, setAutosaveError] = useState<boolean>(false);
const isCscMode = IS_INSTANCE_CSC_MODE();
@@ -135,8 +167,6 @@ export const EnvelopeEditorProvider = ({
};
}, [isCscMode, providedEditorConfig]);
const envelopeRef = useRef(initialEnvelope);
const externalFlushCallbacksRef = useRef<Map<string, () => Promise<void>>>(new Map());
const pendingMutationsRef = useRef<Set<Promise<unknown>>>(new Set());
@@ -156,14 +186,6 @@ export const EnvelopeEditorProvider = ({
});
}, []);
const setEnvelope: typeof _setEnvelope = (action) => {
_setEnvelope((prev) => {
const next = typeof action === 'function' ? action(prev) : action;
envelopeRef.current = next;
return next;
});
};
const isEmbedded = editorConfig.embedded !== undefined;
const editorFields = useEditorFields({
@@ -192,16 +214,18 @@ export const EnvelopeEditorProvider = ({
try {
let recipients: TEditorEnvelope['recipients'] = [];
const currentEnvelope = getEnvelope();
if (!isEmbedded) {
const response = await setRecipientsMutation.mutateAsync({
envelopeId: envelope.id,
envelopeType: envelope.type,
envelopeId: currentEnvelope.id,
envelopeType: currentEnvelope.type,
recipients: localRecipients,
});
recipients = response.data;
} else {
recipients = mapLocalRecipientsToRecipients({ envelope, localRecipients });
recipients = mapLocalRecipientsToRecipients({ envelope: currentEnvelope, localRecipients });
}
setEnvelope((prev) => ({
@@ -211,9 +235,7 @@ export const EnvelopeEditorProvider = ({
}));
// Reset the local fields to ensure deleted recipient fields are removed.
editorFields.resetForm(
envelope.fields.filter((field) => recipients.some((recipient) => recipient.id === field.recipientId)),
);
editorFields.resetForm(getEnvelope().fields);
setAutosaveError(false);
} catch (err) {
@@ -248,16 +270,18 @@ export const EnvelopeEditorProvider = ({
try {
let fields: TSetEnvelopeFieldsResponse['data'] = [];
const currentEnvelope = getEnvelope();
if (!isEmbedded) {
const response = await setFieldsMutation.mutateAsync({
envelopeId: envelope.id,
envelopeType: envelope.type,
envelopeId: currentEnvelope.id,
envelopeType: currentEnvelope.type,
fields: localFields,
});
fields = response.data;
} else {
fields = mapLocalFieldsToFields({ envelope, localFields });
fields = mapLocalFieldsToFields({ envelope: currentEnvelope, localFields });
}
setEnvelope((prev) => ({
@@ -309,7 +333,7 @@ export const EnvelopeEditorProvider = ({
try {
const response = !isEmbedded
? await updateEnvelopeMutation.mutateAsync({
envelopeId: envelope.id,
envelopeId: getEnvelope().id,
data,
meta,
})
@@ -467,12 +491,14 @@ export const EnvelopeEditorProvider = ({
};
const resetForms = () => {
const currentEnvelope = getEnvelope();
editorRecipients.resetForm({
recipients: envelopeRef.current.recipients,
documentMeta: envelopeRef.current.documentMeta,
recipients: currentEnvelope.recipients,
documentMeta: currentEnvelope.documentMeta,
});
editorFields.resetForm(envelopeRef.current.fields);
editorFields.resetForm(currentEnvelope.fields);
};
const flushAutosave = async (): Promise<TEditorEnvelope> => {
@@ -488,7 +514,7 @@ export const EnvelopeEditorProvider = ({
await Promise.allSettled(Array.from(pendingMutationsRef.current));
}
return envelopeRef.current;
return getEnvelope();
};
return (
+7
View File
@@ -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<DocumentSignatureType, DocumentSignatureTypeData>;
+2
View File
@@ -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;
+2
View File
@@ -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,
@@ -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');
}
};
@@ -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<typeof CLEANUP_ANONYMOUS_TOKENS_JOB_DEFINITION_SCHEMA>;
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
>;
+2 -2
View File
@@ -29,6 +29,7 @@
"@documenso/email": "*",
"@documenso/prisma": "*",
"@documenso/signing": "*",
"@documenso/skia-canvas": "^3.0.8-documenso.3",
"@lingui/core": "^5.6.0",
"@lingui/macro": "^5.6.0",
"@lingui/react": "^5.6.0",
@@ -64,10 +65,9 @@
"postcss-selector-parser": "^7.1.4",
"posthog-js": "^1.297.2",
"posthog-node": "4.18.0",
"react": "^18",
"react": "^19.2.7",
"remeda": "^2.32.0",
"sharp": "0.34.5",
"skia-canvas": "^3.0.8",
"stripe": "^12.18.0",
"ts-pattern": "^5.9.0",
"zod": "^3.25.76"
+1 -1
View File
@@ -1,6 +1,6 @@
import { Canvas, Image, Path2D } from '@documenso/skia-canvas';
import pMap from 'p-map';
import * as pdfjsLib from 'pdfjs-dist/legacy/build/pdf.mjs';
import { Canvas, Image, Path2D } from 'skia-canvas';
// @ts-expect-error napi-rs/canvas satisfies the requirements
globalThis.Path2D = Path2D;
@@ -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(),
@@ -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,
},
});
@@ -47,6 +47,7 @@ export const ZEnvelopeForSigningResponse = z.object({
typedSignatureEnabled: true,
uploadSignatureEnabled: true,
drawSignatureEnabled: true,
qrSignatureEnabled: true,
allowDictateNextSigner: true,
language: true,
}),
@@ -2,8 +2,9 @@
* !: This is a workaround to fix the memory leak in the skia-canvas library.
* !: Internals are ported from the original `konva/skia-backend.js` file.
*/
import { Canvas, DOMMatrix, Image, Path2D } from '@documenso/skia-canvas';
import { Konva } from 'konva/lib/_CoreInternals';
import { Canvas, DOMMatrix, Image, Path2D } from 'skia-canvas';
// @ts-expect-error skia-canvas satisfies the requirements
global.DOMMatrix = DOMMatrix;
@@ -37,6 +38,6 @@ Konva.Util.createImageElement = () => {
return node as unknown as HTMLImageElement;
};
Konva._renderBackend = 'skia-canvas';
Konva._renderBackend = '@documenso/skia-canvas';
export default Konva;
+1 -1
View File
@@ -1,8 +1,8 @@
import path from 'node:path';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { FontLibrary } from '@documenso/skia-canvas';
import type { Recipient } from '@prisma/client';
import { FieldType } from '@prisma/client';
import { FontLibrary } from 'skia-canvas';
import { match } from 'ts-pattern';
/**
@@ -2,8 +2,8 @@
import '../konva/skia-backend';
import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
import type { Canvas } from '@documenso/skia-canvas';
import Konva from 'konva';
import type { Canvas } from 'skia-canvas';
import { renderField } from '../../universal/field-renderer/render-field';
import { ensureFontLibrary } from './helpers';
@@ -1,14 +1,16 @@
// sort-imports-ignore
import '../konva/skia-backend';
import fs from 'node:fs';
import path from 'node:path';
import type { Canvas } from '@documenso/skia-canvas';
import { Image as SkiaImage } from '@documenso/skia-canvas';
import type { I18n } from '@lingui/core';
import { msg } from '@lingui/core/macro';
import type { DocumentMeta, Envelope, RecipientRole } from '@prisma/client';
import Konva from 'konva';
import 'konva/skia-backend';
import fs from 'node:fs';
import path from 'node:path';
import type { DateTimeFormatOptions } from 'luxon';
import { DateTime } from 'luxon';
import type { Canvas } from 'skia-canvas';
import { Image as SkiaImage } from 'skia-canvas';
import { match, P } from 'ts-pattern';
import { UAParser } from 'ua-parser-js';
@@ -1,14 +1,16 @@
// sort-imports-ignore
import '../konva/skia-backend';
import fs from 'node:fs';
import path from 'node:path';
import type { Canvas } from '@documenso/skia-canvas';
import { Image as SkiaImage } from '@documenso/skia-canvas';
import type { I18n } from '@lingui/core';
import { msg } from '@lingui/core/macro';
import type { Field, RecipientRole, Signature } from '@prisma/client';
import { SigningStatus } from '@prisma/client';
import Konva from 'konva';
import 'konva/skia-backend';
import fs from 'node:fs';
import path from 'node:path';
import { DateTime } from 'luxon';
import type { Canvas } from 'skia-canvas';
import { Image as SkiaImage } from 'skia-canvas';
import { UAParser } from 'ua-parser-js';
import { renderSVG } from 'uqr';
@@ -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({
@@ -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,
},
@@ -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,
File diff suppressed because it is too large Load Diff
+6
View File
@@ -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(),
+1
View File
@@ -62,6 +62,7 @@ export const ZDocumentSchema = LegacyDocumentSchema.pick({
typedSignatureEnabled: true,
uploadSignatureEnabled: true,
drawSignatureEnabled: true,
qrSignatureEnabled: true,
allowDictateNextSigner: true,
language: true,
emailSettings: true,
+1
View File
@@ -279,6 +279,7 @@ export const ZEditorEnvelopeSchema = EnvelopeSchema.pick({
typedSignatureEnabled: true,
uploadSignatureEnabled: true,
drawSignatureEnabled: true,
qrSignatureEnabled: true,
allowDictateNextSigner: true,
language: true,
emailSettings: true,
+1
View File
@@ -49,6 +49,7 @@ export const ZEnvelopeSchema = EnvelopeSchema.pick({
typedSignatureEnabled: true,
uploadSignatureEnabled: true,
drawSignatureEnabled: true,
qrSignatureEnabled: true,
allowDictateNextSigner: true,
language: true,
emailSettings: true,
+23
View File
@@ -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<typeof ZQrSignatureContextSchema>;
export type TQrSignatureContextType = TQrSignatureContext['type'];
+1
View File
@@ -54,6 +54,7 @@ export const ZTemplateSchema = TemplateSchema.pick({
typedSignatureEnabled: true,
uploadSignatureEnabled: true,
drawSignatureEnabled: true,
qrSignatureEnabled: true,
allowDictateNextSigner: true,
distributionMethod: true,
redirectUrl: true,
+1
View File
@@ -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(),
@@ -14,7 +14,7 @@ let SkiaImage: any;
void (async () => {
if (typeof window === 'undefined') {
const mod = await import('skia-canvas');
const mod = await import('@documenso/skia-canvas');
SkiaImage = mod.Image;
}
})();
+1
View File
@@ -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,
+1
View File
@@ -119,6 +119,7 @@ export const generateDefaultOrganisationSettings = (): Omit<OrganisationGlobalSe
typedSignatureEnabled: true,
uploadSignatureEnabled: true,
drawSignatureEnabled: true,
qrSignatureEnabled: true,
brandingEnabled: false,
brandingLogo: '',
+13 -1
View File
@@ -17,6 +17,7 @@ export enum DocumentSignatureType {
DRAW = 'draw',
TYPE = 'type',
UPLOAD = 'upload',
QR = 'qr',
}
export const formatTeamUrl = (teamUrl: string, baseUrl?: string) => {
@@ -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<TeamGlobalSettings, 'id' | '
typedSignatureEnabled: null,
uploadSignatureEnabled: null,
drawSignatureEnabled: null,
qrSignatureEnabled: null,
brandingEnabled: null,
brandingLogo: null,
@@ -0,0 +1,15 @@
-- CreateEnum
CREATE TYPE "AnonymousVerificationTokenType" AS ENUM ('PASSKEY', 'QR_SIGNATURE');
-- AlterTable: add "type" as nullable, backfill existing rows (all are passkey
-- challenges today), then enforce NOT NULL.
ALTER TABLE "AnonymousVerificationToken" ADD COLUMN "type" "AnonymousVerificationTokenType";
UPDATE "AnonymousVerificationToken" SET "type" = 'PASSKEY';
ALTER TABLE "AnonymousVerificationToken" ALTER COLUMN "type" SET NOT NULL;
-- AlterTable
ALTER TABLE "AnonymousVerificationToken" ADD COLUMN "value" TEXT;
ALTER TABLE "AnonymousVerificationToken" ADD COLUMN "metadata" JSONB;
@@ -0,0 +1,10 @@
-- AlterTable: add with DEFAULT false so every existing row is backfilled to
-- disabled, then flip the column default to true so new rows are enabled.
ALTER TABLE "DocumentMeta" ADD COLUMN "qrSignatureEnabled" BOOLEAN NOT NULL DEFAULT false;
ALTER TABLE "DocumentMeta" ALTER COLUMN "qrSignatureEnabled" SET DEFAULT true;
ALTER TABLE "OrganisationGlobalSettings" ADD COLUMN "qrSignatureEnabled" BOOLEAN NOT NULL DEFAULT false;
ALTER TABLE "OrganisationGlobalSettings" ALTER COLUMN "qrSignatureEnabled" SET DEFAULT true;
-- Existing teams stay NULL (inherit from organisation).
ALTER TABLE "TeamGlobalSettings" ADD COLUMN "qrSignatureEnabled" BOOLEAN;
+14 -2
View File
@@ -144,9 +144,18 @@ model Passkey {
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}
enum AnonymousVerificationTokenType {
PASSKEY
QR_SIGNATURE
}
model AnonymousVerificationToken {
id String @id @unique @default(cuid())
token String @unique
id String @id @unique @default(cuid())
type AnonymousVerificationTokenType
token String @unique
value String?
metadata Json?
expiresAt DateTime
createdAt DateTime @default(now())
}
@@ -570,6 +579,7 @@ model DocumentMeta {
typedSignatureEnabled Boolean @default(true)
uploadSignatureEnabled Boolean @default(true)
drawSignatureEnabled Boolean @default(true)
qrSignatureEnabled Boolean @default(true)
language String @default("en")
distributionMethod DocumentDistributionMethod @default(EMAIL)
@@ -969,6 +979,7 @@ model OrganisationGlobalSettings {
typedSignatureEnabled Boolean @default(true)
uploadSignatureEnabled Boolean @default(true)
drawSignatureEnabled Boolean @default(true)
qrSignatureEnabled Boolean @default(true)
defaultRecipients Json? /// [DefaultRecipient[]] @zod.custom.use(ZDefaultRecipientsSchema)
@@ -1012,6 +1023,7 @@ model TeamGlobalSettings {
typedSignatureEnabled Boolean?
uploadSignatureEnabled Boolean?
drawSignatureEnabled Boolean?
qrSignatureEnabled Boolean?
defaultRecipients Json? /// [DefaultRecipient[]] @zod.custom.use(ZDefaultRecipientsSchema)

Some files were not shown because too many files have changed in this diff Show More