Compare commits

...

6 Commits

Author SHA1 Message Date
David Nguyen 83b021c167 fix: typo 2026-07-11 19:42:52 +08:00
David Nguyen 933459dd5f fix: add error to recipients page 2026-07-11 18:44:12 +08:00
David Nguyen 0f4fe2c8ed Potential fix for pull request finding
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-07-11 17:41:37 +08:00
David Nguyen 638441ec07 Merge branch 'main' into fix/missing-signature-on-templates 2026-07-11 17:41:13 +08:00
David Nguyen ada406f842 fix: render error messages for invalid templates 2026-07-11 09:17:18 +00:00
Tanushree Ahir 400b6a24f1 feat: enable real-time validation for email and password fields (#2204)
## Description

This MR improves the real-time validation experience on the signup page.
Currently, both the email and password fields validate only on blur,
causing users to continue seeing error messages even after correcting
their input until they click outside the field.

This update switches the React Hook Form configuration from mode:
'onBlur' to mode: 'onChange', ensuring validations update immediately as
the user types.
This results in a smoother and less confusing signup experience.

## Related Issue

Fixes #2200

## Changes Made

- Updated React Hook Form configuration in the signup form from:
- mode: 'onBlur' ➜ mode: 'onChange'
- Ensures real-time validation for email and password fields
- No schema or logic changes — only validation trigger updated


## Testing Performed

- Manually tested the signup page for:
- Real-time password rule updates
- Real-time email validation feedback
- Error disappearance immediately after correcting input

## Checklist

- [x] I have tested these changes locally and they work as expected.
- [ ] I have added/updated tests that prove the effectiveness of these
changes.
- [x] I have updated the documentation to reflect these changes, if
applicable.
- [x] I have followed the project's coding style guidelines.
- [x] I have addressed the code review feedback from the previous
submission, if applicable.

## Additional Notes

This is a small UX-focused improvement; the validation schema remains
unchanged.

Co-authored-by: Catalin Pit <catalinpit@gmail.com>
2026-07-10 13:41:31 +03:00
18 changed files with 491 additions and 28 deletions
+1 -1
View File
@@ -96,7 +96,7 @@ export const SignUpForm = ({
password: '',
signature: '',
},
mode: 'onBlur',
mode: 'onChange',
resolver: zodResolver(ZSignUpFormSchema),
});
@@ -0,0 +1,23 @@
import { Trans } from '@lingui/react/macro';
import { AlertTriangleIcon } from 'lucide-react';
export const DirectTemplateInvalidPageView = () => {
return (
<div className="mx-auto flex h-[70vh] w-full max-w-md flex-col items-center justify-center">
<div>
<AlertTriangleIcon className="h-10 w-10 text-destructive" />
<h1 className="mt-4 font-semibold text-3xl">
<Trans>Invalid direct link template</Trans>
</h1>
<p className="mt-2 text-muted-foreground text-sm">
<Trans>
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.
</Trans>
</p>
</div>
</div>
);
};
@@ -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 = () => {
}
/>
<EnvelopeEditorInvalidDirectTemplateAlert />
{/* Document View */}
<div className="mt-4 flex h-full flex-col items-center justify-center">
{envelope.recipients.length === 0 && (
@@ -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 (
<Alert
variant="destructive"
className={cn('mx-auto w-full max-w-[800px] flex-row items-start gap-3 rounded-sm', className)}
>
<AlertTitle>
<Trans>Invalid direct link template</Trans>
</AlertTitle>
<AlertDescription>
<Trans>
Recipients cannot use this direct link template because the following signers are missing a signature field
</Trans>
<ul className="list-disc pl-5">
{signersMissingSignatureFields.map((recipient, i) => (
<li key={recipient.id}>{recipient.email || recipient.name || `Recipient ${i + 1}`}</li>
))}
</ul>
</AlertDescription>
</Alert>
);
};
@@ -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 */}
<EnvelopeRendererFileSelector className="px-0" fields={editorFields.localFields} />
<EnvelopeEditorInvalidDirectTemplateAlert className="mb-4" />
<Alert variant="warning" className="mx-auto max-w-[800px]">
<AlertTitle>
<Trans>Preview Mode</Trans>
@@ -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 (
<div className="mx-auto max-w-4xl space-y-6 p-8">
<input {...getReplaceInputProps()} />
<EnvelopeEditorInvalidDirectTemplateAlert className="max-w-none" />
<Card backdropBlur={false} className="border">
<CardHeader className="pb-3">
<CardTitle>
@@ -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<ReturnType<typeof handleV
return <DirectTemplateAuthPageView />;
}
if (data.isTemplateMissingSignatures) {
return <DirectTemplateInvalidPageView />;
}
const { template, directTemplateRecipient } = data;
return (
@@ -235,6 +259,10 @@ const DirectSigningPageV2 = ({ data }: { data: Awaited<ReturnType<typeof handleV
return <DocumentSigningAuthPageView email={''} emailHasAccount={true} />;
}
if (data.isTemplateMissingSignatures) {
return <DirectTemplateInvalidPageView />;
}
const { envelope, recipient } = data.envelopeForSigning;
const { derivedRecipientAccessAuth } = extractDocumentAuthMethods({
@@ -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.`,
@@ -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 });
});
});
@@ -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();
@@ -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();
});
@@ -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);
});
+9
View File
@@ -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<string, { code: string;
[AppErrorCode.ENVELOPE_CANCELLED]: { code: 'BAD_REQUEST', status: 400 },
[AppErrorCode.ENVELOPE_LEGACY]: { code: 'BAD_REQUEST', status: 400 },
[AppErrorCode.ENVELOPE_TSP_LOCKED]: { code: 'BAD_REQUEST', status: 400 },
[AppErrorCode.MISSING_SIGNATURE_FIELD]: { code: 'BAD_REQUEST', status: 400 },
[AppErrorCode.CSC_INSTANCE_MODE_MISMATCH]: { code: 'BAD_REQUEST', status: 400 },
[AppErrorCode.CSC_UNLICENSED]: { code: 'FORBIDDEN', status: 403 },
[AppErrorCode.CSC_PROVIDER_INFO_FAILED]: { code: 'INTERNAL_SERVER_ERROR', status: 500 },
@@ -291,6 +299,7 @@ export class AppError extends Error {
AppErrorCode.ENVELOPE_CANCELLED,
AppErrorCode.ENVELOPE_LEGACY,
AppErrorCode.ENVELOPE_TSP_LOCKED,
AppErrorCode.MISSING_SIGNATURE_FIELD,
AppErrorCode.CSC_INSTANCE_MODE_MISMATCH,
AppErrorCode.CSC_CREDENTIAL_LIST_EMPTY,
AppErrorCode.CSC_CERT_INVALID,
@@ -194,7 +194,7 @@ export const sendDocument = async ({ id, userId, teamId, sendEmail, requestMetad
.map((r) => (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.`,
});
}
@@ -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
@@ -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' });
}
+3 -3
View File
@@ -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,
},
});
}
+35 -3
View File
@@ -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<Prisma.EnvelopeUncheckedCreateInput>;
/**
* 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,