mirror of
https://github.com/documenso/documenso.git
synced 2026-08-23 23:02:22 +10:00
feat: add qr signatures
This commit is contained in:
@@ -77,4 +77,11 @@ export const DOCUMENT_SIGNATURE_TYPES = {
|
||||
}),
|
||||
value: DocumentSignatureType.UPLOAD,
|
||||
},
|
||||
[DocumentSignatureType.QR]: {
|
||||
label: msg({
|
||||
message: `QR code`,
|
||||
context: `Sign using a mobile phone via QR code`,
|
||||
}),
|
||||
value: DocumentSignatureType.QR,
|
||||
},
|
||||
} satisfies Record<DocumentSignatureType, DocumentSignatureTypeData>;
|
||||
|
||||
@@ -2,3 +2,5 @@ export const SIGNATURE_CANVAS_DPI = 2;
|
||||
export const SIGNATURE_MIN_COVERAGE_THRESHOLD = 0.01;
|
||||
|
||||
export const isBase64Image = (value: string) => value.startsWith('data:image/png;base64,');
|
||||
|
||||
export const QR_SIGNATURE_TOKEN_EXPIRY_MINUTES = 10;
|
||||
|
||||
@@ -21,6 +21,7 @@ import { ALERT_ORGANISATION_SEAT_DRIFT_JOB_DEFINITION } from './definitions/inte
|
||||
import { BACKPORT_SUBSCRIPTION_CLAIM_JOB_DEFINITION } from './definitions/internal/backport-subscription-claims';
|
||||
import { BULK_SEND_TEMPLATE_JOB_DEFINITION } from './definitions/internal/bulk-send-template';
|
||||
import { CANCEL_ORGANISATION_SUBSCRIPTION_JOB_DEFINITION } from './definitions/internal/cancel-organisation-subscription';
|
||||
import { CLEANUP_ANONYMOUS_TOKENS_JOB_DEFINITION } from './definitions/internal/cleanup-anonymous-tokens';
|
||||
import { CLEANUP_RATE_LIMITS_JOB_DEFINITION } from './definitions/internal/cleanup-rate-limits';
|
||||
import { EXECUTE_WEBHOOK_JOB_DEFINITION } from './definitions/internal/execute-webhook';
|
||||
import { EXPIRE_RECIPIENTS_SWEEP_JOB_DEFINITION } from './definitions/internal/expire-recipients-sweep';
|
||||
@@ -64,6 +65,7 @@ export const jobsClient = new JobClient([
|
||||
SEND_SIGNING_REMINDERS_SWEEP_JOB_DEFINITION,
|
||||
PROCESS_SIGNING_REMINDER_JOB_DEFINITION,
|
||||
CLEANUP_RATE_LIMITS_JOB_DEFINITION,
|
||||
CLEANUP_ANONYMOUS_TOKENS_JOB_DEFINITION,
|
||||
SYNC_EMAIL_DOMAINS_JOB_DEFINITION,
|
||||
ADMIN_DELETE_ORGANISATION_JOB_DEFINITION,
|
||||
ALERT_ORGANISATION_SEAT_DRIFT_JOB_DEFINITION,
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import type { JobRunIO } from '../../client/_internal/job';
|
||||
import type { TCleanupAnonymousTokensJobDefinition } from './cleanup-anonymous-tokens';
|
||||
|
||||
const BATCH_SIZE = 10_000;
|
||||
|
||||
export const run = async ({ io }: { payload: TCleanupAnonymousTokensJobDefinition; io: JobRunIO }) => {
|
||||
// Snapshot the cutoff so the run is bounded by the rows that were already
|
||||
// expired when it started, rather than chasing rows expiring mid-run.
|
||||
const cutoff = new Date();
|
||||
|
||||
let totalDeleted = 0;
|
||||
let deleted = 0;
|
||||
|
||||
do {
|
||||
// Postgres doesn't support DELETE with LIMIT, so batch via ctid to avoid
|
||||
// long-running transactions that could lock the table.
|
||||
deleted = await prisma.$executeRaw`
|
||||
DELETE FROM "AnonymousVerificationToken"
|
||||
WHERE ctid IN (
|
||||
SELECT ctid FROM "AnonymousVerificationToken"
|
||||
WHERE "expiresAt" < ${cutoff}
|
||||
LIMIT ${BATCH_SIZE}
|
||||
)
|
||||
`;
|
||||
|
||||
totalDeleted += deleted;
|
||||
} while (deleted >= BATCH_SIZE);
|
||||
|
||||
if (totalDeleted > 0) {
|
||||
io.logger.info(`Cleaned up ${totalDeleted} expired anonymous verification tokens`);
|
||||
} else {
|
||||
io.logger.info('No expired anonymous verification tokens to clean up');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import type { JobDefinition } from '../../client/_internal/job';
|
||||
|
||||
const CLEANUP_ANONYMOUS_TOKENS_JOB_DEFINITION_ID = 'internal.cleanup-anonymous-tokens';
|
||||
|
||||
const CLEANUP_ANONYMOUS_TOKENS_JOB_DEFINITION_SCHEMA = z.object({});
|
||||
|
||||
export type TCleanupAnonymousTokensJobDefinition = z.infer<typeof CLEANUP_ANONYMOUS_TOKENS_JOB_DEFINITION_SCHEMA>;
|
||||
|
||||
export const CLEANUP_ANONYMOUS_TOKENS_JOB_DEFINITION = {
|
||||
id: CLEANUP_ANONYMOUS_TOKENS_JOB_DEFINITION_ID,
|
||||
name: 'Cleanup Anonymous Verification Tokens',
|
||||
version: '1.0.0',
|
||||
trigger: {
|
||||
name: CLEANUP_ANONYMOUS_TOKENS_JOB_DEFINITION_ID,
|
||||
schema: CLEANUP_ANONYMOUS_TOKENS_JOB_DEFINITION_SCHEMA,
|
||||
cron: '0 */2 * * *', // Every 2 hours.
|
||||
},
|
||||
handler: async ({ payload, io }) => {
|
||||
const handler = await import('./cleanup-anonymous-tokens.handler');
|
||||
|
||||
await handler.run({ payload, io });
|
||||
},
|
||||
} as const satisfies JobDefinition<
|
||||
typeof CLEANUP_ANONYMOUS_TOKENS_JOB_DEFINITION_ID,
|
||||
TCleanupAnonymousTokensJobDefinition
|
||||
>;
|
||||
@@ -1,4 +1,5 @@
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { AnonymousVerificationTokenType } from '@prisma/client';
|
||||
import { generateAuthenticationOptions } from '@simplewebauthn/server';
|
||||
import { DateTime } from 'luxon';
|
||||
|
||||
@@ -24,12 +25,14 @@ export const createPasskeySigninOptions = async ({ sessionId }: CreatePasskeySig
|
||||
id: sessionId,
|
||||
},
|
||||
update: {
|
||||
type: AnonymousVerificationTokenType.PASSKEY,
|
||||
token: challenge,
|
||||
expiresAt: DateTime.now().plus({ minutes: 2 }).toJSDate(),
|
||||
createdAt: new Date(),
|
||||
},
|
||||
create: {
|
||||
id: sessionId,
|
||||
type: AnonymousVerificationTokenType.PASSKEY,
|
||||
token: challenge,
|
||||
expiresAt: DateTime.now().plus({ minutes: 2 }).toJSDate(),
|
||||
createdAt: new Date(),
|
||||
|
||||
@@ -31,6 +31,7 @@ export type CreateDocumentMetaOptions = {
|
||||
typedSignatureEnabled?: boolean;
|
||||
uploadSignatureEnabled?: boolean;
|
||||
drawSignatureEnabled?: boolean;
|
||||
qrSignatureEnabled?: boolean;
|
||||
language?: SupportedLanguageCodes;
|
||||
requestMetadata: ApiRequestMetadata;
|
||||
};
|
||||
@@ -53,6 +54,7 @@ export const updateDocumentMeta = async ({
|
||||
typedSignatureEnabled,
|
||||
uploadSignatureEnabled,
|
||||
drawSignatureEnabled,
|
||||
qrSignatureEnabled,
|
||||
language,
|
||||
requestMetadata,
|
||||
}: CreateDocumentMetaOptions) => {
|
||||
@@ -132,6 +134,7 @@ export const updateDocumentMeta = async ({
|
||||
typedSignatureEnabled,
|
||||
uploadSignatureEnabled,
|
||||
drawSignatureEnabled,
|
||||
qrSignatureEnabled,
|
||||
language,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -47,6 +47,7 @@ export const ZEnvelopeForSigningResponse = z.object({
|
||||
typedSignatureEnabled: true,
|
||||
uploadSignatureEnabled: true,
|
||||
drawSignatureEnabled: true,
|
||||
qrSignatureEnabled: true,
|
||||
allowDictateNextSigner: true,
|
||||
language: true,
|
||||
}),
|
||||
|
||||
@@ -72,6 +72,21 @@ export const reportSenderRateLimit = createRateLimit({
|
||||
window: '7d',
|
||||
});
|
||||
|
||||
// ---- Signature (QR mobile handoff) ----
|
||||
|
||||
export const qrSignatureCreateRateLimit = createRateLimit({
|
||||
action: 'signature.qr-create',
|
||||
max: 20,
|
||||
window: '15m',
|
||||
});
|
||||
|
||||
export const qrSignatureCompleteRateLimit = createRateLimit({
|
||||
action: 'signature.qr-complete',
|
||||
max: 20,
|
||||
globalMax: 60,
|
||||
window: '15m',
|
||||
});
|
||||
|
||||
// ---- Billing ----
|
||||
|
||||
export const syncSubscriptionRateLimit = createRateLimit({
|
||||
|
||||
@@ -112,6 +112,7 @@ export type CreateDocumentFromTemplateOptions = {
|
||||
typedSignatureEnabled?: boolean;
|
||||
uploadSignatureEnabled?: boolean;
|
||||
drawSignatureEnabled?: boolean;
|
||||
qrSignatureEnabled?: boolean;
|
||||
envelopeExpirationPeriod?: TEnvelopeExpirationPeriod | null;
|
||||
};
|
||||
|
||||
@@ -540,6 +541,7 @@ export const createDocumentFromTemplate = async ({
|
||||
typedSignatureEnabled: override?.typedSignatureEnabled ?? template.documentMeta?.typedSignatureEnabled,
|
||||
uploadSignatureEnabled: override?.uploadSignatureEnabled ?? template.documentMeta?.uploadSignatureEnabled,
|
||||
drawSignatureEnabled: override?.drawSignatureEnabled ?? template.documentMeta?.drawSignatureEnabled,
|
||||
qrSignatureEnabled: override?.qrSignatureEnabled ?? template.documentMeta?.qrSignatureEnabled,
|
||||
allowDictateNextSigner: override?.allowDictateNextSigner ?? template.documentMeta?.allowDictateNextSigner,
|
||||
envelopeExpirationPeriod: override?.envelopeExpirationPeriod ?? template.documentMeta?.envelopeExpirationPeriod,
|
||||
},
|
||||
|
||||
@@ -46,6 +46,7 @@ export const generateSampleWebhookPayload = (event: WebhookTriggerEvents, webhoo
|
||||
typedSignatureEnabled: true,
|
||||
uploadSignatureEnabled: true,
|
||||
drawSignatureEnabled: true,
|
||||
qrSignatureEnabled: true,
|
||||
language: 'en',
|
||||
distributionMethod: DocumentDistributionMethod.EMAIL,
|
||||
emailSettings: null,
|
||||
|
||||
@@ -28,6 +28,7 @@ export const ZDocumentMetaSchema = DocumentMetaSchema.pick({
|
||||
typedSignatureEnabled: true,
|
||||
uploadSignatureEnabled: true,
|
||||
drawSignatureEnabled: true,
|
||||
qrSignatureEnabled: true,
|
||||
language: true,
|
||||
emailSettings: true,
|
||||
});
|
||||
@@ -105,6 +106,10 @@ export const ZDocumentMetaUploadSignatureEnabledSchema = z
|
||||
.boolean()
|
||||
.describe('Whether to allow recipients to sign using an uploaded signature.');
|
||||
|
||||
export const ZDocumentMetaQrSignatureEnabledSchema = z
|
||||
.boolean()
|
||||
.describe('Whether to allow recipients to sign using a QR code handoff to a mobile device.');
|
||||
|
||||
/**
|
||||
* Note: Any updates to this will cause public API changes. You will need to update
|
||||
* all corresponding areas where this is used (some places that use this needs to pass
|
||||
@@ -123,6 +128,7 @@ export const ZDocumentMetaCreateSchema = z.object({
|
||||
typedSignatureEnabled: ZDocumentMetaTypedSignatureEnabledSchema.optional(),
|
||||
uploadSignatureEnabled: ZDocumentMetaUploadSignatureEnabledSchema.optional(),
|
||||
drawSignatureEnabled: ZDocumentMetaDrawSignatureEnabledSchema.optional(),
|
||||
qrSignatureEnabled: ZDocumentMetaQrSignatureEnabledSchema.optional(),
|
||||
emailId: z.string().nullish(),
|
||||
emailReplyTo: zEmail().nullish(),
|
||||
emailSettings: ZDocumentEmailSettingsSchema.nullish(),
|
||||
|
||||
@@ -62,6 +62,7 @@ export const ZDocumentSchema = LegacyDocumentSchema.pick({
|
||||
typedSignatureEnabled: true,
|
||||
uploadSignatureEnabled: true,
|
||||
drawSignatureEnabled: true,
|
||||
qrSignatureEnabled: true,
|
||||
allowDictateNextSigner: true,
|
||||
language: true,
|
||||
emailSettings: true,
|
||||
|
||||
@@ -279,6 +279,7 @@ export const ZEditorEnvelopeSchema = EnvelopeSchema.pick({
|
||||
typedSignatureEnabled: true,
|
||||
uploadSignatureEnabled: true,
|
||||
drawSignatureEnabled: true,
|
||||
qrSignatureEnabled: true,
|
||||
allowDictateNextSigner: true,
|
||||
language: true,
|
||||
emailSettings: true,
|
||||
|
||||
@@ -49,6 +49,7 @@ export const ZEnvelopeSchema = EnvelopeSchema.pick({
|
||||
typedSignatureEnabled: true,
|
||||
uploadSignatureEnabled: true,
|
||||
drawSignatureEnabled: true,
|
||||
qrSignatureEnabled: true,
|
||||
allowDictateNextSigner: true,
|
||||
language: true,
|
||||
emailSettings: true,
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
/**
|
||||
* The context a QR signature session is created for.
|
||||
*
|
||||
* - `PROFILE_SIGNATURE`: a standalone signature, e.g. the profile or signup
|
||||
* forms. Carries no additional data.
|
||||
* - `DOCUMENT_SIGNATURE`: a signature for a document signing flow. Carries the
|
||||
* recipient token so the mobile page can render the document context.
|
||||
*/
|
||||
export const ZQrSignatureContextSchema = z.discriminatedUnion('type', [
|
||||
z.object({
|
||||
type: z.literal('PROFILE_SIGNATURE'),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal('DOCUMENT_SIGNATURE'),
|
||||
recipientToken: z.string().min(1).max(64),
|
||||
}),
|
||||
]);
|
||||
|
||||
export type TQrSignatureContext = z.infer<typeof ZQrSignatureContextSchema>;
|
||||
|
||||
export type TQrSignatureContextType = TQrSignatureContext['type'];
|
||||
@@ -54,6 +54,7 @@ export const ZTemplateSchema = TemplateSchema.pick({
|
||||
typedSignatureEnabled: true,
|
||||
uploadSignatureEnabled: true,
|
||||
drawSignatureEnabled: true,
|
||||
qrSignatureEnabled: true,
|
||||
allowDictateNextSigner: true,
|
||||
distributionMethod: true,
|
||||
redirectUrl: true,
|
||||
|
||||
@@ -55,6 +55,7 @@ export const ZWebhookDocumentMetaSchema = z.object({
|
||||
typedSignatureEnabled: z.boolean(),
|
||||
uploadSignatureEnabled: z.boolean(),
|
||||
drawSignatureEnabled: z.boolean(),
|
||||
qrSignatureEnabled: z.boolean(),
|
||||
language: z.string(),
|
||||
distributionMethod: z.nativeEnum(DocumentDistributionMethod),
|
||||
emailSettings: z.any().nullable(),
|
||||
|
||||
@@ -59,6 +59,7 @@ export const extractDerivedDocumentMeta = (
|
||||
typedSignatureEnabled: meta.typedSignatureEnabled ?? settings.typedSignatureEnabled,
|
||||
uploadSignatureEnabled: meta.uploadSignatureEnabled ?? settings.uploadSignatureEnabled,
|
||||
drawSignatureEnabled: meta.drawSignatureEnabled ?? settings.drawSignatureEnabled,
|
||||
qrSignatureEnabled: meta.qrSignatureEnabled ?? settings.qrSignatureEnabled,
|
||||
|
||||
// Email settings.
|
||||
emailId: meta.emailId ?? settings.emailId,
|
||||
|
||||
@@ -119,6 +119,7 @@ export const generateDefaultOrganisationSettings = (): Omit<OrganisationGlobalSe
|
||||
typedSignatureEnabled: true,
|
||||
uploadSignatureEnabled: true,
|
||||
drawSignatureEnabled: true,
|
||||
qrSignatureEnabled: true,
|
||||
|
||||
brandingEnabled: false,
|
||||
brandingLogo: '',
|
||||
|
||||
@@ -17,6 +17,7 @@ export enum DocumentSignatureType {
|
||||
DRAW = 'draw',
|
||||
TYPE = 'type',
|
||||
UPLOAD = 'upload',
|
||||
QR = 'qr',
|
||||
}
|
||||
|
||||
export const formatTeamUrl = (teamUrl: string, baseUrl?: string) => {
|
||||
@@ -93,10 +94,16 @@ export const extractTeamSignatureSettings = (
|
||||
typedSignatureEnabled: boolean | null;
|
||||
drawSignatureEnabled: boolean | null;
|
||||
uploadSignatureEnabled: boolean | null;
|
||||
qrSignatureEnabled: boolean | null;
|
||||
} | null,
|
||||
) => {
|
||||
if (!settings) {
|
||||
return [DocumentSignatureType.TYPE, DocumentSignatureType.UPLOAD, DocumentSignatureType.DRAW];
|
||||
return [
|
||||
DocumentSignatureType.TYPE,
|
||||
DocumentSignatureType.UPLOAD,
|
||||
DocumentSignatureType.DRAW,
|
||||
DocumentSignatureType.QR,
|
||||
];
|
||||
}
|
||||
|
||||
const signatureTypes: DocumentSignatureType[] = [];
|
||||
@@ -113,6 +120,10 @@ export const extractTeamSignatureSettings = (
|
||||
signatureTypes.push(DocumentSignatureType.UPLOAD);
|
||||
}
|
||||
|
||||
if (settings.qrSignatureEnabled) {
|
||||
signatureTypes.push(DocumentSignatureType.QR);
|
||||
}
|
||||
|
||||
return signatureTypes;
|
||||
};
|
||||
|
||||
@@ -186,6 +197,7 @@ export const generateDefaultTeamSettings = (): Omit<TeamGlobalSettings, 'id' | '
|
||||
typedSignatureEnabled: null,
|
||||
uploadSignatureEnabled: null,
|
||||
drawSignatureEnabled: null,
|
||||
qrSignatureEnabled: null,
|
||||
|
||||
brandingEnabled: null,
|
||||
brandingLogo: null,
|
||||
|
||||
Reference in New Issue
Block a user