mirror of
https://github.com/documenso/documenso.git
synced 2026-08-23 14:52:23 +10:00
feat: add qr signatures
This commit is contained in:
@@ -5,6 +5,7 @@ import {
|
||||
ZDocumentMetaDrawSignatureEnabledSchema,
|
||||
ZDocumentMetaLanguageSchema,
|
||||
ZDocumentMetaMessageSchema,
|
||||
ZDocumentMetaQrSignatureEnabledSchema,
|
||||
ZDocumentMetaRedirectUrlSchema,
|
||||
ZDocumentMetaSubjectSchema,
|
||||
ZDocumentMetaTimezoneSchema,
|
||||
@@ -65,6 +66,7 @@ export const ZCreateEmbeddingDocumentRequestSchema = z.object({
|
||||
typedSignatureEnabled: ZDocumentMetaTypedSignatureEnabledSchema.optional(),
|
||||
drawSignatureEnabled: ZDocumentMetaDrawSignatureEnabledSchema.optional(),
|
||||
uploadSignatureEnabled: ZDocumentMetaUploadSignatureEnabledSchema.optional(),
|
||||
qrSignatureEnabled: ZDocumentMetaQrSignatureEnabledSchema.optional(),
|
||||
emailSettings: ZDocumentEmailSettingsSchema.optional(),
|
||||
})
|
||||
.optional(),
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
ZDocumentMetaDrawSignatureEnabledSchema,
|
||||
ZDocumentMetaLanguageSchema,
|
||||
ZDocumentMetaMessageSchema,
|
||||
ZDocumentMetaQrSignatureEnabledSchema,
|
||||
ZDocumentMetaRedirectUrlSchema,
|
||||
ZDocumentMetaSubjectSchema,
|
||||
ZDocumentMetaTimezoneSchema,
|
||||
@@ -62,6 +63,7 @@ export const ZCreateEmbeddingTemplateRequestSchema = z.object({
|
||||
typedSignatureEnabled: ZDocumentMetaTypedSignatureEnabledSchema.optional(),
|
||||
drawSignatureEnabled: ZDocumentMetaDrawSignatureEnabledSchema.optional(),
|
||||
uploadSignatureEnabled: ZDocumentMetaUploadSignatureEnabledSchema.optional(),
|
||||
qrSignatureEnabled: ZDocumentMetaQrSignatureEnabledSchema.optional(),
|
||||
emailSettings: ZDocumentEmailSettingsSchema.optional(),
|
||||
})
|
||||
.optional(),
|
||||
|
||||
@@ -30,6 +30,7 @@ export const ZGetMultiSignDocumentResponseSchema = ZDocumentLiteSchema.extend({
|
||||
typedSignatureEnabled: true,
|
||||
uploadSignatureEnabled: true,
|
||||
drawSignatureEnabled: true,
|
||||
qrSignatureEnabled: true,
|
||||
allowDictateNextSigner: true,
|
||||
language: true,
|
||||
emailSettings: true,
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
ZDocumentMetaDrawSignatureEnabledSchema,
|
||||
ZDocumentMetaLanguageSchema,
|
||||
ZDocumentMetaMessageSchema,
|
||||
ZDocumentMetaQrSignatureEnabledSchema,
|
||||
ZDocumentMetaRedirectUrlSchema,
|
||||
ZDocumentMetaSubjectSchema,
|
||||
ZDocumentMetaTimezoneSchema,
|
||||
@@ -66,6 +67,7 @@ export const ZUpdateEmbeddingDocumentRequestSchema = z.object({
|
||||
typedSignatureEnabled: ZDocumentMetaTypedSignatureEnabledSchema.optional(),
|
||||
drawSignatureEnabled: ZDocumentMetaDrawSignatureEnabledSchema.optional(),
|
||||
uploadSignatureEnabled: ZDocumentMetaUploadSignatureEnabledSchema.optional(),
|
||||
qrSignatureEnabled: ZDocumentMetaQrSignatureEnabledSchema.optional(),
|
||||
emailSettings: ZDocumentEmailSettingsSchema.optional(),
|
||||
})
|
||||
.optional(),
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
ZDocumentMetaDrawSignatureEnabledSchema,
|
||||
ZDocumentMetaLanguageSchema,
|
||||
ZDocumentMetaMessageSchema,
|
||||
ZDocumentMetaQrSignatureEnabledSchema,
|
||||
ZDocumentMetaRedirectUrlSchema,
|
||||
ZDocumentMetaSubjectSchema,
|
||||
ZDocumentMetaTimezoneSchema,
|
||||
@@ -66,6 +67,7 @@ export const ZUpdateEmbeddingTemplateRequestSchema = z.object({
|
||||
typedSignatureEnabled: ZDocumentMetaTypedSignatureEnabledSchema.optional(),
|
||||
drawSignatureEnabled: ZDocumentMetaDrawSignatureEnabledSchema.optional(),
|
||||
uploadSignatureEnabled: ZDocumentMetaUploadSignatureEnabledSchema.optional(),
|
||||
qrSignatureEnabled: ZDocumentMetaQrSignatureEnabledSchema.optional(),
|
||||
emailSettings: ZDocumentEmailSettingsSchema.optional(),
|
||||
})
|
||||
.optional(),
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
ZDocumentMetaDrawSignatureEnabledSchema,
|
||||
ZDocumentMetaLanguageSchema,
|
||||
ZDocumentMetaMessageSchema,
|
||||
ZDocumentMetaQrSignatureEnabledSchema,
|
||||
ZDocumentMetaRedirectUrlSchema,
|
||||
ZDocumentMetaSubjectSchema,
|
||||
ZDocumentMetaTimezoneSchema,
|
||||
@@ -94,6 +95,7 @@ export const ZUseEnvelopePayloadSchema = z.object({
|
||||
typedSignatureEnabled: ZDocumentMetaTypedSignatureEnabledSchema.optional(),
|
||||
uploadSignatureEnabled: ZDocumentMetaUploadSignatureEnabledSchema.optional(),
|
||||
drawSignatureEnabled: ZDocumentMetaDrawSignatureEnabledSchema.optional(),
|
||||
qrSignatureEnabled: ZDocumentMetaQrSignatureEnabledSchema.optional(),
|
||||
allowDictateNextSigner: z.boolean().optional(),
|
||||
envelopeExpirationPeriod: ZEnvelopeExpirationPeriod.nullish(),
|
||||
})
|
||||
|
||||
@@ -37,6 +37,7 @@ export const updateOrganisationSettingsRoute = authenticatedProcedure
|
||||
typedSignatureEnabled,
|
||||
uploadSignatureEnabled,
|
||||
drawSignatureEnabled,
|
||||
qrSignatureEnabled,
|
||||
defaultRecipients,
|
||||
delegateDocumentOwnership,
|
||||
envelopeExpirationPeriod,
|
||||
@@ -104,6 +105,7 @@ export const updateOrganisationSettingsRoute = authenticatedProcedure
|
||||
uploadSignatureEnabled ?? organisation.organisationGlobalSettings.uploadSignatureEnabled;
|
||||
const derivedDrawSignatureEnabled =
|
||||
drawSignatureEnabled ?? organisation.organisationGlobalSettings.drawSignatureEnabled;
|
||||
const derivedQrSignatureEnabled = qrSignatureEnabled ?? organisation.organisationGlobalSettings.qrSignatureEnabled;
|
||||
|
||||
const derivedDelegateDocumentOwnership =
|
||||
delegateDocumentOwnership ?? organisation.organisationGlobalSettings.delegateDocumentOwnership;
|
||||
@@ -111,7 +113,8 @@ export const updateOrganisationSettingsRoute = authenticatedProcedure
|
||||
if (
|
||||
derivedTypedSignatureEnabled === false &&
|
||||
derivedUploadSignatureEnabled === false &&
|
||||
derivedDrawSignatureEnabled === false
|
||||
derivedDrawSignatureEnabled === false &&
|
||||
derivedQrSignatureEnabled === false
|
||||
) {
|
||||
throw new AppError(AppErrorCode.INVALID_BODY, {
|
||||
message: 'At least one signature type must be enabled',
|
||||
@@ -165,6 +168,7 @@ export const updateOrganisationSettingsRoute = authenticatedProcedure
|
||||
typedSignatureEnabled,
|
||||
uploadSignatureEnabled,
|
||||
drawSignatureEnabled,
|
||||
qrSignatureEnabled,
|
||||
defaultRecipients: defaultRecipients === null ? Prisma.DbNull : defaultRecipients,
|
||||
delegateDocumentOwnership: derivedDelegateDocumentOwnership,
|
||||
envelopeExpirationPeriod: envelopeExpirationPeriod === null ? Prisma.DbNull : envelopeExpirationPeriod,
|
||||
|
||||
@@ -25,6 +25,7 @@ export const ZUpdateOrganisationSettingsRequestSchema = z.object({
|
||||
typedSignatureEnabled: z.boolean().optional(),
|
||||
uploadSignatureEnabled: z.boolean().optional(),
|
||||
drawSignatureEnabled: z.boolean().optional(),
|
||||
qrSignatureEnabled: z.boolean().optional(),
|
||||
defaultRecipients: ZDefaultRecipientsSchema.nullish(),
|
||||
delegateDocumentOwnership: z.boolean().nullish(),
|
||||
envelopeExpirationPeriod: ZEnvelopeExpirationPeriod.optional(),
|
||||
|
||||
@@ -10,6 +10,7 @@ import { folderRouter } from './folder-router/router';
|
||||
import { organisationRouter } from './organisation-router/router';
|
||||
import { profileRouter } from './profile-router/router';
|
||||
import { recipientRouter } from './recipient-router/router';
|
||||
import { signatureRouter } from './signature-router/router';
|
||||
import { teamRouter } from './team-router/router';
|
||||
import { templateRouter } from './template-router/router';
|
||||
import { router } from './trpc';
|
||||
@@ -24,6 +25,7 @@ export const appRouter = router({
|
||||
field: fieldRouter,
|
||||
folder: folderRouter,
|
||||
recipient: recipientRouter,
|
||||
signature: signatureRouter,
|
||||
admin: adminRouter,
|
||||
organisation: organisationRouter,
|
||||
apiToken: apiTokenRouter,
|
||||
|
||||
@@ -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>;
|
||||
@@ -0,0 +1,64 @@
|
||||
import { QR_SIGNATURE_TOKEN_EXPIRY_MINUTES } from '@documenso/lib/constants/signatures';
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { assertRateLimit } from '@documenso/lib/server-only/rate-limit/rate-limit-middleware';
|
||||
import { qrSignatureCreateRateLimit } from '@documenso/lib/server-only/rate-limit/rate-limits';
|
||||
import { nanoid } from '@documenso/lib/universal/id';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { AnonymousVerificationTokenType } from '@prisma/client';
|
||||
import { DateTime } from 'luxon';
|
||||
|
||||
import { procedure } from '../../trpc';
|
||||
import { ZCreateQrSignatureRequestSchema, ZCreateQrSignatureResponseSchema } from './create-qr-signature.types';
|
||||
|
||||
/**
|
||||
* NOTE: THIS IS A PUBLIC (UNAUTHENTICATED) PROCEDURE.
|
||||
*
|
||||
* Creates a short-lived anonymous session which allows a signature drawn on a
|
||||
* mobile device to be handed off to the desktop signature pad. The token is
|
||||
* the sole authorization for the session.
|
||||
*/
|
||||
export const createQrSignatureRoute = procedure
|
||||
.input(ZCreateQrSignatureRequestSchema)
|
||||
.output(ZCreateQrSignatureResponseSchema)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const { context } = input;
|
||||
|
||||
const { ipAddress } = ctx.metadata.requestMetadata;
|
||||
|
||||
const rateLimitResult = await qrSignatureCreateRateLimit.check({
|
||||
ip: ipAddress ?? 'unknown',
|
||||
});
|
||||
|
||||
assertRateLimit(rateLimitResult);
|
||||
|
||||
if (context?.type === 'DOCUMENT_SIGNATURE') {
|
||||
const recipient = await prisma.recipient.findFirst({
|
||||
where: {
|
||||
token: context.recipientToken,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!recipient) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'Recipient not found for the provided token',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const qrSignatureSession = await prisma.anonymousVerificationToken.create({
|
||||
data: {
|
||||
type: AnonymousVerificationTokenType.QR_SIGNATURE,
|
||||
token: nanoid(),
|
||||
metadata: context ? { context } : undefined,
|
||||
expiresAt: DateTime.now().plus({ minutes: QR_SIGNATURE_TOKEN_EXPIRY_MINUTES }).toJSDate(),
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
token: qrSignatureSession.token,
|
||||
expiresAt: qrSignatureSession.expiresAt,
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
import { ZQrSignatureContextSchema } from '@documenso/lib/types/qr-signature';
|
||||
import { z } from 'zod';
|
||||
|
||||
export const ZCreateQrSignatureRequestSchema = z.object({
|
||||
context: ZQrSignatureContextSchema.nullish(),
|
||||
});
|
||||
|
||||
export const ZCreateQrSignatureResponseSchema = z.object({
|
||||
token: z.string(),
|
||||
expiresAt: z.date(),
|
||||
});
|
||||
|
||||
export type TCreateQrSignatureRequest = z.infer<typeof ZCreateQrSignatureRequestSchema>;
|
||||
export type TCreateQrSignatureResponse = z.infer<typeof ZCreateQrSignatureResponseSchema>;
|
||||
@@ -0,0 +1,99 @@
|
||||
import { ZQrSignatureContextSchema } from '@documenso/lib/types/qr-signature';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { AnonymousVerificationTokenType } from '@prisma/client';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { procedure } from '../../trpc';
|
||||
import {
|
||||
ZGetQrSignatureSessionRequestSchema,
|
||||
ZGetQrSignatureSessionResponseSchema,
|
||||
} from './get-qr-signature-session.types';
|
||||
|
||||
const ZSessionMetadataSchema = z.object({
|
||||
context: ZQrSignatureContextSchema,
|
||||
});
|
||||
|
||||
/**
|
||||
* NOTE: THIS IS A PUBLIC (UNAUTHENTICATED) PROCEDURE.
|
||||
*
|
||||
* Classify a QR signature session token for the mobile signing page and
|
||||
* resolve the context stored on the session.
|
||||
*
|
||||
* A missing row is indistinguishable from an expired one by design.
|
||||
*
|
||||
* Called once per page load; the global trpc rate limit covers it, matching
|
||||
* the polling `qr.get` route.
|
||||
*/
|
||||
export const getQrSignatureSessionRoute = procedure
|
||||
.input(ZGetQrSignatureSessionRequestSchema)
|
||||
.output(ZGetQrSignatureSessionResponseSchema)
|
||||
.query(async ({ input }) => {
|
||||
const { token } = input;
|
||||
|
||||
const qrSignatureSession = await prisma.anonymousVerificationToken.findUnique({
|
||||
where: {
|
||||
token,
|
||||
type: AnonymousVerificationTokenType.QR_SIGNATURE,
|
||||
},
|
||||
});
|
||||
|
||||
if (!qrSignatureSession || qrSignatureSession.expiresAt < new Date()) {
|
||||
return { status: 'EXPIRED' } as const;
|
||||
}
|
||||
|
||||
if (qrSignatureSession.value) {
|
||||
return { status: 'ALREADY_SUBMITTED' } as const;
|
||||
}
|
||||
|
||||
const parsedMetadata = ZSessionMetadataSchema.nullish().safeParse(qrSignatureSession.metadata);
|
||||
|
||||
if (!parsedMetadata.success) {
|
||||
return { status: 'INVALID' } as const;
|
||||
}
|
||||
|
||||
// Sessions created without a context are valid, but generic.
|
||||
if (!parsedMetadata.data) {
|
||||
return { status: 'VALID', context: { type: 'NONE' } } as const;
|
||||
}
|
||||
|
||||
const { context } = parsedMetadata.data;
|
||||
|
||||
if (context.type === 'PROFILE_SIGNATURE') {
|
||||
return { status: 'VALID', context: { type: context.type } } as const;
|
||||
}
|
||||
|
||||
if (context.recipientToken.length < 1) {
|
||||
return { status: 'INVALID' } as const;
|
||||
}
|
||||
|
||||
const recipient = await prisma.recipient.findFirst({
|
||||
where: {
|
||||
token: context.recipientToken,
|
||||
},
|
||||
select: {
|
||||
envelope: {
|
||||
select: {
|
||||
title: true,
|
||||
team: {
|
||||
select: {
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!recipient) {
|
||||
return { status: 'INVALID' } as const;
|
||||
}
|
||||
|
||||
return {
|
||||
status: 'VALID',
|
||||
context: {
|
||||
type: 'DOCUMENT_SIGNATURE',
|
||||
documentTitle: recipient.envelope.title,
|
||||
teamName: recipient.envelope.team.name,
|
||||
},
|
||||
} as const;
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const ZGetQrSignatureSessionRequestSchema = z.object({
|
||||
token: z.string().min(1).max(64).describe('The QR signature session token'),
|
||||
});
|
||||
|
||||
/**
|
||||
* The resolved context of a valid QR signature session.
|
||||
*
|
||||
* `NONE` is a session created without any context, in which case the mobile
|
||||
* page shows a generic "Signature requested".
|
||||
*/
|
||||
export const ZQrSignatureSessionContextSchema = z.discriminatedUnion('type', [
|
||||
z.object({
|
||||
type: z.literal('NONE'),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal('PROFILE_SIGNATURE'),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal('DOCUMENT_SIGNATURE'),
|
||||
documentTitle: z.string(),
|
||||
teamName: z.string(),
|
||||
}),
|
||||
]);
|
||||
|
||||
export const ZGetQrSignatureSessionResponseSchema = z.discriminatedUnion('status', [
|
||||
z.object({
|
||||
status: z.literal('EXPIRED'),
|
||||
}),
|
||||
z.object({
|
||||
status: z.literal('ALREADY_SUBMITTED'),
|
||||
}),
|
||||
z.object({
|
||||
// The session references a signing flow that no longer exists, or carries
|
||||
// malformed metadata.
|
||||
status: z.literal('INVALID'),
|
||||
}),
|
||||
z.object({
|
||||
status: z.literal('VALID'),
|
||||
context: ZQrSignatureSessionContextSchema,
|
||||
}),
|
||||
]);
|
||||
|
||||
export type TGetQrSignatureSessionRequest = z.infer<typeof ZGetQrSignatureSessionRequestSchema>;
|
||||
export type TGetQrSignatureSessionResponse = z.infer<typeof ZGetQrSignatureSessionResponseSchema>;
|
||||
export type TQrSignatureSessionContext = z.infer<typeof ZQrSignatureSessionContextSchema>;
|
||||
@@ -0,0 +1,57 @@
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { AnonymousVerificationTokenType } from '@prisma/client';
|
||||
|
||||
import { procedure } from '../../trpc';
|
||||
import { ZGetQrSignatureRequestSchema, ZGetQrSignatureResponseSchema } from './get-qr-signature.types';
|
||||
|
||||
/**
|
||||
* NOTE: THIS IS A PUBLIC (UNAUTHENTICATED) PROCEDURE.
|
||||
*
|
||||
* Polled by the desktop signature pad while waiting for a mobile signature.
|
||||
*
|
||||
* A missing row is indistinguishable from an expired one by design, so we
|
||||
* return EXPIRED for both. Once the signature is returned the row is deleted,
|
||||
* making the token single-use.
|
||||
*/
|
||||
export const getQrSignatureRoute = procedure
|
||||
.input(ZGetQrSignatureRequestSchema)
|
||||
.output(ZGetQrSignatureResponseSchema)
|
||||
.query(async ({ input }) => {
|
||||
const { token } = input;
|
||||
|
||||
const qrSignatureSession = await prisma.anonymousVerificationToken.findUnique({
|
||||
where: {
|
||||
token,
|
||||
type: AnonymousVerificationTokenType.QR_SIGNATURE,
|
||||
},
|
||||
});
|
||||
|
||||
if (!qrSignatureSession || qrSignatureSession.expiresAt < new Date()) {
|
||||
return {
|
||||
status: 'EXPIRED',
|
||||
} as const;
|
||||
}
|
||||
|
||||
if (!qrSignatureSession.value) {
|
||||
return {
|
||||
status: 'PENDING',
|
||||
} as const;
|
||||
}
|
||||
|
||||
const { count: deletedCount } = await prisma.anonymousVerificationToken.deleteMany({
|
||||
where: {
|
||||
id: qrSignatureSession.id,
|
||||
},
|
||||
});
|
||||
|
||||
if (deletedCount === 0) {
|
||||
return {
|
||||
status: 'EXPIRED',
|
||||
} as const;
|
||||
}
|
||||
|
||||
return {
|
||||
status: 'COMPLETED',
|
||||
signature: qrSignatureSession.value,
|
||||
} as const;
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const ZGetQrSignatureRequestSchema = z.object({
|
||||
token: z.string().min(1).max(64).describe('The QR signature session token to poll'),
|
||||
});
|
||||
|
||||
export const ZGetQrSignatureResponseSchema = z.discriminatedUnion('status', [
|
||||
z.object({
|
||||
status: z.literal('PENDING'),
|
||||
}),
|
||||
z.object({
|
||||
status: z.literal('EXPIRED'),
|
||||
}),
|
||||
z.object({
|
||||
status: z.literal('COMPLETED'),
|
||||
signature: z.string(),
|
||||
}),
|
||||
]);
|
||||
|
||||
export type TGetQrSignatureRequest = z.infer<typeof ZGetQrSignatureRequestSchema>;
|
||||
export type TGetQrSignatureResponse = z.infer<typeof ZGetQrSignatureResponseSchema>;
|
||||
@@ -0,0 +1,14 @@
|
||||
import { router } from '../trpc';
|
||||
import { completeQrSignatureRoute } from './qr/complete-qr-signature';
|
||||
import { createQrSignatureRoute } from './qr/create-qr-signature';
|
||||
import { getQrSignatureRoute } from './qr/get-qr-signature';
|
||||
import { getQrSignatureSessionRoute } from './qr/get-qr-signature-session';
|
||||
|
||||
export const signatureRouter = router({
|
||||
qr: {
|
||||
create: createQrSignatureRoute,
|
||||
get: getQrSignatureRoute,
|
||||
getSession: getQrSignatureSessionRoute,
|
||||
complete: completeQrSignatureRoute,
|
||||
},
|
||||
});
|
||||
@@ -36,6 +36,7 @@ export const updateTeamSettingsRoute = authenticatedProcedure
|
||||
typedSignatureEnabled,
|
||||
uploadSignatureEnabled,
|
||||
drawSignatureEnabled,
|
||||
qrSignatureEnabled,
|
||||
delegateDocumentOwnership,
|
||||
envelopeExpirationPeriod,
|
||||
reminderSettings,
|
||||
@@ -66,7 +67,12 @@ export const updateTeamSettingsRoute = authenticatedProcedure
|
||||
}
|
||||
|
||||
// Signatures will only be inherited if all are NULL.
|
||||
if (typedSignatureEnabled === false && uploadSignatureEnabled === false && drawSignatureEnabled === false) {
|
||||
if (
|
||||
typedSignatureEnabled === false &&
|
||||
uploadSignatureEnabled === false &&
|
||||
drawSignatureEnabled === false &&
|
||||
qrSignatureEnabled === false
|
||||
) {
|
||||
throw new AppError(AppErrorCode.INVALID_BODY, {
|
||||
message: 'At least one signature type must be enabled',
|
||||
});
|
||||
@@ -168,6 +174,7 @@ export const updateTeamSettingsRoute = authenticatedProcedure
|
||||
typedSignatureEnabled,
|
||||
uploadSignatureEnabled,
|
||||
drawSignatureEnabled,
|
||||
qrSignatureEnabled,
|
||||
delegateDocumentOwnership,
|
||||
envelopeExpirationPeriod: envelopeExpirationPeriod === null ? Prisma.DbNull : envelopeExpirationPeriod,
|
||||
reminderSettings: reminderSettings === null ? Prisma.DbNull : reminderSettings,
|
||||
|
||||
@@ -29,6 +29,7 @@ export const ZUpdateTeamSettingsRequestSchema = z.object({
|
||||
typedSignatureEnabled: z.boolean().nullish(),
|
||||
uploadSignatureEnabled: z.boolean().nullish(),
|
||||
drawSignatureEnabled: z.boolean().nullish(),
|
||||
qrSignatureEnabled: z.boolean().nullish(),
|
||||
delegateDocumentOwnership: z.boolean().nullish(),
|
||||
envelopeExpirationPeriod: ZEnvelopeExpirationPeriod.nullish(),
|
||||
reminderSettings: ZEnvelopeReminderSettings.nullish(),
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
ZDocumentMetaDrawSignatureEnabledSchema,
|
||||
ZDocumentMetaLanguageSchema,
|
||||
ZDocumentMetaMessageSchema,
|
||||
ZDocumentMetaQrSignatureEnabledSchema,
|
||||
ZDocumentMetaRedirectUrlSchema,
|
||||
ZDocumentMetaSubjectSchema,
|
||||
ZDocumentMetaTimezoneSchema,
|
||||
@@ -66,6 +67,7 @@ export const ZTemplateMetaUpsertSchema = z.object({
|
||||
typedSignatureEnabled: ZDocumentMetaTypedSignatureEnabledSchema.optional(),
|
||||
uploadSignatureEnabled: ZDocumentMetaUploadSignatureEnabledSchema.optional(),
|
||||
drawSignatureEnabled: ZDocumentMetaDrawSignatureEnabledSchema.optional(),
|
||||
qrSignatureEnabled: ZDocumentMetaQrSignatureEnabledSchema.optional(),
|
||||
signingOrder: z.nativeEnum(DocumentSigningOrder).optional(),
|
||||
allowDictateNextSigner: z.boolean().optional(),
|
||||
});
|
||||
@@ -147,6 +149,7 @@ export const ZCreateDocumentFromTemplateRequestSchema = z.object({
|
||||
typedSignatureEnabled: ZDocumentMetaTypedSignatureEnabledSchema.optional(),
|
||||
uploadSignatureEnabled: ZDocumentMetaUploadSignatureEnabledSchema.optional(),
|
||||
drawSignatureEnabled: ZDocumentMetaDrawSignatureEnabledSchema.optional(),
|
||||
qrSignatureEnabled: ZDocumentMetaQrSignatureEnabledSchema.optional(),
|
||||
allowDictateNextSigner: z.boolean().optional(),
|
||||
envelopeExpirationPeriod: ZEnvelopeExpirationPeriod.nullish(),
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user