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 (
+
{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..650c7e5a5
--- /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/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/templates.ts b/packages/prisma/seed/templates.ts
index b72e0a39c..94c8bf355 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 = {
@@ -215,6 +222,31 @@ export const seedDirectTemplate = async (options: SeedTemplateOptions) => {
},
});
+ 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: directTemplateRecpient.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,