feat: persist and enforce the allow document rejection setting

Accepts allowDocumentRejection in the organisation and team settings
update routes, adds it to the default organisation (true) and team
(inherit) settings, and copies the merged value into the document meta
when a document or template is created via extractDerivedDocumentMeta.
Documents created from a template take the template's value, with an
optional override on envelope.use.

Exposes the field in the recipient signing response and refuses
rejectDocumentWithToken requests for documents that disallow rejection,
since the endpoint can be called directly without the UI.

Extends the document preferences and envelope settings e2e tests to
cover inheritance into the team settings and new documents, and
persistence of the per-document value from the editor.
This commit is contained in:
Catalin Pit
2026-09-11 14:28:15 +03:00
parent d91a3e3828
commit 2140d132d7
13 changed files with 48 additions and 1 deletions
@@ -24,6 +24,7 @@ const TEST_SETTINGS_VALUES = {
subject: 'E2E settings subject',
message: 'E2E settings message',
language: 'French',
allowDocumentRejection: 'No',
dateFormat: 'DD/MM/YYYY',
timezone: 'Europe/London',
distributionMethod: 'None',
@@ -104,6 +105,10 @@ const runSettingsFlow = async ({ root }: TEnvelopeEditorSurface, { externalId, i
await root.getByRole('option', { name: 'Upload' }).click();
await clickSettingsDialogHeader(root);
await getComboboxByLabel(root, 'Allow Document Rejection').click();
await root.getByRole('option', { name: TEST_SETTINGS_VALUES.allowDocumentRejection, exact: true }).click();
await clickSettingsDialogHeader(root);
await getComboboxByLabel(root, 'Date Format').click();
await root.getByRole('option', { name: TEST_SETTINGS_VALUES.dateFormat, exact: true }).click();
await clickSettingsDialogHeader(root);
@@ -265,6 +270,9 @@ const runSettingsFlow = async ({ root }: TEnvelopeEditorSurface, { externalId, i
await expect(root.locator('input[name="meta.redirectUrl"]')).toHaveValue(TEST_SETTINGS_VALUES.redirectUrl);
await expect(getComboboxByLabel(root, 'Language')).toContainText(TEST_SETTINGS_VALUES.language);
await expect(getComboboxByLabel(root, 'Allowed Signature Types')).not.toContainText('Upload');
await expect(getComboboxByLabel(root, 'Allow Document Rejection')).toContainText(
TEST_SETTINGS_VALUES.allowDocumentRejection,
);
await expect(getComboboxByLabel(root, 'Date Format')).toContainText(TEST_SETTINGS_VALUES.dateFormat);
await expect(getComboboxByLabel(root, 'Time Zone')).toContainText(TEST_SETTINGS_VALUES.timezone);
await expect(root.locator('[data-testid="documentDistributionMethodSelectValue"]')).toContainText(
@@ -384,6 +392,7 @@ const assertEnvelopeSettingsPersistedInDatabase = async ({
expect(envelope.documentMeta.drawSignatureEnabled).toBe(true);
expect(envelope.documentMeta.typedSignatureEnabled).toBe(true);
expect(envelope.documentMeta.uploadSignatureEnabled).toBe(false);
expect(envelope.documentMeta.allowDocumentRejection).toBe(false);
expect(envelope.documentMeta.emailSettings).toMatchObject(DB_EXPECTED_VALUES.emailSettings);
const authOptions = parseAuthOptions(envelope.authOptions);
@@ -37,6 +37,10 @@ test('[ORGANISATIONS]: manage document preferences', async ({ page }) => {
await page.getByRole('option', { name: 'Upload' }).click();
await page.keyboard.press('Escape');
// Disable document rejection
await page.getByTestId('allow-document-rejection-trigger').click();
await page.getByRole('option', { name: 'No', exact: true }).click();
await page.getByRole('button', { name: 'Save changes' }).first().click();
await expect(page.getByText('Your document preferences have been updated').first()).toBeVisible();
@@ -68,10 +72,14 @@ test('[ORGANISATIONS]: manage document preferences', async ({ page }) => {
expect(teamSettings.typedSignatureEnabled).toEqual(true);
expect(teamSettings.uploadSignatureEnabled).toEqual(false);
expect(teamSettings.drawSignatureEnabled).toEqual(false);
expect(teamSettings.allowDocumentRejection).toEqual(false);
// Edit the team settings
await page.goto(`/t/${team.url}/settings/document`);
// Document rejection is left untouched so it keeps inheriting from the organisation.
await expect(page.getByTestId('allow-document-rejection-status')).toHaveText('Inherited');
await page.getByTestId('document-visibility-trigger').click();
await page.getByRole('option', { name: 'Everyone can access and view' }).click();
await page.getByTestId('document-language-trigger').click();
@@ -102,6 +110,7 @@ test('[ORGANISATIONS]: manage document preferences', async ({ page }) => {
expect(updatedTeamSettings.typedSignatureEnabled).toEqual(true);
expect(updatedTeamSettings.uploadSignatureEnabled).toEqual(false);
expect(updatedTeamSettings.drawSignatureEnabled).toEqual(false);
expect(updatedTeamSettings.allowDocumentRejection).toEqual(false);
const document = await seedTeamDocumentWithMeta(team);
@@ -120,6 +129,7 @@ test('[ORGANISATIONS]: manage document preferences', async ({ page }) => {
expect(documentMeta.language).toEqual('pl');
expect(documentMeta.timezone).toEqual('Europe/London');
expect(documentMeta.dateFormat).toEqual('MM/dd/yyyy');
expect(documentMeta.allowDocumentRejection).toEqual(false);
});
test('[ORGANISATIONS]: manage branding preferences', async ({ page }) => {
@@ -33,7 +33,11 @@ export async function rejectDocumentWithToken({ token, id, reason, requestMetada
envelope: unsafeBuildEnvelopeIdQuery(id, EnvelopeType.DOCUMENT),
},
include: {
envelope: true,
envelope: {
include: {
documentMeta: true,
},
},
},
});
@@ -51,6 +55,14 @@ export async function rejectDocumentWithToken({ token, id, reason, requestMetada
});
}
// Hiding the reject button on the signing page is not enough because the
// recipient can call this endpoint directly, so enforce the setting here.
if (!envelope.documentMeta.allowDocumentRejection) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: `Document ${envelope.id} does not allow rejection`,
});
}
assertRecipientNotExpired(recipient);
// Update the recipient status to rejected
@@ -48,6 +48,7 @@ export const ZEnvelopeForSigningResponse = z.object({
uploadSignatureEnabled: true,
drawSignatureEnabled: true,
allowDictateNextSigner: true,
allowDocumentRejection: true,
language: true,
}),
recipients: ZRecipientLiteSchema.pick({
@@ -113,6 +113,7 @@ export type CreateDocumentFromTemplateOptions = {
uploadSignatureEnabled?: boolean;
drawSignatureEnabled?: boolean;
envelopeExpirationPeriod?: TEnvelopeExpirationPeriod | null;
allowDocumentRejection?: boolean;
};
formValues?: TDocumentFormValues;
@@ -542,6 +543,7 @@ export const createDocumentFromTemplate = async ({
drawSignatureEnabled: override?.drawSignatureEnabled ?? template.documentMeta?.drawSignatureEnabled,
allowDictateNextSigner: override?.allowDictateNextSigner ?? template.documentMeta?.allowDictateNextSigner,
envelopeExpirationPeriod: override?.envelopeExpirationPeriod ?? template.documentMeta?.envelopeExpirationPeriod,
allowDocumentRejection: override?.allowDocumentRejection ?? template.documentMeta?.allowDocumentRejection,
},
signatureLevel,
),
+3
View File
@@ -70,6 +70,9 @@ export const extractDerivedDocumentMeta = (
// Reminder settings.
reminderSettings: meta.reminderSettings ?? settings.reminderSettings ?? null,
// Rejection settings.
allowDocumentRejection: meta.allowDocumentRejection ?? settings.allowDocumentRejection,
} satisfies Omit<DocumentMeta, 'id'>;
};
+1
View File
@@ -111,6 +111,7 @@ export const generateDefaultOrganisationSettings = (): Omit<OrganisationGlobalSe
documentTimezone: null, // Null means local timezone.
documentDateFormat: DEFAULT_DOCUMENT_DATE_FORMAT,
delegateDocumentOwnership: false,
allowDocumentRejection: true,
includeSenderDetails: true,
includeSigningCertificate: true,
+1
View File
@@ -178,6 +178,7 @@ export const generateDefaultTeamSettings = (): Omit<TeamGlobalSettings, 'id' | '
documentTimezone: null,
documentDateFormat: null,
delegateDocumentOwnership: null,
allowDocumentRejection: null,
includeSenderDetails: null,
includeSigningCertificate: null,
@@ -2,6 +2,7 @@ import { ZEnvelopeExpirationPeriod } from '@documenso/lib/constants/envelope-exp
import { ZDocumentEmailSettingsSchema } from '@documenso/lib/types/document-email';
import { ZDocumentFormValuesSchema } from '@documenso/lib/types/document-form-values';
import {
ZDocumentMetaAllowDocumentRejectionSchema,
ZDocumentMetaDateFormatSchema,
ZDocumentMetaDistributionMethodSchema,
ZDocumentMetaDrawSignatureEnabledSchema,
@@ -96,6 +97,7 @@ export const ZUseEnvelopePayloadSchema = z.object({
drawSignatureEnabled: ZDocumentMetaDrawSignatureEnabledSchema.optional(),
allowDictateNextSigner: z.boolean().optional(),
envelopeExpirationPeriod: ZEnvelopeExpirationPeriod.nullish(),
allowDocumentRejection: ZDocumentMetaAllowDocumentRejectionSchema.optional(),
})
.describe('Override values from the template for the created document.')
.optional(),
@@ -39,6 +39,7 @@ export const updateOrganisationSettingsRoute = authenticatedProcedure
drawSignatureEnabled,
defaultRecipients,
delegateDocumentOwnership,
allowDocumentRejection,
envelopeExpirationPeriod,
reminderSettings,
@@ -167,6 +168,7 @@ export const updateOrganisationSettingsRoute = authenticatedProcedure
drawSignatureEnabled,
defaultRecipients: defaultRecipients === null ? Prisma.DbNull : defaultRecipients,
delegateDocumentOwnership: derivedDelegateDocumentOwnership,
allowDocumentRejection,
envelopeExpirationPeriod: envelopeExpirationPeriod === null ? Prisma.DbNull : envelopeExpirationPeriod,
reminderSettings: reminderSettings === null ? Prisma.DbNull : reminderSettings,
@@ -27,6 +27,7 @@ export const ZUpdateOrganisationSettingsRequestSchema = z.object({
drawSignatureEnabled: z.boolean().optional(),
defaultRecipients: ZDefaultRecipientsSchema.nullish(),
delegateDocumentOwnership: z.boolean().nullish(),
allowDocumentRejection: z.boolean().optional(),
envelopeExpirationPeriod: ZEnvelopeExpirationPeriod.optional(),
reminderSettings: ZEnvelopeReminderSettings.optional(),
@@ -37,6 +37,7 @@ export const updateTeamSettingsRoute = authenticatedProcedure
uploadSignatureEnabled,
drawSignatureEnabled,
delegateDocumentOwnership,
allowDocumentRejection,
envelopeExpirationPeriod,
reminderSettings,
@@ -169,6 +170,7 @@ export const updateTeamSettingsRoute = authenticatedProcedure
uploadSignatureEnabled,
drawSignatureEnabled,
delegateDocumentOwnership,
allowDocumentRejection,
envelopeExpirationPeriod: envelopeExpirationPeriod === null ? Prisma.DbNull : envelopeExpirationPeriod,
reminderSettings: reminderSettings === null ? Prisma.DbNull : reminderSettings,
@@ -30,6 +30,7 @@ export const ZUpdateTeamSettingsRequestSchema = z.object({
uploadSignatureEnabled: z.boolean().nullish(),
drawSignatureEnabled: z.boolean().nullish(),
delegateDocumentOwnership: z.boolean().nullish(),
allowDocumentRejection: z.boolean().nullish(),
envelopeExpirationPeriod: ZEnvelopeExpirationPeriod.nullish(),
reminderSettings: ZEnvelopeReminderSettings.nullish(),