feat: add qr signatures

This commit is contained in:
David Nguyen
2026-08-18 11:49:15 +10:00
parent 779de01fe8
commit 9825ea88b1
99 changed files with 1686 additions and 48 deletions
+1
View File
@@ -438,6 +438,7 @@ export const ApiContractV1Implementation = tsr.router(ApiContractV1, {
typedSignatureEnabled: body.meta.typedSignatureEnabled,
uploadSignatureEnabled: body.meta.uploadSignatureEnabled,
drawSignatureEnabled: body.meta.drawSignatureEnabled,
qrSignatureEnabled: body.meta.qrSignatureEnabled,
distributionMethod: body.meta.distributionMethod,
emailSettings: body.meta.emailSettings,
},
+3
View File
@@ -170,6 +170,8 @@ export const ZCreateDocumentMutationSchema = z.object({
typedSignatureEnabled: z.boolean().optional().default(true),
uploadSignatureEnabled: z.boolean().optional().default(true),
drawSignatureEnabled: z.boolean().optional().default(true),
// No default: omission must fall through to team/org settings.
qrSignatureEnabled: z.boolean().optional(),
distributionMethod: z.nativeEnum(DocumentDistributionMethod).optional(),
emailSettings: ZDocumentEmailSettingsSchema.optional(),
})
@@ -340,6 +342,7 @@ export const ZGenerateDocumentFromTemplateMutationSchema = z.object({
typedSignatureEnabled: z.boolean(),
uploadSignatureEnabled: z.boolean(),
drawSignatureEnabled: z.boolean(),
qrSignatureEnabled: z.boolean(),
emailSettings: ZDocumentEmailSettingsSchema,
})
.partial()
@@ -196,6 +196,7 @@ test.describe('API V2 Envelopes', () => {
typedSignatureEnabled: true,
uploadSignatureEnabled: false,
drawSignatureEnabled: false,
qrSignatureEnabled: false,
emailReplyTo: userA.email,
emailSettings: {
recipientSigningRequest: false,
@@ -295,6 +296,7 @@ test.describe('API V2 Envelopes', () => {
expect(envelope.documentMeta.typedSignatureEnabled).toBe(payload.meta.typedSignatureEnabled);
expect(envelope.documentMeta.uploadSignatureEnabled).toBe(payload.meta.uploadSignatureEnabled);
expect(envelope.documentMeta.drawSignatureEnabled).toBe(payload.meta.drawSignatureEnabled);
expect(envelope.documentMeta.qrSignatureEnabled).toBe(payload.meta.qrSignatureEnabled);
expect(envelope.documentMeta.emailReplyTo).toBe(payload.meta.emailReplyTo);
expect(envelope.documentMeta.emailSettings).toEqual(payload.meta.emailSettings);
@@ -158,6 +158,7 @@ test.describe('AutoSave Settings Step', () => {
expect(retrieved.documentMeta?.drawSignatureEnabled).toBe(false);
expect(retrieved.documentMeta?.typedSignatureEnabled).toBe(false);
expect(retrieved.documentMeta?.uploadSignatureEnabled).toBe(true);
expect(retrieved.documentMeta?.qrSignatureEnabled).toBe(true);
}).toPass();
});
@@ -384,6 +384,7 @@ const assertEnvelopeSettingsPersistedInDatabase = async ({
expect(envelope.documentMeta.drawSignatureEnabled).toBe(true);
expect(envelope.documentMeta.typedSignatureEnabled).toBe(true);
expect(envelope.documentMeta.uploadSignatureEnabled).toBe(false);
expect(envelope.documentMeta.qrSignatureEnabled).toBe(true);
expect(envelope.documentMeta.emailSettings).toMatchObject(DB_EXPECTED_VALUES.emailSettings);
const authOptions = parseAuthOptions(envelope.authOptions);
@@ -68,6 +68,7 @@ test('[ORGANISATIONS]: manage document preferences', async ({ page }) => {
expect(teamSettings.typedSignatureEnabled).toEqual(true);
expect(teamSettings.uploadSignatureEnabled).toEqual(false);
expect(teamSettings.drawSignatureEnabled).toEqual(false);
expect(teamSettings.qrSignatureEnabled).toEqual(true);
// Edit the team settings
await page.goto(`/t/${team.url}/settings/document`);
@@ -102,6 +103,7 @@ test('[ORGANISATIONS]: manage document preferences', async ({ page }) => {
expect(updatedTeamSettings.typedSignatureEnabled).toEqual(true);
expect(updatedTeamSettings.uploadSignatureEnabled).toEqual(false);
expect(updatedTeamSettings.drawSignatureEnabled).toEqual(false);
expect(updatedTeamSettings.qrSignatureEnabled).toEqual(true);
const document = await seedTeamDocumentWithMeta(team);
@@ -117,6 +119,7 @@ test('[ORGANISATIONS]: manage document preferences', async ({ page }) => {
expect(documentMeta.typedSignatureEnabled).toEqual(true);
expect(documentMeta.uploadSignatureEnabled).toEqual(false);
expect(documentMeta.drawSignatureEnabled).toEqual(false);
expect(documentMeta.qrSignatureEnabled).toEqual(true);
expect(documentMeta.language).toEqual('pl');
expect(documentMeta.timezone).toEqual('Europe/London');
expect(documentMeta.dateFormat).toEqual('MM/dd/yyyy');
@@ -0,0 +1,220 @@
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
import { prisma } from '@documenso/prisma';
import { AnonymousVerificationTokenType, FieldType } from '@documenso/prisma/client';
import { seedPendingDocumentWithFullFields } from '@documenso/prisma/seed/documents';
import { seedUser } from '@documenso/prisma/seed/users';
import type { Page } from '@playwright/test';
import { expect, test } from '@playwright/test';
test.describe.configure({ mode: 'parallel' });
/**
* Draw a zig-zag onto the drawing canvas so that it passes the minimum
* signature coverage threshold.
*/
const drawOnSignaturePad = async (page: Page) => {
const canvas = page.getByTestId('signature-pad-draw');
await canvas.waitFor({ state: 'visible' });
let capturedBox: { x: number; y: number; width: number; height: number } | null = null;
// `boundingBox()` can return null if the canvas is replaced mid-hydration,
// so poll until a measurable element is attached, capturing the box inside
// the retry closure so it is never re-fetched (and re-raced) afterwards.
await expect(async () => {
capturedBox = await canvas.boundingBox();
expect(capturedBox).not.toBeNull();
expect(capturedBox?.width ?? 0).toBeGreaterThan(0);
}).toPass({ timeout: 5_000 });
// TS cannot see the closure assignment above, so widen the type back out.
const box = capturedBox as { x: number; y: number; width: number; height: number } | null;
if (!box) {
throw new Error('Signature pad canvas not found');
}
await page.mouse.move(box.x + box.width * 0.15, box.y + box.height * 0.5);
await page.mouse.down();
for (let i = 0; i < 8; i++) {
await page.mouse.move(box.x + box.width * (0.15 + i * 0.09), box.y + box.height * (i % 2 === 0 ? 0.25 : 0.75), {
steps: 10,
});
}
await page.mouse.up();
};
test('[QR_SIGNATURE]: complete signing via mobile qr handoff', async ({ page, browser }) => {
const { user, team } = await seedUser();
const { recipients } = await seedPendingDocumentWithFullFields({
owner: user,
recipients: ['qr-signer@test.documenso.com'],
teamId: team.id,
fields: [FieldType.SIGNATURE],
});
const recipient = recipients[0];
await page.goto(`/sign/${recipient.token}`);
// Wait for the client-side PDF render so we know the page has hydrated
// before interacting with the signature pad.
await page.waitForSelector(PDF_VIEWER_PAGE_SELECTOR);
// Open the signature dialog and switch to the Mobile tab.
await page.getByTestId('signature-pad-dialog-button').click();
await page.getByRole('tab', { name: 'Mobile' }).click();
// Read the handoff URL rendered beneath the QR code.
await expect(page.getByTestId('signature-pad-qr-url')).toBeVisible();
const handoffUrl = await page.getByTestId('signature-pad-qr-url').textContent();
expect(handoffUrl).toContain('/mobile-signature/');
// Open the mobile page in a fully isolated browser context (no shared
// cookies or session) to prove the handoff requires no authentication.
// A realistic landscape-phone viewport: the pad sizes itself dynamically to
// the viewport, and the primitive's minimum-coverage check is a percentage
// of the canvas area - a desktop-sized context would demand far more ink
// than the drawn zigzag provides.
const mobileContext = await browser.newContext({ viewport: { width: 844, height: 390 } });
const mobilePage = await mobileContext.newPage();
await mobilePage.goto(handoffUrl ?? '');
// The phone page renders the signing card (landscape layout at the default
// test viewport) with Next disabled until a valid signature is drawn.
await expect(mobilePage.getByTestId('signature-pad-draw')).toBeVisible();
await expect(mobilePage.getByRole('button', { name: 'Next' })).toBeDisabled();
await drawOnSignaturePad(mobilePage);
await mobilePage.getByRole('button', { name: 'Next' }).click();
await expect(mobilePage.getByText('Success')).toBeVisible();
await mobileContext.close();
// The desktop pad should receive the signature within a poll interval.
await expect(page.getByTestId('signature-pad-qr-preview')).toBeVisible({ timeout: 10_000 });
// The session is single-use: the desktop pickup deletes the row on read, and
// a missing row is indistinguishable from an expired one by design. So a
// revisit must show the expired page (not "Signature already sent"), which
// proves the deletion happened.
const revisitContext = await browser.newContext();
const revisitPage = await revisitContext.newPage();
await revisitPage.goto(handoffUrl ?? '');
await expect(revisitPage.getByRole('heading', { name: 'This link has expired' })).toBeVisible();
await revisitContext.close();
// Direct proof of consumption: the token row must be gone from the database.
const consumedToken = (handoffUrl ?? '').split('/mobile-signature/')[1];
const consumedRow = await prisma.anonymousVerificationToken.findFirst({
where: { token: consumedToken },
});
expect(consumedRow).toBeNull();
// Confirm and finish signing the document.
await page.getByRole('button', { name: 'Next' }).click();
await page.locator('[data-field-type="SIGNATURE"]:not([data-readonly="true"])').first().click();
await page.getByRole('button', { name: 'Complete' }).click();
await page.getByRole('button', { name: 'Sign' }).click();
await page.waitForURL(`/sign/${recipient.token}/complete`);
await expect(page.getByText('Document Signed')).toBeVisible();
});
test('[QR_SIGNATURE]: mobile tab hidden when qr disabled', async ({ page }) => {
const { user, team } = await seedUser();
const { document, recipients } = await seedPendingDocumentWithFullFields({
owner: user,
recipients: ['qr-disabled-signer@test.documenso.com'],
teamId: team.id,
fields: [FieldType.SIGNATURE],
});
// Seeded documents create their meta row with bare column defaults, which
// leave qrSignatureEnabled true, so disable it directly on the meta row.
await prisma.documentMeta.update({
where: { id: document.documentMetaId },
data: { qrSignatureEnabled: false },
});
const recipient = recipients[0];
await page.goto(`/sign/${recipient.token}`);
await page.waitForSelector(PDF_VIEWER_PAGE_SELECTOR);
await page.getByTestId('signature-pad-dialog-button').click();
// Waiting on the Draw tab first guarantees the tab list has rendered before
// asserting the Mobile tab is absent.
await expect(page.getByRole('tab', { name: 'Draw' })).toBeVisible();
await expect(page.getByRole('tab', { name: 'Mobile' })).not.toBeVisible();
});
test('[QR_SIGNATURE]: mobile tab shown when draw disabled but qr enabled', async ({ page }) => {
const { user, team } = await seedUser();
const { document, recipients } = await seedPendingDocumentWithFullFields({
owner: user,
recipients: ['qr-only-signer@test.documenso.com'],
teamId: team.id,
fields: [FieldType.SIGNATURE],
});
// qrSignatureEnabled already defaults to true on seeded metas, but set it
// explicitly so the test still documents the required state if defaults change.
await prisma.documentMeta.update({
where: { id: document.documentMetaId },
data: { drawSignatureEnabled: false, qrSignatureEnabled: true },
});
const recipient = recipients[0];
await page.goto(`/sign/${recipient.token}`);
await page.waitForSelector(PDF_VIEWER_PAGE_SELECTOR);
await page.getByTestId('signature-pad-dialog-button').click();
await expect(page.getByRole('tab', { name: 'Mobile' })).toBeVisible();
await expect(page.getByRole('tab', { name: 'Draw' })).not.toBeVisible();
});
test('[QR_SIGNATURE]: unknown token shows expired page', async ({ page }) => {
await page.goto('/mobile-signature/this-token-does-not-exist');
await expect(page.getByRole('heading', { name: 'This link has expired' })).toBeVisible();
});
test('[QR_SIGNATURE]: expired token shows expired page', async ({ page }) => {
const expiredToken = `qr-e2e-expired-${Date.now()}-${Math.floor(Math.random() * 100000)}`;
await prisma.anonymousVerificationToken.create({
data: {
type: AnonymousVerificationTokenType.QR_SIGNATURE,
token: expiredToken,
expiresAt: new Date(Date.now() - 60_000),
},
});
await page.goto(`/mobile-signature/${expiredToken}`);
await expect(page.getByRole('heading', { name: 'This link has expired' })).toBeVisible();
});
@@ -25,6 +25,7 @@ test('[TEAMS]: check that default team signature settings are all enabled', asyn
await expect(page.getByRole('combobox').filter({ hasText: 'Type' })).toBeVisible();
await expect(page.getByRole('combobox').filter({ hasText: 'Upload' })).toBeVisible();
await expect(page.getByRole('combobox').filter({ hasText: 'Draw' })).toBeVisible();
await expect(page.getByRole('combobox').filter({ hasText: 'QR code' })).toBeVisible();
// Go to document and check that the signatured tabs are correct.
await page.goto(`/sign/${document.recipients[0].token}`);
@@ -34,6 +35,7 @@ test('[TEAMS]: check that default team signature settings are all enabled', asyn
await expect(page.getByRole('tab', { name: 'Type' })).toBeVisible();
await expect(page.getByRole('tab', { name: 'Upload' })).toBeVisible();
await expect(page.getByRole('tab', { name: 'Draw' })).toBeVisible();
await expect(page.getByRole('tab', { name: 'Mobile' })).toBeVisible();
});
test('[TEAMS]: check signature modes can be disabled', async ({ page }) => {
@@ -45,8 +47,11 @@ test('[TEAMS]: check signature modes can be disabled', async ({ page }) => {
redirectPath: `/t/${team.url}/settings/document`,
});
const allTabs = ['Type', 'Upload', 'Draw'];
const tabTest = [['Type', 'Upload', 'Draw'], ['Type', 'Upload'], ['Type']];
// The 'QR code' signature type is surfaced as the 'Mobile' tab on the signing dialog.
const allSignatureOptions = ['Type', 'Upload', 'Draw', 'QR code'];
const tabNameForOption = (option: string) => (option === 'QR code' ? 'Mobile' : option);
const tabTest = [['Type', 'Upload', 'Draw', 'QR code'], ['Type', 'Upload'], ['Type']];
for (const tabs of tabTest) {
await page.goto(`/t/${team.url}/settings/document`);
@@ -57,9 +62,10 @@ test('[TEAMS]: check signature modes can be disabled', async ({ page }) => {
await expect(page.getByRole('option', { name: 'Type' })).toBeVisible();
await expect(page.getByRole('option', { name: 'Upload' })).toBeVisible();
await expect(page.getByRole('option', { name: 'Draw' })).toBeVisible();
await expect(page.getByRole('option', { name: 'QR code' })).toBeVisible();
// Clear all selected items.
for (const tab of allTabs) {
for (const tab of allSignatureOptions) {
const item = page.getByRole('option', { name: tab });
const isSelected = (await item.innerHTML()).includes('opacity-100');
@@ -90,12 +96,13 @@ test('[TEAMS]: check signature modes can be disabled', async ({ page }) => {
await page.waitForSelector('[role="dialog"]');
// Check the tab values
for (const tab of allTabs) {
if (tabs.includes(tab)) {
await expect(page.getByRole('tab', { name: tab })).toBeVisible();
for (const option of allSignatureOptions) {
const tabName = tabNameForOption(option);
if (tabs.includes(option)) {
await expect(page.getByRole('tab', { name: tabName })).toBeVisible();
} else {
// await expect(page.getByRole('tab', { name: tab })).not.toBeVisible();
await expect(page.getByRole('tab', { name: tab })).toHaveCount(0);
await expect(page.getByRole('tab', { name: tabName })).toHaveCount(0);
}
}
}
@@ -110,8 +117,8 @@ test('[TEAMS]: check signature modes work for templates', async ({ page }) => {
redirectPath: `/t/${team.url}/settings/document`,
});
const allTabs = ['Type', 'Upload', 'Draw'];
const tabTest = [['Type', 'Upload', 'Draw'], ['Type', 'Upload'], ['Type']];
const allSignatureOptions = ['Type', 'Upload', 'Draw', 'QR code'];
const tabTest = [['Type', 'Upload', 'Draw', 'QR code'], ['Type', 'Upload'], ['Type']];
for (const tabs of tabTest) {
await page.goto(`/t/${team.url}/settings/document`);
@@ -122,9 +129,10 @@ test('[TEAMS]: check signature modes work for templates', async ({ page }) => {
await expect(page.getByRole('option', { name: 'Type' })).toBeVisible();
await expect(page.getByRole('option', { name: 'Upload' })).toBeVisible();
await expect(page.getByRole('option', { name: 'Draw' })).toBeVisible();
await expect(page.getByRole('option', { name: 'QR code' })).toBeVisible();
// Clear all selected items.
for (const tab of allTabs) {
for (const tab of allSignatureOptions) {
const item = page.getByRole('option', { name: tab });
const isSelected = (await item.innerHTML()).includes('opacity-100');
@@ -176,5 +184,6 @@ test('[TEAMS]: check signature modes work for templates', async ({ page }) => {
expect(document?.documentMeta?.typedSignatureEnabled).toEqual(tabs.includes('Type'));
expect(document?.documentMeta?.uploadSignatureEnabled).toEqual(tabs.includes('Upload'));
expect(document?.documentMeta?.drawSignatureEnabled).toEqual(tabs.includes('Draw'));
expect(document?.documentMeta?.qrSignatureEnabled).toEqual(tabs.includes('QR code'));
}
});
@@ -152,6 +152,7 @@ test.describe('AutoSave Settings Step - Templates', () => {
expect(retrievedTemplate.templateMeta?.drawSignatureEnabled).toBe(false);
expect(retrievedTemplate.templateMeta?.typedSignatureEnabled).toBe(false);
expect(retrievedTemplate.templateMeta?.uploadSignatureEnabled).toBe(true);
expect(retrievedTemplate.templateMeta?.qrSignatureEnabled).toBe(true);
}).toPass();
});
+7
View File
@@ -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
View File
@@ -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;
+2
View File
@@ -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,
+6
View File
@@ -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(),
+1
View File
@@ -62,6 +62,7 @@ export const ZDocumentSchema = LegacyDocumentSchema.pick({
typedSignatureEnabled: true,
uploadSignatureEnabled: true,
drawSignatureEnabled: true,
qrSignatureEnabled: true,
allowDictateNextSigner: true,
language: true,
emailSettings: true,
+1
View File
@@ -279,6 +279,7 @@ export const ZEditorEnvelopeSchema = EnvelopeSchema.pick({
typedSignatureEnabled: true,
uploadSignatureEnabled: true,
drawSignatureEnabled: true,
qrSignatureEnabled: true,
allowDictateNextSigner: true,
language: true,
emailSettings: true,
+1
View File
@@ -49,6 +49,7 @@ export const ZEnvelopeSchema = EnvelopeSchema.pick({
typedSignatureEnabled: true,
uploadSignatureEnabled: true,
drawSignatureEnabled: true,
qrSignatureEnabled: true,
allowDictateNextSigner: true,
language: true,
emailSettings: true,
+23
View File
@@ -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'];
+1
View File
@@ -54,6 +54,7 @@ export const ZTemplateSchema = TemplateSchema.pick({
typedSignatureEnabled: true,
uploadSignatureEnabled: true,
drawSignatureEnabled: true,
qrSignatureEnabled: true,
allowDictateNextSigner: true,
distributionMethod: true,
redirectUrl: true,
+1
View File
@@ -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(),
+1
View File
@@ -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,
+1
View File
@@ -119,6 +119,7 @@ export const generateDefaultOrganisationSettings = (): Omit<OrganisationGlobalSe
typedSignatureEnabled: true,
uploadSignatureEnabled: true,
drawSignatureEnabled: true,
qrSignatureEnabled: true,
brandingEnabled: false,
brandingLogo: '',
+13 -1
View File
@@ -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,
@@ -0,0 +1,15 @@
-- CreateEnum
CREATE TYPE "AnonymousVerificationTokenType" AS ENUM ('PASSKEY', 'QR_SIGNATURE');
-- AlterTable: add "type" as nullable, backfill existing rows (all are passkey
-- challenges today), then enforce NOT NULL.
ALTER TABLE "AnonymousVerificationToken" ADD COLUMN "type" "AnonymousVerificationTokenType";
UPDATE "AnonymousVerificationToken" SET "type" = 'PASSKEY';
ALTER TABLE "AnonymousVerificationToken" ALTER COLUMN "type" SET NOT NULL;
-- AlterTable
ALTER TABLE "AnonymousVerificationToken" ADD COLUMN "value" TEXT;
ALTER TABLE "AnonymousVerificationToken" ADD COLUMN "metadata" JSONB;
@@ -0,0 +1,10 @@
-- AlterTable: add with DEFAULT false so every existing row is backfilled to
-- disabled, then flip the column default to true so new rows are enabled.
ALTER TABLE "DocumentMeta" ADD COLUMN "qrSignatureEnabled" BOOLEAN NOT NULL DEFAULT false;
ALTER TABLE "DocumentMeta" ALTER COLUMN "qrSignatureEnabled" SET DEFAULT true;
ALTER TABLE "OrganisationGlobalSettings" ADD COLUMN "qrSignatureEnabled" BOOLEAN NOT NULL DEFAULT false;
ALTER TABLE "OrganisationGlobalSettings" ALTER COLUMN "qrSignatureEnabled" SET DEFAULT true;
-- Existing teams stay NULL (inherit from organisation).
ALTER TABLE "TeamGlobalSettings" ADD COLUMN "qrSignatureEnabled" BOOLEAN;
+14 -2
View File
@@ -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)
@@ -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(),
+2
View File
@@ -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(),
})
+1
View File
@@ -50,6 +50,7 @@
"tailwind-merge": "^1.14.0",
"tailwindcss-animate": "^1.0.7",
"ts-pattern": "^5.9.0",
"uqr": "^0.1.2",
"zod": "^3.25.76"
}
}
+14 -3
View File
@@ -77,9 +77,20 @@ export class Point implements PointLike {
let x = Math.min(Math.max(left, clientX), right) - left;
let y = Math.min(Math.max(top, clientY), bottom) - top;
// adjust for DPI
x *= dpi;
y *= dpi;
// Adjust for DPI. Canvas bitmaps are sized once at mount, so if the element
// has been resized since (fluid container, device rotation) the nominal dpi
// no longer matches reality — use the actual bitmap / CSS box ratio so the
// ink always lands under the pointer.
let scaleX = dpi;
let scaleY = dpi;
if (target instanceof HTMLCanvasElement && right - left > 0 && bottom - top > 0) {
scaleX = target.width / (right - left);
scaleY = target.height / (bottom - top);
}
x *= scaleX;
y *= scaleY;
return new Point(x, y);
}
@@ -1,3 +1,4 @@
import type { TQrSignatureContext } from '@documenso/lib/types/qr-signature';
import { parseMessageDescriptor } from '@documenso/lib/utils/i18n';
import { Dialog, DialogClose, DialogContent, DialogFooter } from '@documenso/ui/primitives/dialog';
@@ -22,6 +23,8 @@ export type SignaturePadDialogProps = Omit<HTMLAttributes<HTMLCanvasElement>, 'o
typedSignatureEnabled?: boolean;
uploadSignatureEnabled?: boolean;
drawSignatureEnabled?: boolean;
qrSignatureEnabled?: boolean;
qrSignatureContext?: TQrSignatureContext;
};
export const SignaturePadDialog = ({
@@ -34,6 +37,8 @@ export const SignaturePadDialog = ({
typedSignatureEnabled,
uploadSignatureEnabled,
drawSignatureEnabled,
qrSignatureEnabled,
qrSignatureContext,
dialogConfirmText,
}: SignaturePadDialogProps) => {
const { i18n } = useLingui();
@@ -121,6 +126,8 @@ export const SignaturePadDialog = ({
typedSignatureEnabled={typedSignatureEnabled}
uploadSignatureEnabled={uploadSignatureEnabled}
drawSignatureEnabled={drawSignatureEnabled}
qrSignatureEnabled={qrSignatureEnabled}
qrSignatureContext={qrSignatureContext}
/>
<DialogFooter>
@@ -260,14 +260,14 @@ export const SignaturePadDraw = ({ className, value, onChange, ...props }: Signa
});
return (
<div className={cn('h-full w-full', className)}>
<div className={cn('h-full w-full select-none', className)}>
<canvas
data-testid="signature-pad-draw"
ref={$el}
className={cn('h-full w-full', {
'dark:hue-rotate-180 dark:invert': selectedColor === 'black',
})}
style={{ touchAction: 'none' }}
style={{ touchAction: 'none', WebkitTouchCallout: 'none' }}
onPointerMove={(event) => onMouseMove(event)}
onPointerDown={(event) => onMouseDown(event)}
onPointerUp={(event) => onMouseUp(event)}
@@ -0,0 +1,277 @@
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { SIGNATURE_CANVAS_DPI } from '@documenso/lib/constants/signatures';
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
import type { TQrSignatureContext } from '@documenso/lib/types/qr-signature';
import { trpc } from '@documenso/trpc/react';
import { Trans, useLingui } from '@lingui/react/macro';
import { Loader2Icon, RefreshCwIcon } from 'lucide-react';
import { useEffect, useRef, useState } from 'react';
import { renderSVG } from 'uqr';
import { cn } from '../../lib/utils';
import { Button } from '../button';
import { SignatureRender } from './signature-render';
export type QrSignatureSession = {
token: string;
expiresAt: Date;
};
/**
* Redraw a signature onto a canvas of the given size, scaled to fit and
* centered.
*
* The phone pad's canvas has different dimensions to the local draw pad, and
* the draw pad renders its value at natural size without scaling - committing
* the phone's PNG directly would make it render smaller (or larger) than the
* preview. Normalising to the local pad's dimensions keeps every consumer of
* the value untouched.
*/
const normalizeSignatureSize = async (dataUrl: string, targetWidth: number, targetHeight: number): Promise<string> =>
new Promise((resolve) => {
const img = new Image();
img.onload = () => {
const canvas = document.createElement('canvas');
canvas.width = targetWidth;
canvas.height = targetHeight;
const ctx = canvas.getContext('2d');
if (!ctx) {
resolve(dataUrl);
return;
}
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = 'high';
const scale = Math.min(targetWidth / img.width, targetHeight / img.height);
const scaledWidth = img.width * scale;
const scaledHeight = img.height * scale;
ctx.drawImage(img, (targetWidth - scaledWidth) / 2, (targetHeight - scaledHeight) / 2, scaledWidth, scaledHeight);
resolve(canvas.toDataURL());
};
img.onerror = () => resolve(dataUrl);
img.src = dataUrl;
});
export type SignaturePadQrProps = {
className?: string;
value: string;
onChange: (_signatureDataUrl: string) => void;
session: QrSignatureSession | null;
onSessionChange: (_session: QrSignatureSession | null) => void;
/**
* What the handoff signature is for. Rendered on the mobile signing page so
* the signer can see the context of what they are signing. When omitted the
* mobile page shows a generic "Signature requested".
*/
context?: TQrSignatureContext;
};
/**
* The "Mobile" tab of the signature pad.
*
* Displays a QR code linking to a public mobile drawing page, then polls until
* the phone submits a signature. The received signature is committed as a
* drawn (base64 PNG) signature via `onChange`.
*
* The session lives in the parent so that switching tabs does not invalidate
* an in-flight handoff (tab contents unmount when inactive).
*/
export const SignaturePadQr = ({
className,
value,
onChange,
session,
onSessionChange,
context,
}: SignaturePadQrProps) => {
const { t } = useLingui();
const hasFiredCreateRef = useRef(false);
const $container = useRef<HTMLDivElement>(null);
// Only show the preview for a signature received during this mount - a value
// drawn on another tab renders the QR code so the handoff stays available
// without destroying the committed signature.
const [hasReceivedSignature, setHasReceivedSignature] = useState(false);
const { mutate: createQrSignatureSession, isError: isCreateSessionError } = trpc.signature.qr.create.useMutation({
...DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
onSuccess: (data) => {
onSessionChange(data);
},
});
const { data: qrSignatureData } = trpc.signature.qr.get.useQuery(
{
token: session?.token ?? '',
},
{
enabled: Boolean(session),
refetchInterval: (query) =>
query.state.data?.status === 'COMPLETED' || query.state.data?.status === 'EXPIRED' ? false : 2500,
},
);
useEffect(() => {
if (!session && !hasFiredCreateRef.current) {
hasFiredCreateRef.current = true;
createQrSignatureSession({ context });
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
if (qrSignatureData?.status !== 'COMPLETED') {
return;
}
// The tab container has the same box as the draw tab, so its measured size
// matches the draw pad's canvas dimensions.
const container = $container.current;
const targetWidth = container ? Math.round(container.clientWidth * SIGNATURE_CANVAS_DPI) : 0;
const targetHeight = container ? Math.round(container.clientHeight * SIGNATURE_CANVAS_DPI) : 0;
if (targetWidth <= 0 || targetHeight <= 0) {
onChange(qrSignatureData.signature);
onSessionChange(null);
setHasReceivedSignature(true);
return;
}
let isCancelled = false;
void normalizeSignatureSize(qrSignatureData.signature, targetWidth, targetHeight).then((normalizedSignature) => {
if (!isCancelled) {
onChange(normalizedSignature);
onSessionChange(null);
setHasReceivedSignature(true);
}
});
return () => {
isCancelled = true;
};
// Note: `onChange`/`onSessionChange` are fresh closures from the parent each
// render, so including them would re-fire this effect spuriously.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [qrSignatureData]);
const onGenerateNewCodeClick = () => {
onSessionChange(null);
createQrSignatureSession({ context });
};
const onScanAgainClick = () => {
setHasReceivedSignature(false);
onSessionChange(null);
createQrSignatureSession({ context });
};
// Only a signature received via this tab shows the preview; any other
// value keeps the QR available.
if (value && hasReceivedSignature) {
return (
<div
data-testid="signature-pad-qr-preview"
className={cn('relative flex h-full w-full flex-col items-center justify-center', className)}
>
<SignatureRender value={value} className="h-full w-full" />
<div className="absolute right-3 bottom-3">
<button
type="button"
className="flex items-center gap-1 rounded-full p-0 text-[0.688rem] text-muted-foreground/60 ring-offset-background hover:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
onClick={() => onScanAgainClick()}
>
<RefreshCwIcon className="size-3" />
<Trans>Scan again</Trans>
</button>
</div>
</div>
);
}
if (isCreateSessionError || qrSignatureData?.status === 'EXPIRED') {
return (
<div className={cn('flex h-full w-full flex-col items-center justify-center gap-2', className)}>
<p className="text-muted-foreground text-sm">
{isCreateSessionError ? (
<Trans>Something went wrong. Please try again.</Trans>
) : (
<Trans>This QR code has expired.</Trans>
)}
</p>
<Button type="button" variant="outline" size="sm" onClick={() => onGenerateNewCodeClick()}>
<RefreshCwIcon className="mr-2 size-4" />
<Trans>Generate new code</Trans>
</Button>
</div>
);
}
// Session is being created (or is about to be) - show the loader. This must
// never depend on the mutation's isPending, which can be stale after a
// StrictMode double-mount.
if (!session) {
return (
<div role="status" className={cn('flex h-full w-full items-center justify-center', className)}>
<Loader2Icon className="size-6 animate-spin text-muted-foreground" />
<span className="sr-only">
<Trans>Loading</Trans>
</span>
</div>
);
}
const mobileSigningUrl = `${NEXT_PUBLIC_WEBAPP_URL()}/mobile-signature/${session.token}`;
return (
<div
ref={$container}
data-testid="signature-pad-qr"
className={cn(
'flex h-full min-h-0 w-full flex-col items-center justify-center gap-2 overflow-hidden p-3',
className,
)}
>
{/* The QR absorbs whatever vertical space is left over, so the labels
below always keep their room and can never be pushed out of the pad. */}
<div className="flex min-h-0 flex-1 items-center justify-center">
<div
role="img"
aria-label={t`QR code for mobile signing`}
className="aspect-square h-full rounded-md bg-white p-1.5 [&>svg]:block [&>svg]:h-full [&>svg]:w-full"
// biome-ignore lint/security/noDangerouslySetInnerHtml: Expected usage to render QR.
dangerouslySetInnerHTML={{
__html: renderSVG(mobileSigningUrl),
}}
/>
</div>
<p className="shrink-0 text-muted-foreground text-xs">
<Trans>Scan with your phone to draw your signature</Trans>
</p>
<p
data-testid="signature-pad-qr-url"
className="w-full shrink-0 truncate px-2 text-center text-[0.688rem] text-muted-foreground/60"
>
{mobileSigningUrl}
</p>
</div>
);
};
@@ -1,15 +1,16 @@
import { DocumentSignatureType } from '@documenso/lib/constants/document';
import { isBase64Image } from '@documenso/lib/constants/signatures';
import type { TQrSignatureContext } from '@documenso/lib/types/qr-signature';
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 +30,8 @@ export type SignaturePadProps = Omit<HTMLAttributes<HTMLCanvasElement>, 'onChang
typedSignatureEnabled?: boolean;
uploadSignatureEnabled?: boolean;
drawSignatureEnabled?: boolean;
qrSignatureEnabled?: boolean;
qrSignatureContext?: TQrSignatureContext;
onValidityChange?: (isValid: boolean) => void;
};
@@ -41,11 +44,15 @@ export const SignaturePad = ({
typedSignatureEnabled = true,
uploadSignatureEnabled = true,
drawSignatureEnabled = true,
qrSignatureEnabled = true,
qrSignatureContext,
}: 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 +60,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 +87,10 @@ export const SignaturePad = ({
return 'image';
}
if (qrSignatureEnabled) {
return 'qr';
}
throw new Error('No signature enabled');
})(),
);
@@ -111,7 +122,7 @@ export const SignaturePad = ({
});
};
const onTabChange = (value: 'draw' | 'text' | 'image') => {
const onTabChange = (value: 'draw' | 'text' | 'image' | 'qr') => {
if (disabled) {
return;
}
@@ -119,7 +130,7 @@ export const SignaturePad = ({
setTab(value);
match(value)
.with('draw', () => {
.with(P.union('draw', 'qr'), () => {
onDrawSignatureChange(drawSignature);
})
.with('text', () => {
@@ -131,7 +142,7 @@ export const SignaturePad = ({
.exhaustive();
};
if (!drawSignatureEnabled && !typedSignatureEnabled && !uploadSignatureEnabled) {
if (!drawSignatureEnabled && !typedSignatureEnabled && !uploadSignatureEnabled && !qrSignatureEnabled) {
return null;
}
@@ -142,7 +153,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 +163,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 +192,19 @@ 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}
context={qrSignatureContext}
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"