feat: add qr signatures

This commit is contained in:
David Nguyen
2026-08-18 11:49:15 +10:00
parent 779de01fe8
commit 9825ea88b1
99 changed files with 1686 additions and 48 deletions
+14 -3
View File
@@ -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);
}
@@ -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<HTMLAttributes<HTMLCanvasElement>, '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}
/>
<DialogFooter>
@@ -260,14 +260,14 @@ export const SignaturePadDraw = ({ className, value, onChange, ...props }: Signa
});
return (
<div className={cn('h-full w-full', className)}>
<div className={cn('h-full w-full select-none', className)}>
<canvas
data-testid="signature-pad-draw"
ref={$el}
className={cn('h-full w-full', {
'dark:hue-rotate-180 dark:invert': selectedColor === 'black',
})}
style={{ touchAction: 'none' }}
style={{ touchAction: 'none', WebkitTouchCallout: 'none' }}
onPointerMove={(event) => onMouseMove(event)}
onPointerDown={(event) => onMouseDown(event)}
onPointerUp={(event) => onMouseUp(event)}
@@ -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<string> =>
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<HTMLDivElement>(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 (
<div
data-testid="signature-pad-qr-preview"
className={cn('relative flex h-full w-full flex-col items-center justify-center', className)}
>
<SignatureRender value={value} className="h-full w-full" />
<div className="absolute right-3 bottom-3">
<button
type="button"
className="flex items-center gap-1 rounded-full p-0 text-[0.688rem] text-muted-foreground/60 ring-offset-background hover:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
onClick={() => onScanAgainClick()}
>
<RefreshCwIcon className="size-3" />
<Trans>Scan again</Trans>
</button>
</div>
</div>
);
}
if (isCreateSessionError || qrSignatureData?.status === 'EXPIRED') {
return (
<div className={cn('flex h-full w-full flex-col items-center justify-center gap-2', className)}>
<p className="text-muted-foreground text-sm">
{isCreateSessionError ? (
<Trans>Something went wrong. Please try again.</Trans>
) : (
<Trans>This QR code has expired.</Trans>
)}
</p>
<Button type="button" variant="outline" size="sm" onClick={() => onGenerateNewCodeClick()}>
<RefreshCwIcon className="mr-2 size-4" />
<Trans>Generate new code</Trans>
</Button>
</div>
);
}
// 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 (
<div role="status" className={cn('flex h-full w-full items-center justify-center', className)}>
<Loader2Icon className="size-6 animate-spin text-muted-foreground" />
<span className="sr-only">
<Trans>Loading</Trans>
</span>
</div>
);
}
const mobileSigningUrl = `${NEXT_PUBLIC_WEBAPP_URL()}/mobile-signature/${session.token}`;
return (
<div
ref={$container}
data-testid="signature-pad-qr"
className={cn(
'flex h-full min-h-0 w-full flex-col items-center justify-center gap-2 overflow-hidden p-3',
className,
)}
>
{/* 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. */}
<div className="flex min-h-0 flex-1 items-center justify-center">
<div
role="img"
aria-label={t`QR code for mobile signing`}
className="aspect-square h-full rounded-md bg-white p-1.5 [&>svg]:block [&>svg]:h-full [&>svg]:w-full"
// biome-ignore lint/security/noDangerouslySetInnerHtml: Expected usage to render QR.
dangerouslySetInnerHTML={{
__html: renderSVG(mobileSigningUrl),
}}
/>
</div>
<p className="shrink-0 text-muted-foreground text-xs">
<Trans>Scan with your phone to draw your signature</Trans>
</p>
<p
data-testid="signature-pad-qr-url"
className="w-full shrink-0 truncate px-2 text-center text-[0.688rem] text-muted-foreground/60"
>
{mobileSigningUrl}
</p>
</div>
);
};
@@ -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<HTMLAttributes<HTMLCanvasElement>, '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<QrSignatureSession | null>(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')}
>
<TabsList>
{drawSignatureEnabled && (
@@ -152,6 +163,13 @@ export const SignaturePad = ({
</TabsTrigger>
)}
{qrSignatureEnabled && (
<TabsTrigger value="qr" className="max-sm:hidden">
<SmartphoneIcon className="mr-2 size-4" />
<Trans context="Sign using a mobile phone">Mobile</Trans>
</TabsTrigger>
)}
{typedSignatureEnabled && (
<TabsTrigger value="text">
<KeyboardIcon className="mr-2 size-4" />
@@ -174,6 +192,19 @@ export const SignaturePad = ({
<SignaturePadDraw className="h-full w-full" onChange={onDrawSignatureChange} value={drawSignature} />
</TabsContent>
<TabsContent
value="qr"
className="relative flex aspect-signature-pad items-center justify-center rounded-md border border-border bg-muted/25 text-center"
>
<SignaturePadQr
value={drawSignature}
onChange={onDrawSignatureChange}
session={qrSession}
context={qrSignatureContext}
onSessionChange={setQrSession}
/>
</TabsContent>
<TabsContent
value="text"
className="relative flex aspect-signature-pad items-center justify-center rounded-md border border-border bg-muted/25 text-center"