fix: reviewed

This commit is contained in:
David Nguyen
2026-07-28 22:10:37 +09:00
parent c767ec9fd9
commit c872c6f9be
4 changed files with 139 additions and 9 deletions
+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)
@@ -0,0 +1,72 @@
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { assertRateLimit } from '@documenso/lib/server-only/rate-limit/rate-limit-middleware';
import { qrSignatureCompleteRateLimit } from '@documenso/lib/server-only/rate-limit/rate-limits';
import { prisma } from '@documenso/prisma';
import { AnonymousVerificationTokenType } from '@prisma/client';
import { procedure } from '../../trpc';
import { ZCompleteQrSignatureRequestSchema, ZCompleteQrSignatureResponseSchema } from './complete-qr-signature.types';
/**
* NOTE: THIS IS A PUBLIC (UNAUTHENTICATED) PROCEDURE.
*
* Called from the mobile signing page to attach a drawn signature to a QR
* signature session. The desktop pad picks it up by polling `qr.get`.
*/
export const completeQrSignatureRoute = procedure
.input(ZCompleteQrSignatureRequestSchema)
.output(ZCompleteQrSignatureResponseSchema)
.mutation(async ({ input, ctx }) => {
const { token, signature } = input;
const { ipAddress } = ctx.metadata.requestMetadata;
const rateLimitResult = await qrSignatureCompleteRateLimit.check({
ip: ipAddress ?? 'unknown',
identifier: token,
});
assertRateLimit(rateLimitResult);
const qrSignatureSession = await prisma.anonymousVerificationToken.findFirst({
where: {
token,
type: AnonymousVerificationTokenType.QR_SIGNATURE,
},
});
if (!qrSignatureSession) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'QR signature session not found or expired',
});
}
if (qrSignatureSession.expiresAt < new Date()) {
throw new AppError(AppErrorCode.EXPIRED_CODE, {
message: 'QR signature session has expired',
});
}
if (qrSignatureSession.value) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'A signature has already been submitted for this session',
});
}
const { count: updatedCount } = await prisma.anonymousVerificationToken.updateMany({
where: {
id: qrSignatureSession.id,
type: AnonymousVerificationTokenType.QR_SIGNATURE,
value: null,
},
data: {
value: signature,
},
});
if (updatedCount === 0) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'A signature has already been submitted for this session',
});
}
});
@@ -0,0 +1,17 @@
import { isBase64Image } from '@documenso/lib/constants/signatures';
import { z } from 'zod';
export const ZCompleteQrSignatureRequestSchema = z.object({
token: z.string().min(1).max(64).describe('The QR signature session token'),
signature: z
.string()
.min(1)
.max(1_000_000)
.refine((value) => isBase64Image(value), {
message: 'Signature must be a base64 encoded PNG image',
}),
});
export const ZCompleteQrSignatureResponseSchema = z.void();
export type TCompleteQrSignatureRequest = z.infer<typeof ZCompleteQrSignatureRequestSchema>;
@@ -2,14 +2,16 @@ import { DocumentSignatureType } from '@documenso/lib/constants/document';
import { isBase64Image } from '@documenso/lib/constants/signatures';
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 +31,7 @@ export type SignaturePadProps = Omit<HTMLAttributes<HTMLCanvasElement>, 'onChang
typedSignatureEnabled?: boolean;
uploadSignatureEnabled?: boolean;
drawSignatureEnabled?: boolean;
qrSignatureEnabled?: boolean;
onValidityChange?: (isValid: boolean) => void;
};
@@ -41,11 +44,14 @@ export const SignaturePad = ({
typedSignatureEnabled = true,
uploadSignatureEnabled = true,
drawSignatureEnabled = true,
qrSignatureEnabled = true,
}: 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 +59,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 +86,10 @@ export const SignaturePad = ({
return 'image';
}
if (qrSignatureEnabled) {
return 'qr';
}
throw new Error('No signature enabled');
})(),
);
@@ -111,7 +121,7 @@ export const SignaturePad = ({
});
};
const onTabChange = (value: 'draw' | 'text' | 'image') => {
const onTabChange = (value: 'draw' | 'text' | 'image' | 'qr') => {
if (disabled) {
return;
}
@@ -119,7 +129,7 @@ export const SignaturePad = ({
setTab(value);
match(value)
.with('draw', () => {
.with(P.union('draw', 'qr'), () => {
onDrawSignatureChange(drawSignature);
})
.with('text', () => {
@@ -131,7 +141,7 @@ export const SignaturePad = ({
.exhaustive();
};
if (!drawSignatureEnabled && !typedSignatureEnabled && !uploadSignatureEnabled) {
if (!drawSignatureEnabled && !typedSignatureEnabled && !uploadSignatureEnabled && !qrSignatureEnabled) {
return null;
}
@@ -142,7 +152,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 +162,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 +191,18 @@ 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}
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"