diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index 06be2eed9..45838902f 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -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) diff --git a/packages/trpc/server/signature-router/qr/complete-qr-signature.ts b/packages/trpc/server/signature-router/qr/complete-qr-signature.ts new file mode 100644 index 000000000..048a9e2cc --- /dev/null +++ b/packages/trpc/server/signature-router/qr/complete-qr-signature.ts @@ -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', + }); + } + }); diff --git a/packages/trpc/server/signature-router/qr/complete-qr-signature.types.ts b/packages/trpc/server/signature-router/qr/complete-qr-signature.types.ts new file mode 100644 index 000000000..287213b3d --- /dev/null +++ b/packages/trpc/server/signature-router/qr/complete-qr-signature.types.ts @@ -0,0 +1,17 @@ +import { isBase64Image } from '@documenso/lib/constants/signatures'; +import { z } from 'zod'; + +export const ZCompleteQrSignatureRequestSchema = z.object({ + token: z.string().min(1).max(64).describe('The QR signature session token'), + signature: z + .string() + .min(1) + .max(1_000_000) + .refine((value) => isBase64Image(value), { + message: 'Signature must be a base64 encoded PNG image', + }), +}); + +export const ZCompleteQrSignatureResponseSchema = z.void(); + +export type TCompleteQrSignatureRequest = z.infer; diff --git a/packages/ui/primitives/signature-pad/signature-pad.tsx b/packages/ui/primitives/signature-pad/signature-pad.tsx index 734daaa97..be788733c 100644 --- a/packages/ui/primitives/signature-pad/signature-pad.tsx +++ b/packages/ui/primitives/signature-pad/signature-pad.tsx @@ -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, '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(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')} > {drawSignatureEnabled && ( @@ -152,6 +162,13 @@ export const SignaturePad = ({ )} + {qrSignatureEnabled && ( + + + Mobile + + )} + {typedSignatureEnabled && ( @@ -174,6 +191,18 @@ export const SignaturePad = ({ + + + +