From ba0dead96ff7010a9e954e43da585a5a37a78af2 Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Mon, 20 Jul 2026 15:57:38 +0900 Subject: [PATCH] fix: render error messages for invalid templates (#3088) Currently direct templates can be created without the required signatures fields for signers. This means that the document can be fully signed by everyone but will ultimately fail the sealing step which leaves the document in an unrecoverable state. --- .../direct-template-invalid-page.tsx | 23 ++++ .../envelope-editor-fields-page.tsx | 3 + ...e-editor-invalid-direct-template-alert.tsx | 55 ++++++++ .../envelope-editor-preview-page.tsx | 3 + .../envelope-editor-upload-page.tsx | 4 + .../routes/_recipient+/d.$token+/_index.tsx | 28 ++++ apps/remix/app/utils/toast-error-messages.ts | 8 ++ ...nvelope-direct-template-validation.spec.ts | 76 +++++++++++ .../public-profiles/public-profiles.spec.ts | 12 ++ .../e2e/templates/direct-templates.spec.ts | 125 +++++++++++++++--- .../e2e/templates/template-use-dialog.spec.ts | 101 ++++++++++++++ packages/lib/errors/app-error.ts | 9 ++ .../lib/server-only/document/send-document.ts | 2 +- ...et-envelope-for-direct-template-signing.ts | 12 ++ .../create-document-from-direct-template.ts | 12 ++ packages/prisma/seed/initial-seed.ts | 6 +- packages/prisma/seed/templates.ts | 38 +++++- 17 files changed, 490 insertions(+), 27 deletions(-) create mode 100644 apps/remix/app/components/general/direct-template/direct-template-invalid-page.tsx create mode 100644 apps/remix/app/components/general/envelope-editor/envelope-editor-invalid-direct-template-alert.tsx create mode 100644 packages/app-tests/e2e/envelope-editor-v2/envelope-direct-template-validation.spec.ts create mode 100644 packages/app-tests/e2e/templates/template-use-dialog.spec.ts diff --git a/apps/remix/app/components/general/direct-template/direct-template-invalid-page.tsx b/apps/remix/app/components/general/direct-template/direct-template-invalid-page.tsx new file mode 100644 index 000000000..c95b4109f --- /dev/null +++ b/apps/remix/app/components/general/direct-template/direct-template-invalid-page.tsx @@ -0,0 +1,23 @@ +import { Trans } from '@lingui/react/macro'; +import { AlertTriangleIcon } from 'lucide-react'; + +export const DirectTemplateInvalidPageView = () => { + return ( +
+
+ + +

+ Invalid direct link template +

+ +

+ + This direct link template cannot be used because one or more signers do not have a signature field assigned. + Please contact the sender to update the template. + +

+
+
+ ); +}; diff --git a/apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page.tsx b/apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page.tsx index 945d3c308..a6146156a 100644 --- a/apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page.tsx +++ b/apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page.tsx @@ -54,6 +54,7 @@ import { useCurrentTeam } from '~/providers/team'; import { EnvelopeEditorFieldDragDrop } from './envelope-editor-fields-drag-drop'; import { EnvelopeEditorFieldsPageRenderer } from './envelope-editor-fields-page-renderer'; +import { EnvelopeEditorInvalidDirectTemplateAlert } from './envelope-editor-invalid-direct-template-alert'; import { EnvelopeRendererFileSelector } from './envelope-file-selector'; import { EnvelopeRecipientSelector } from './envelope-recipient-selector'; @@ -238,6 +239,8 @@ export const EnvelopeEditorFieldsPage = () => { } /> + + {/* Document View */}
{envelope.recipients.length === 0 && ( diff --git a/apps/remix/app/components/general/envelope-editor/envelope-editor-invalid-direct-template-alert.tsx b/apps/remix/app/components/general/envelope-editor/envelope-editor-invalid-direct-template-alert.tsx new file mode 100644 index 000000000..153db0a91 --- /dev/null +++ b/apps/remix/app/components/general/envelope-editor/envelope-editor-invalid-direct-template-alert.tsx @@ -0,0 +1,55 @@ +import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider'; +import { getRecipientsWithMissingFields } from '@documenso/lib/utils/recipients'; +import { cn } from '@documenso/ui/lib/utils'; +import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert'; +import { Trans } from '@lingui/react/macro'; +import { useMemo } from 'react'; + +export type EnvelopeEditorInvalidDirectTemplateAlertProps = { + className?: string; +}; + +/** + * Warns that a direct link template cannot be used because one or more signers + * are missing a signature field. + */ +export const EnvelopeEditorInvalidDirectTemplateAlert = ({ + className, +}: EnvelopeEditorInvalidDirectTemplateAlertProps) => { + const { envelope, isTemplate } = useCurrentEnvelopeEditor(); + + const signersMissingSignatureFields = useMemo(() => { + if (!isTemplate || !envelope.directLink?.enabled) { + return []; + } + + return getRecipientsWithMissingFields(envelope.recipients, envelope.fields); + }, [isTemplate, envelope.directLink, envelope.recipients, envelope.fields]); + + if (signersMissingSignatureFields.length === 0) { + return null; + } + + return ( + + + Invalid direct link template + + + + + Recipients cannot use this direct link template because the following signers are missing a signature field + + +
    + {signersMissingSignatureFields.map((recipient, i) => ( +
  • {recipient.email || recipient.name || `Recipient ${i + 1}`}
  • + ))} +
+
+
+ ); +}; diff --git a/apps/remix/app/components/general/envelope-editor/envelope-editor-preview-page.tsx b/apps/remix/app/components/general/envelope-editor/envelope-editor-preview-page.tsx index 42144b6f8..df5339360 100644 --- a/apps/remix/app/components/general/envelope-editor/envelope-editor-preview-page.tsx +++ b/apps/remix/app/components/general/envelope-editor/envelope-editor-preview-page.tsx @@ -22,6 +22,7 @@ import { match } from 'ts-pattern'; import { EnvelopeGenericPageRenderer } from '~/components/general/envelope-editor/envelope-generic-page-renderer'; import { EnvelopePdfViewer } from '~/components/general/pdf-viewer/envelope-pdf-viewer'; +import { EnvelopeEditorInvalidDirectTemplateAlert } from './envelope-editor-invalid-direct-template-alert'; import { EnvelopeRendererFileSelector } from './envelope-file-selector'; export const EnvelopeEditorPreviewPage = () => { @@ -228,6 +229,8 @@ export const EnvelopeEditorPreviewPage = () => { {/* Horizontal envelope item selector */} + + Preview Mode diff --git a/apps/remix/app/components/general/envelope-editor/envelope-editor-upload-page.tsx b/apps/remix/app/components/general/envelope-editor/envelope-editor-upload-page.tsx index 6e8c64345..c6fb38938 100644 --- a/apps/remix/app/components/general/envelope-editor/envelope-editor-upload-page.tsx +++ b/apps/remix/app/components/general/envelope-editor/envelope-editor-upload-page.tsx @@ -26,6 +26,7 @@ import { ErrorCode as DropzoneErrorCode, type FileRejection, useDropzone } from import { EnvelopeItemDeleteDialog } from '~/components/dialogs/envelope-item-delete-dialog'; +import { EnvelopeEditorInvalidDirectTemplateAlert } from './envelope-editor-invalid-direct-template-alert'; import { EnvelopeEditorRecipientForm } from './envelope-editor-recipient-form'; import { EnvelopeItemTitleInput } from './envelope-editor-title-input'; @@ -449,6 +450,9 @@ export const EnvelopeEditorUploadPage = () => { return (
+ + + diff --git a/apps/remix/app/routes/_recipient+/d.$token+/_index.tsx b/apps/remix/app/routes/_recipient+/d.$token+/_index.tsx index bd7b34383..dff005fe1 100644 --- a/apps/remix/app/routes/_recipient+/d.$token+/_index.tsx +++ b/apps/remix/app/routes/_recipient+/d.$token+/_index.tsx @@ -7,6 +7,7 @@ import { getEnvelopeForDirectTemplateSigning } from '@documenso/lib/server-only/ import { getTemplateByDirectLinkToken } from '@documenso/lib/server-only/template/get-template-by-direct-link-token'; import { DocumentAccessAuth } from '@documenso/lib/types/document-auth'; import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth'; +import { getRecipientsWithMissingFields } from '@documenso/lib/utils/recipients'; import { prisma } from '@documenso/prisma'; import { Plural } from '@lingui/react/macro'; import { UsersIcon } from 'lucide-react'; @@ -14,6 +15,7 @@ import { redirect } from 'react-router'; import { match } from 'ts-pattern'; import { Header as AuthenticatedHeader } from '~/components/general/app-header'; +import { DirectTemplateInvalidPageView } from '~/components/general/direct-template/direct-template-invalid-page'; import { DirectTemplatePageView } from '~/components/general/direct-template/direct-template-page'; import { DirectTemplateAuthPageView } from '~/components/general/direct-template/direct-template-signing-auth-page'; import { DocumentSigningAuthPageView } from '~/components/general/document-signing/document-signing-auth-page'; @@ -70,8 +72,18 @@ const handleV1Loader = async ({ params, request }: Route.LoaderArgs) => { }; } + const recipientsWithMissingFields = getRecipientsWithMissingFields(template.recipients, template.fields); + + if (recipientsWithMissingFields.length > 0) { + return { + isAccessAuthValid: true, + isTemplateMissingSignatures: true, + } as const; + } + return { isAccessAuthValid: true, + isTemplateMissingSignatures: false, template: { ...template, folder: null, @@ -96,6 +108,7 @@ const handleV2Loader = async ({ params, request }: Route.LoaderArgs) => { .then((envelopeForSigning) => { return { isDocumentAccessValid: true, + isTemplateMissingSignatures: false, envelopeForSigning, } as const; }) @@ -108,6 +121,13 @@ const handleV2Loader = async ({ params, request }: Route.LoaderArgs) => { } as const; } + if (error.code === AppErrorCode.MISSING_SIGNATURE_FIELD) { + return { + isDocumentAccessValid: true, + isTemplateMissingSignatures: true, + } as const; + } + throw new Response('Not Found', { status: 404 }); }); }; @@ -181,6 +201,10 @@ const DirectSigningPageV1 = ({ data }: { data: Awaited; } + if (data.isTemplateMissingSignatures) { + return ; + } + const { template, directTemplateRecipient } = data; return ( @@ -235,6 +259,10 @@ const DirectSigningPageV2 = ({ data }: { data: Awaited; } + if (data.isTemplateMissingSignatures) { + return ; + } + const { envelope, recipient } = data.envelopeForSigning; const { derivedRecipientAccessAuth } = extractDocumentAuthMethods({ diff --git a/apps/remix/app/utils/toast-error-messages.ts b/apps/remix/app/utils/toast-error-messages.ts index 0f2ce3031..462f42694 100644 --- a/apps/remix/app/utils/toast-error-messages.ts +++ b/apps/remix/app/utils/toast-error-messages.ts @@ -32,6 +32,10 @@ export const getDirectTemplateErrorMessage = (code: string): ToastMessageDescrip return match(code) .with('RECIPIENT_LIMIT_EXCEEDED', () => RECIPIENT_LIMIT_EXCEEDED_ERROR_MESSAGE) .with(AppErrorCode.TOO_MANY_REQUESTS, () => FAIR_USE_LIMIT_EXCEEDED_ERROR_MESSAGE) + .with(AppErrorCode.MISSING_SIGNATURE_FIELD, () => ({ + title: msg`Missing signature fields`, + description: msg`This direct link template cannot be used because one or more signers do not have a signature field assigned.`, + })) .otherwise(() => ({ title: msg`Something went wrong`, description: msg`We were unable to submit this document at this time. Please try again later.`, @@ -77,6 +81,10 @@ export const getTemplateUseErrorMessage = (code: string): ToastMessageDescriptor title: msg`Error`, description: msg`The document was created but could not be sent to recipients.`, })) + .with(AppErrorCode.MISSING_SIGNATURE_FIELD, () => ({ + title: msg`Missing signature fields`, + description: msg`The document could not be sent because some signers do not have a signature field. Please edit the template and add a signature field for each signer.`, + })) .with(AppErrorCode.INVALID_BODY, AppErrorCode.INVALID_REQUEST, () => ({ title: msg`Error`, description: msg`The document could not be created because of missing or invalid information. Please review the template's recipients and fields.`, diff --git a/packages/app-tests/e2e/envelope-editor-v2/envelope-direct-template-validation.spec.ts b/packages/app-tests/e2e/envelope-editor-v2/envelope-direct-template-validation.spec.ts new file mode 100644 index 000000000..36af4cad2 --- /dev/null +++ b/packages/app-tests/e2e/envelope-editor-v2/envelope-direct-template-validation.spec.ts @@ -0,0 +1,76 @@ +import { seedDirectTemplate } from '@documenso/prisma/seed/templates'; +import { seedUser } from '@documenso/prisma/seed/users'; +import { expect, type Page, test } from '@playwright/test'; + +import { apiSignin } from '../fixtures/authentication'; +import { clickEnvelopeEditorStep } from '../fixtures/envelope-editor'; + +const INVALID_DIRECT_TEMPLATE_ALERT_TITLE = 'Invalid direct link template'; + +/** + * Place a field on the PDF canvas in the envelope editor. + */ +const placeFieldOnPdf = async (root: Page, fieldName: 'Signature' | 'Text', position: { x: number; y: number }) => { + await root.getByRole('button', { name: fieldName, exact: true }).click(); + + const canvas = root.locator('.konva-container canvas').first(); + await expect(canvas).toBeVisible(); + await canvas.click({ position }); +}; + +/** + * Seed a V2 direct template and open it in the native template editor. + * + * Only the native template editor is covered here: direct links only exist + * for templates and are not part of the embedded editor surfaces. + */ +const openDirectTemplateEditor = async (page: Page, options: { createDirectRecipientSignatureField: boolean }) => { + const { user, team } = await seedUser(); + + const template = await seedDirectTemplate({ + title: `E2E Direct Template Validation ${Date.now()}`, + userId: user.id, + teamId: team.id, + internalVersion: 2, + createDirectRecipientSignatureField: options.createDirectRecipientSignatureField, + }); + + await apiSignin({ + page, + email: user.email, + redirectPath: `/t/${team.url}/templates/${template.id}/edit`, + }); + + return { user, team, template }; +}; + +test.describe('template editor', () => { + test('shows invalid direct template warning when a signer has no signature field', async ({ page }) => { + await openDirectTemplateEditor(page, { createDirectRecipientSignatureField: false }); + + await expect(page.getByText(INVALID_DIRECT_TEMPLATE_ALERT_TITLE)).toBeVisible(); + await expect(page.getByText('are missing a signature field')).toBeVisible(); + }); + + test('does not show the warning when all signers have signature fields', async ({ page }) => { + await openDirectTemplateEditor(page, { createDirectRecipientSignatureField: true }); + + // Wait for the editor to render before asserting the banner is absent. + await expect(page.getByTestId('envelope-editor-step-upload')).toBeVisible(); + await expect(page.getByText(INVALID_DIRECT_TEMPLATE_ALERT_TITLE)).not.toBeVisible(); + }); + + test('warning disappears after placing a signature field', async ({ page }) => { + await openDirectTemplateEditor(page, { createDirectRecipientSignatureField: false }); + + await expect(page.getByText(INVALID_DIRECT_TEMPLATE_ALERT_TITLE)).toBeVisible(); + + // Place a signature field for the direct recipient (auto-selected single recipient). + await clickEnvelopeEditorStep(page, 'addFields'); + await expect(page.locator('.konva-container canvas').first()).toBeVisible(); + await placeFieldOnPdf(page, 'Signature', { x: 120, y: 140 }); + + // The banner clears once the field is autosaved and the envelope state updates. + await expect(page.getByText(INVALID_DIRECT_TEMPLATE_ALERT_TITLE)).not.toBeVisible({ timeout: 15_000 }); + }); +}); diff --git a/packages/app-tests/e2e/public-profiles/public-profiles.spec.ts b/packages/app-tests/e2e/public-profiles/public-profiles.spec.ts index 414c18508..6f6ce998c 100644 --- a/packages/app-tests/e2e/public-profiles/public-profiles.spec.ts +++ b/packages/app-tests/e2e/public-profiles/public-profiles.spec.ts @@ -6,6 +6,7 @@ import { expect, test } from '@playwright/test'; import { apiSignin } from '../fixtures/authentication'; import { expectToastTextToBeVisible } from '../fixtures/generic'; +import { signSignaturePad } from '../fixtures/signature'; test('[PUBLIC_PROFILE]: create team profile', async ({ page }) => { const { user, team } = await seedUser(); @@ -73,8 +74,19 @@ test('[PUBLIC_PROFILE]: create team profile', async ({ page }) => { await expect(page.locator('body')).toContainText('public-direct-template-title'); await expect(page.locator('body')).toContainText('public-direct-template-description'); + const directSignatureField = directTemplate.fields[0]; + + if (!directSignatureField) { + throw new Error('Expected seeded direct template signature field to exist'); + } + await page.getByRole('link', { name: 'Sign' }).click(); await page.getByRole('button', { name: 'Continue' }).click(); + + await signSignaturePad(page); + await page.locator(`#field-${directSignatureField.id}`).getByRole('button').click(); + await expect(page.locator(`#field-${directSignatureField.id}`)).toHaveAttribute('data-inserted', 'true'); + await page.getByRole('button', { name: 'Complete' }).click(); await page.getByRole('button', { name: 'Sign' }).click(); diff --git a/packages/app-tests/e2e/templates/direct-templates.spec.ts b/packages/app-tests/e2e/templates/direct-templates.spec.ts index 69a4c9ad2..fbfa218c5 100644 --- a/packages/app-tests/e2e/templates/direct-templates.spec.ts +++ b/packages/app-tests/e2e/templates/direct-templates.spec.ts @@ -197,7 +197,18 @@ test('[DIRECT_TEMPLATES]: V1 direct template link auth access', async ({ page }) await expect(page.getByRole('heading', { name: 'General' })).toBeVisible(); await expect(page.getByLabel('Email')).toBeDisabled(); + const directSignatureField = directTemplateWithAuth.fields[0]; + + if (!directSignatureField) { + throw new Error('Expected seeded direct template signature field to exist'); + } + await page.getByRole('button', { name: 'Continue' }).click(); + + await signSignaturePad(page); + await page.locator(`#field-${directSignatureField.id}`).getByRole('button').click(); + await expect(page.locator(`#field-${directSignatureField.id}`)).toHaveAttribute('data-inserted', 'true'); + await page.getByRole('button', { name: 'Complete' }).click(); await page.getByRole('button', { name: 'Sign' }).click(); @@ -235,6 +246,37 @@ test('[DIRECT_TEMPLATES]: V2 direct template link auth access', async ({ page }) await page.goto(directTemplatePath); await expect(page.getByRole('heading', { name: 'Personal direct template link' })).toBeVisible(); + + const directSignatureField = directTemplateWithAuth.fields[0]; + + if (!directSignatureField) { + throw new Error('Expected seeded direct template signature field to exist'); + } + + // Wait for the PDF and the Konva canvas overlay to be ready. + await expect(page.locator('img[data-page-number]').first()).toBeVisible({ timeout: 30_000 }); + const canvas = page.locator('.konva-container canvas').first(); + await expect(canvas).toBeVisible({ timeout: 30_000 }); + + // Sign the direct template recipient's signature field via the canvas-based V2 UI. + await signSignaturePad(page); + + const canvasBox = await canvas.boundingBox(); + + if (!canvasBox) { + throw new Error('Canvas bounding box not found'); + } + + const x = + (Number(directSignatureField.positionX) / 100) * canvasBox.width + + ((Number(directSignatureField.width) / 100) * canvasBox.width) / 2; + const y = + (Number(directSignatureField.positionY) / 100) * canvasBox.height + + ((Number(directSignatureField.height) / 100) * canvasBox.height) / 2; + + await canvas.click({ position: { x, y } }); + await expect(page.getByText('0 Fields Remaining').first()).toBeVisible({ timeout: 10_000 }); + await page.getByRole('button', { name: 'Complete' }).click(); await expect(page.getByLabel('Your Email')).not.toBeVisible(); @@ -266,6 +308,16 @@ test('[DIRECT_TEMPLATES]: use direct template link with 1 recipient', async ({ p await expect(page.getByText('Next Recipient Name')).not.toBeVisible(); + const directSignatureField = template.fields[0]; + + if (!directSignatureField) { + throw new Error('Expected seeded direct template signature field to exist'); + } + + await signSignaturePad(page); + await page.locator(`#field-${directSignatureField.id}`).getByRole('button').click(); + await expect(page.locator(`#field-${directSignatureField.id}`)).toHaveAttribute('data-inserted', 'true'); + await page.getByRole('button', { name: 'Complete' }).click(); await page.getByRole('button', { name: 'Sign' }).click(); await page.waitForURL(/\/sign/); @@ -299,19 +351,13 @@ test('[DIRECT_TEMPLATES]: V1 use direct template link with 2 recipients with nex }, }); - const directTemplateRecipient = template.recipients[0]; + // The seeded direct template already includes a signature field for the direct recipient. + const directSignatureField = template.fields[0]; - if (!directTemplateRecipient) { - throw new Error('Expected direct template recipient to exist'); + if (!directSignatureField) { + throw new Error('Expected seeded direct template signature field to exist'); } - // All SIGNER recipients need a signature field for sendDocument to dispatch emails. - const directSignatureField = await seedSignatureFieldForRecipient({ - envelopeId: template.id, - recipientId: directTemplateRecipient.id, - positionY: 10, - }); - const originalName = 'Signer 2'; const originalSecondSignerEmail = seedTestEmail(); @@ -413,19 +459,13 @@ test('[DIRECT_TEMPLATES]: V2 use direct template link with 2 recipients with nex }, }); - const directTemplateRecipient = template.recipients[0]; + // The seeded direct template already includes a signature field for the direct recipient. + const directSignatureField = template.fields[0]; - if (!directTemplateRecipient) { - throw new Error('Expected direct template recipient to exist'); + if (!directSignatureField) { + throw new Error('Expected seeded direct template signature field to exist'); } - // All SIGNER recipients need a signature field for sendDocument to dispatch emails. - const directSignatureField = await seedSignatureFieldForRecipient({ - envelopeId: template.id, - recipientId: directTemplateRecipient.id, - positionY: 10, - }); - const originalName = 'Signer 2'; const originalSecondSignerEmail = seedTestEmail(); @@ -521,3 +561,48 @@ test('[DIRECT_TEMPLATES]: V2 use direct template link with 2 recipients with nex expect(updatedSecondRecipient.email).toBe(newSecondSignerEmail); await expectSigningRequestJobForRecipient(updatedSecondRecipient.id); }); + +test('[DIRECT_TEMPLATES]: V1 direct template without signature fields shows invalid template page', async ({ + page, +}) => { + const { user, team } = await seedUser(); + + const template = await seedDirectTemplate({ + title: 'V1 invalid direct template', + userId: user.id, + teamId: team.id, + createDirectRecipientSignatureField: false, + }); + + await page.goto(formatDirectTemplatePath(template.directLink?.token || '')); + + await expect(page.getByRole('heading', { name: 'Invalid direct link template' })).toBeVisible(); + await expect(page.getByText('This direct link template cannot be used because one or more signers')).toBeVisible(); + + // The signing flow must not render. + await expect(page.getByRole('heading', { name: 'General' })).not.toBeVisible(); + await expect(page.getByRole('button', { name: 'Continue' })).not.toBeVisible(); +}); + +test('[DIRECT_TEMPLATES]: V2 direct template without signature fields shows invalid template page', async ({ + page, +}) => { + const { user, team } = await seedUser(); + + const template = await seedDirectTemplate({ + title: 'V2 invalid direct template', + userId: user.id, + teamId: team.id, + internalVersion: 2, + createDirectRecipientSignatureField: false, + }); + + await page.goto(formatDirectTemplatePath(template.directLink?.token || '')); + + await expect(page.getByRole('heading', { name: 'Invalid direct link template' })).toBeVisible(); + await expect(page.getByText('This direct link template cannot be used because one or more signers')).toBeVisible(); + + // The signing flow (PDF canvas) must not render. + await expect(page.locator('.konva-container canvas')).toHaveCount(0); + await expect(page.getByRole('button', { name: 'Complete' })).not.toBeVisible(); +}); diff --git a/packages/app-tests/e2e/templates/template-use-dialog.spec.ts b/packages/app-tests/e2e/templates/template-use-dialog.spec.ts new file mode 100644 index 000000000..1dbca5f8d --- /dev/null +++ b/packages/app-tests/e2e/templates/template-use-dialog.spec.ts @@ -0,0 +1,101 @@ +import { FIELD_SIGNATURE_META_DEFAULT_VALUES } from '@documenso/lib/types/field-meta'; +import { prisma } from '@documenso/prisma'; +import { seedTemplate } from '@documenso/prisma/seed/templates'; +import { seedUser } from '@documenso/prisma/seed/users'; +import { expect, test } from '@playwright/test'; +import { DocumentStatus, FieldType } from '@prisma/client'; + +import { apiSignin } from '../fixtures/authentication'; +import { expectToastTextToBeVisible } from '../fixtures/generic'; + +const seedSignatureFieldForRecipient = async (options: { envelopeId: string; recipientId: number }) => { + const envelopeItem = await prisma.envelopeItem.findFirstOrThrow({ + where: { envelopeId: options.envelopeId }, + }); + + return await prisma.field.create({ + data: { + envelopeId: options.envelopeId, + envelopeItemId: envelopeItem.id, + recipientId: options.recipientId, + type: FieldType.SIGNATURE, + page: 1, + positionX: 5, + positionY: 10, + width: 20, + height: 5, + customText: '', + inserted: false, + fieldMeta: FIELD_SIGNATURE_META_DEFAULT_VALUES, + }, + }); +}; + +test('[TEMPLATE_USE]: shows missing signature fields error when sending a template without signature fields', async ({ + page, +}) => { + const { user, team } = await seedUser(); + + // seedTemplate creates one SIGNER recipient and no fields. + await seedTemplate({ + title: 'Template missing signature fields', + userId: user.id, + teamId: team.id, + }); + + await apiSignin({ + page, + email: user.email, + redirectPath: `/t/${team.url}/templates`, + }); + + await page.getByRole('button', { name: 'Use Template' }).click(); + await expect(page.getByRole('heading', { name: 'Create document from template' })).toBeVisible(); + + // Enable distribution so the document is sent on creation. + await page.locator('#distributeDocument').click(); + await page.getByRole('button', { name: 'Create and send' }).click(); + + await expectToastTextToBeVisible(page, 'Missing signature fields'); + await expectToastTextToBeVisible( + page, + 'The document could not be sent because some signers do not have a signature field', + ); +}); + +test('[TEMPLATE_USE]: creates and sends a document when signers have signature fields', async ({ page }) => { + const { user, team } = await seedUser(); + + const template = await seedTemplate({ + title: 'Template with signature fields', + userId: user.id, + teamId: team.id, + }); + + await seedSignatureFieldForRecipient({ + envelopeId: template.id, + recipientId: template.recipients[0].id, + }); + + await apiSignin({ + page, + email: user.email, + redirectPath: `/t/${team.url}/templates`, + }); + + await page.getByRole('button', { name: 'Use Template' }).click(); + await expect(page.getByRole('heading', { name: 'Create document from template' })).toBeVisible(); + + await page.locator('#distributeDocument').click(); + await page.getByRole('button', { name: 'Create and send' }).click(); + + await page.waitForURL(new RegExp(`/t/${team.url}/documents/envelope_.*`)); + + const envelopeId = page.url().split('/').pop()?.split('?')[0]; + + const envelope = await prisma.envelope.findFirstOrThrow({ + where: { id: envelopeId }, + }); + + expect(envelope.status).toBe(DocumentStatus.PENDING); +}); diff --git a/packages/lib/errors/app-error.ts b/packages/lib/errors/app-error.ts index 75338e0de..5dc3e7b7e 100644 --- a/packages/lib/errors/app-error.ts +++ b/packages/lib/errors/app-error.ts @@ -36,6 +36,13 @@ export enum AppErrorCode { */ ENVELOPE_TSP_LOCKED = 'ENVELOPE_TSP_LOCKED', + /** + * A signer recipient does not have a signature field assigned. Thrown when + * distributing an envelope or using a direct template where at least one + * signer has no signature field. + */ + MISSING_SIGNATURE_FIELD = 'MISSING_SIGNATURE_FIELD', + /** * CSC (Cloud Signature Consortium) error codes. See the CSC QES V1 spec * for the recovery taxonomy. @@ -84,6 +91,7 @@ export const genericErrorCodeToTrpcErrorCodeMap: Record (r.name ? `${r.name} (${r.email}, id: ${r.id})` : `${r.email} (id: ${r.id})`)) .join(', '); - throw new AppError(AppErrorCode.INVALID_REQUEST, { + throw new AppError(AppErrorCode.MISSING_SIGNATURE_FIELD, { message: `The following recipients are missing required fields: ${missingRecipientDescriptions}. Signers must have at least one signature field.`, }); } diff --git a/packages/lib/server-only/envelope/get-envelope-for-direct-template-signing.ts b/packages/lib/server-only/envelope/get-envelope-for-direct-template-signing.ts index e855f4fbe..6383bc7f7 100644 --- a/packages/lib/server-only/envelope/get-envelope-for-direct-template-signing.ts +++ b/packages/lib/server-only/envelope/get-envelope-for-direct-template-signing.ts @@ -5,6 +5,7 @@ import { match } from 'ts-pattern'; import { AppError, AppErrorCode } from '../../errors/app-error'; import { DocumentAccessAuth, type TDocumentAuthMethods } from '../../types/document-auth'; import { extractDocumentAuthMethods } from '../../utils/document-auth'; +import { getRecipientsWithMissingFields } from '../../utils/recipients'; import { extractFieldAutoInsertValues } from '../document/send-document'; import { getTeamSettings } from '../team/get-team-settings'; import type { EnvelopeForSigningResponse } from './get-envelope-for-recipient-signing'; @@ -125,6 +126,17 @@ export const getEnvelopeForDirectTemplateSigning = async ({ }); } + const recipientsWithMissingFields = getRecipientsWithMissingFields( + envelope.recipients, + envelope.recipients.flatMap((envelopeRecipient) => envelopeRecipient.fields), + ); + + if (recipientsWithMissingFields.length > 0) { + throw new AppError(AppErrorCode.MISSING_SIGNATURE_FIELD, { + message: 'One or more signers on this direct template are missing a signature field', + }); + } + const settings = await getTeamSettings({ teamId: envelope.teamId }); const sender = settings.includeSenderDetails diff --git a/packages/lib/server-only/template/create-document-from-direct-template.ts b/packages/lib/server-only/template/create-document-from-direct-template.ts index f7682d707..e767170b7 100644 --- a/packages/lib/server-only/template/create-document-from-direct-template.ts +++ b/packages/lib/server-only/template/create-document-from-direct-template.ts @@ -40,6 +40,7 @@ import { extractDocumentAuthMethods, } from '../../utils/document-auth'; import { mapSecondaryIdToTemplateId } from '../../utils/envelope'; +import { getRecipientsWithMissingFields } from '../../utils/recipients'; import { sendDocument } from '../document/send-document'; import { validateFieldAuth } from '../document/validate-field-auth'; import { incrementDocumentId } from '../envelope/increment-id'; @@ -172,6 +173,17 @@ export const createDocumentFromDirectTemplate = async ({ }); } + const recipientsWithMissingFields = getRecipientsWithMissingFields( + recipients, + recipients.flatMap((recipient) => recipient.fields), + ); + + if (recipientsWithMissingFields.length > 0) { + throw new AppError(AppErrorCode.MISSING_SIGNATURE_FIELD, { + message: 'One or more signers on this direct template are missing a signature field', + }); + } + if (directTemplateEnvelope.updatedAt.getTime() !== templateUpdatedAt.getTime()) { throw new AppError(AppErrorCode.INVALID_REQUEST, { message: 'Template no longer matches' }); } diff --git a/packages/prisma/seed/initial-seed.ts b/packages/prisma/seed/initial-seed.ts index 2833b3350..ce5065672 100644 --- a/packages/prisma/seed/initial-seed.ts +++ b/packages/prisma/seed/initial-seed.ts @@ -361,9 +361,9 @@ export const seedAlignmentTestDocument = async ({ const { id, recipients, envelopeItems } = createdEnvelope; if (isDirectTemplate) { - const directTemplateRecpient = recipients.find((recipient) => recipient.email === DIRECT_TEMPLATE_RECIPIENT_EMAIL); + const directTemplateRecipient = recipients.find((recipient) => recipient.email === DIRECT_TEMPLATE_RECIPIENT_EMAIL); - if (!directTemplateRecpient) { + if (!directTemplateRecipient) { throw new Error('Need to create a direct template recipient'); } @@ -372,7 +372,7 @@ export const seedAlignmentTestDocument = async ({ envelopeId: id, enabled: true, token: directTemplateToken ?? Math.random().toString(), - directTemplateRecipientId: directTemplateRecpient.id, + directTemplateRecipientId: directTemplateRecipient.id, }, }); } diff --git a/packages/prisma/seed/templates.ts b/packages/prisma/seed/templates.ts index b72e0a39c..86f761954 100644 --- a/packages/prisma/seed/templates.ts +++ b/packages/prisma/seed/templates.ts @@ -6,6 +6,7 @@ import { DIRECT_TEMPLATE_RECIPIENT_NAME, } from '@documenso/lib/constants/direct-templates'; import { incrementTemplateId } from '@documenso/lib/server-only/envelope/increment-id'; +import { FIELD_SIGNATURE_META_DEFAULT_VALUES } from '@documenso/lib/types/field-meta'; import { SignatureLevel } from '@documenso/lib/types/signature-level'; import { prefixedId } from '@documenso/lib/universal/id'; @@ -15,6 +16,7 @@ import { DocumentDataType, DocumentSource, EnvelopeType, + FieldType, ReadStatus, RecipientRole, SendStatus, @@ -29,6 +31,11 @@ type SeedTemplateOptions = { teamId: number; internalVersion?: 1 | 2; createTemplateOptions?: Partial; + /** + * Only used by seedDirectTemplate. Creates a signature field for the direct + * recipient so the seeded direct template is valid. Defaults to true. + */ + createDirectRecipientSignatureField?: boolean; }; type CreateTemplateOptions = { @@ -198,11 +205,11 @@ export const seedDirectTemplate = async (options: SeedTemplateOptions) => { }, }); - const directTemplateRecpient = template.recipients.find( + const directTemplateRecipient = template.recipients.find( (recipient) => recipient.email === DIRECT_TEMPLATE_RECIPIENT_EMAIL, ); - if (!directTemplateRecpient) { + if (!directTemplateRecipient) { throw new Error('Need to create a direct template recipient'); } @@ -211,10 +218,35 @@ export const seedDirectTemplate = async (options: SeedTemplateOptions) => { envelopeId: template.id, enabled: true, token: Math.random().toString(), - directTemplateRecipientId: directTemplateRecpient.id, + directTemplateRecipientId: directTemplateRecipient.id, }, }); + const { createDirectRecipientSignatureField = true } = options; + + if (createDirectRecipientSignatureField) { + const envelopeItem = await prisma.envelopeItem.findFirstOrThrow({ + where: { envelopeId: template.id }, + }); + + await prisma.field.create({ + data: { + envelopeId: template.id, + envelopeItemId: envelopeItem.id, + recipientId: directTemplateRecipient.id, + type: FieldType.SIGNATURE, + page: 1, + positionX: 5, + positionY: 10, + width: 20, + height: 5, + customText: '', + inserted: false, + fieldMeta: FIELD_SIGNATURE_META_DEFAULT_VALUES, + }, + }); + } + return await prisma.envelope.findFirstOrThrow({ where: { id: template.id,