mirror of
https://github.com/documenso/documenso.git
synced 2026-08-15 02:53:32 +10:00
fix: reviewed
This commit is contained in:
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user