mirror of
https://github.com/documenso/documenso.git
synced 2026-08-24 23:32:29 +10:00
fix: reviewed
This commit is contained in:
@@ -144,9 +144,18 @@ model Passkey {
|
|||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum AnonymousVerificationTokenType {
|
||||||
|
PASSKEY
|
||||||
|
QR_SIGNATURE
|
||||||
|
}
|
||||||
|
|
||||||
model AnonymousVerificationToken {
|
model AnonymousVerificationToken {
|
||||||
id String @id @unique @default(cuid())
|
id String @id @unique @default(cuid())
|
||||||
|
type AnonymousVerificationTokenType
|
||||||
token String @unique
|
token String @unique
|
||||||
|
value String?
|
||||||
|
metadata Json?
|
||||||
|
|
||||||
expiresAt DateTime
|
expiresAt DateTime
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
}
|
}
|
||||||
@@ -570,6 +579,7 @@ model DocumentMeta {
|
|||||||
typedSignatureEnabled Boolean @default(true)
|
typedSignatureEnabled Boolean @default(true)
|
||||||
uploadSignatureEnabled Boolean @default(true)
|
uploadSignatureEnabled Boolean @default(true)
|
||||||
drawSignatureEnabled Boolean @default(true)
|
drawSignatureEnabled Boolean @default(true)
|
||||||
|
qrSignatureEnabled Boolean @default(true)
|
||||||
|
|
||||||
language String @default("en")
|
language String @default("en")
|
||||||
distributionMethod DocumentDistributionMethod @default(EMAIL)
|
distributionMethod DocumentDistributionMethod @default(EMAIL)
|
||||||
@@ -969,6 +979,7 @@ model OrganisationGlobalSettings {
|
|||||||
typedSignatureEnabled Boolean @default(true)
|
typedSignatureEnabled Boolean @default(true)
|
||||||
uploadSignatureEnabled Boolean @default(true)
|
uploadSignatureEnabled Boolean @default(true)
|
||||||
drawSignatureEnabled Boolean @default(true)
|
drawSignatureEnabled Boolean @default(true)
|
||||||
|
qrSignatureEnabled Boolean @default(true)
|
||||||
|
|
||||||
defaultRecipients Json? /// [DefaultRecipient[]] @zod.custom.use(ZDefaultRecipientsSchema)
|
defaultRecipients Json? /// [DefaultRecipient[]] @zod.custom.use(ZDefaultRecipientsSchema)
|
||||||
|
|
||||||
@@ -1012,6 +1023,7 @@ model TeamGlobalSettings {
|
|||||||
typedSignatureEnabled Boolean?
|
typedSignatureEnabled Boolean?
|
||||||
uploadSignatureEnabled Boolean?
|
uploadSignatureEnabled Boolean?
|
||||||
drawSignatureEnabled Boolean?
|
drawSignatureEnabled Boolean?
|
||||||
|
qrSignatureEnabled Boolean?
|
||||||
|
|
||||||
defaultRecipients Json? /// [DefaultRecipient[]] @zod.custom.use(ZDefaultRecipientsSchema)
|
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 { isBase64Image } from '@documenso/lib/constants/signatures';
|
||||||
|
|
||||||
import { Trans } from '@lingui/react/macro';
|
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 type { HTMLAttributes } from 'react';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { match } from 'ts-pattern';
|
import { match, P } from 'ts-pattern';
|
||||||
|
|
||||||
import { SignatureIcon } from '../../icons/signature';
|
import { SignatureIcon } from '../../icons/signature';
|
||||||
import { cn } from '../../lib/utils';
|
import { cn } from '../../lib/utils';
|
||||||
import { SignaturePadDraw } from './signature-pad-draw';
|
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 { SignaturePadType } from './signature-pad-type';
|
||||||
import { SignaturePadUpload } from './signature-pad-upload';
|
import { SignaturePadUpload } from './signature-pad-upload';
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from './signature-tabs';
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from './signature-tabs';
|
||||||
@@ -29,6 +31,7 @@ export type SignaturePadProps = Omit<HTMLAttributes<HTMLCanvasElement>, 'onChang
|
|||||||
typedSignatureEnabled?: boolean;
|
typedSignatureEnabled?: boolean;
|
||||||
uploadSignatureEnabled?: boolean;
|
uploadSignatureEnabled?: boolean;
|
||||||
drawSignatureEnabled?: boolean;
|
drawSignatureEnabled?: boolean;
|
||||||
|
qrSignatureEnabled?: boolean;
|
||||||
|
|
||||||
onValidityChange?: (isValid: boolean) => void;
|
onValidityChange?: (isValid: boolean) => void;
|
||||||
};
|
};
|
||||||
@@ -41,11 +44,14 @@ export const SignaturePad = ({
|
|||||||
typedSignatureEnabled = true,
|
typedSignatureEnabled = true,
|
||||||
uploadSignatureEnabled = true,
|
uploadSignatureEnabled = true,
|
||||||
drawSignatureEnabled = true,
|
drawSignatureEnabled = true,
|
||||||
|
qrSignatureEnabled = true,
|
||||||
}: SignaturePadProps) => {
|
}: SignaturePadProps) => {
|
||||||
const [imageSignature, setImageSignature] = useState(isBase64Image(value) ? value : '');
|
const [imageSignature, setImageSignature] = useState(isBase64Image(value) ? value : '');
|
||||||
const [drawSignature, setDrawSignature] = useState(isBase64Image(value) ? value : '');
|
const [drawSignature, setDrawSignature] = useState(isBase64Image(value) ? value : '');
|
||||||
const [typedSignature, setTypedSignature] = useState(isBase64Image(value) ? '' : value);
|
const [typedSignature, setTypedSignature] = useState(isBase64Image(value) ? '' : value);
|
||||||
|
|
||||||
|
const [qrSession, setQrSession] = useState<QrSignatureSession | null>(null);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This is cooked.
|
* This is cooked.
|
||||||
*
|
*
|
||||||
@@ -53,7 +59,7 @@ export const SignaturePad = ({
|
|||||||
* the first enabled tab.
|
* the first enabled tab.
|
||||||
*/
|
*/
|
||||||
const [tab, setTab] = useState(
|
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.
|
// First passthrough to check to see if there's a signature for a given tab.
|
||||||
if (drawSignatureEnabled && drawSignature) {
|
if (drawSignatureEnabled && drawSignature) {
|
||||||
return 'draw';
|
return 'draw';
|
||||||
@@ -80,6 +86,10 @@ export const SignaturePad = ({
|
|||||||
return 'image';
|
return 'image';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (qrSignatureEnabled) {
|
||||||
|
return 'qr';
|
||||||
|
}
|
||||||
|
|
||||||
throw new Error('No signature enabled');
|
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) {
|
if (disabled) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -119,7 +129,7 @@ export const SignaturePad = ({
|
|||||||
setTab(value);
|
setTab(value);
|
||||||
|
|
||||||
match(value)
|
match(value)
|
||||||
.with('draw', () => {
|
.with(P.union('draw', 'qr'), () => {
|
||||||
onDrawSignatureChange(drawSignature);
|
onDrawSignatureChange(drawSignature);
|
||||||
})
|
})
|
||||||
.with('text', () => {
|
.with('text', () => {
|
||||||
@@ -131,7 +141,7 @@ export const SignaturePad = ({
|
|||||||
.exhaustive();
|
.exhaustive();
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!drawSignatureEnabled && !typedSignatureEnabled && !uploadSignatureEnabled) {
|
if (!drawSignatureEnabled && !typedSignatureEnabled && !uploadSignatureEnabled && !qrSignatureEnabled) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -142,7 +152,7 @@ export const SignaturePad = ({
|
|||||||
'pointer-events-none': disabled,
|
'pointer-events-none': disabled,
|
||||||
})}
|
})}
|
||||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
// 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>
|
<TabsList>
|
||||||
{drawSignatureEnabled && (
|
{drawSignatureEnabled && (
|
||||||
@@ -152,6 +162,13 @@ export const SignaturePad = ({
|
|||||||
</TabsTrigger>
|
</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 && (
|
{typedSignatureEnabled && (
|
||||||
<TabsTrigger value="text">
|
<TabsTrigger value="text">
|
||||||
<KeyboardIcon className="mr-2 size-4" />
|
<KeyboardIcon className="mr-2 size-4" />
|
||||||
@@ -174,6 +191,18 @@ export const SignaturePad = ({
|
|||||||
<SignaturePadDraw className="h-full w-full" onChange={onDrawSignatureChange} value={drawSignature} />
|
<SignaturePadDraw className="h-full w-full" onChange={onDrawSignatureChange} value={drawSignature} />
|
||||||
</TabsContent>
|
</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
|
<TabsContent
|
||||||
value="text"
|
value="text"
|
||||||
className="relative flex aspect-signature-pad items-center justify-center rounded-md border border-border bg-muted/25 text-center"
|
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