feat: password reauthentication for documents and recipients (#1827)

Adds password reauthentication to our existing reauth providers,
additionally swaps from an exclusive provider to an inclusive type where
multiple methods can be selected to offer a this or that experience.
This commit is contained in:
Lucas Smith
2025-06-07 00:27:19 +10:00
committed by GitHub
parent ce66da0055
commit 55c8632620
62 changed files with 985 additions and 466 deletions

View File

@ -821,7 +821,7 @@ export const ApiContractV1Implementation = tsr.router(ApiContractV1, {
name,
role,
signingOrder,
actionAuth: authOptions?.actionAuth ?? null,
actionAuth: authOptions?.actionAuth ?? [],
},
],
requestMetadata: metadata,
@ -888,7 +888,7 @@ export const ApiContractV1Implementation = tsr.router(ApiContractV1, {
name,
role,
signingOrder,
actionAuth: authOptions?.actionAuth,
actionAuth: authOptions?.actionAuth ?? [],
requestMetadata: metadata.requestMetadata,
}).catch(() => null);

View File

@ -177,8 +177,8 @@ export const ZCreateDocumentMutationSchema = z.object({
.default({}),
authOptions: z
.object({
globalAccessAuth: ZDocumentAccessAuthTypesSchema.optional(),
globalActionAuth: ZDocumentActionAuthTypesSchema.optional(),
globalAccessAuth: z.array(ZDocumentAccessAuthTypesSchema).optional().default([]),
globalActionAuth: z.array(ZDocumentActionAuthTypesSchema).optional().default([]),
})
.optional()
.openapi({
@ -237,8 +237,8 @@ export const ZCreateDocumentFromTemplateMutationSchema = z.object({
.optional(),
authOptions: z
.object({
globalAccessAuth: ZDocumentAccessAuthTypesSchema.optional(),
globalActionAuth: ZDocumentActionAuthTypesSchema.optional(),
globalAccessAuth: z.array(ZDocumentAccessAuthTypesSchema).optional().default([]),
globalActionAuth: z.array(ZDocumentActionAuthTypesSchema).optional().default([]),
})
.optional(),
formValues: z.record(z.string(), z.union([z.string(), z.boolean(), z.number()])).optional(),
@ -310,8 +310,8 @@ export const ZGenerateDocumentFromTemplateMutationSchema = z.object({
.optional(),
authOptions: z
.object({
globalAccessAuth: ZDocumentAccessAuthTypesSchema.optional(),
globalActionAuth: ZDocumentActionAuthTypesSchema.optional(),
globalAccessAuth: z.array(ZDocumentAccessAuthTypesSchema).optional().default([]),
globalActionAuth: z.array(ZDocumentActionAuthTypesSchema).optional().default([]),
})
.optional(),
formValues: z.record(z.string(), z.union([z.string(), z.boolean(), z.number()])).optional(),
@ -350,7 +350,7 @@ export const ZCreateRecipientMutationSchema = z.object({
signingOrder: z.number().nullish(),
authOptions: z
.object({
actionAuth: ZRecipientActionAuthTypesSchema.optional(),
actionAuth: z.array(ZRecipientActionAuthTypesSchema).optional().default([]),
})
.optional()
.openapi({

View File

@ -42,8 +42,8 @@ test('[DOCUMENT_AUTH]: should allow or deny access when required', async ({ page
{
createDocumentOptions: {
authOptions: createDocumentAuthOptions({
globalAccessAuth: 'ACCOUNT',
globalActionAuth: null,
globalAccessAuth: ['ACCOUNT'],
globalActionAuth: [],
}),
},
},

View File

@ -65,8 +65,8 @@ test('[DOCUMENT_AUTH]: should allow signing with valid global auth', async ({ pa
recipients: [recipientWithAccount],
updateDocumentOptions: {
authOptions: createDocumentAuthOptions({
globalAccessAuth: null,
globalActionAuth: 'ACCOUNT',
globalAccessAuth: [],
globalActionAuth: ['ACCOUNT'],
}),
},
});
@ -116,8 +116,8 @@ test.skip('[DOCUMENT_AUTH]: should deny signing document when required for globa
recipients: [recipientWithAccount],
updateDocumentOptions: {
authOptions: createDocumentAuthOptions({
globalAccessAuth: null,
globalActionAuth: 'ACCOUNT',
globalAccessAuth: [],
globalActionAuth: ['ACCOUNT'],
}),
},
});
@ -147,8 +147,8 @@ test('[DOCUMENT_AUTH]: should deny signing fields when required for global auth'
recipients: [recipientWithAccount, seedTestEmail()],
updateDocumentOptions: {
authOptions: createDocumentAuthOptions({
globalAccessAuth: null,
globalActionAuth: 'ACCOUNT',
globalAccessAuth: [],
globalActionAuth: ['ACCOUNT'],
}),
},
});
@ -193,20 +193,20 @@ test('[DOCUMENT_AUTH]: should allow field signing when required for recipient au
recipientsCreateOptions: [
{
authOptions: createRecipientAuthOptions({
accessAuth: null,
actionAuth: null,
accessAuth: [],
actionAuth: [],
}),
},
{
authOptions: createRecipientAuthOptions({
accessAuth: null,
actionAuth: 'EXPLICIT_NONE',
accessAuth: [],
actionAuth: ['EXPLICIT_NONE'],
}),
},
{
authOptions: createRecipientAuthOptions({
accessAuth: null,
actionAuth: 'ACCOUNT',
accessAuth: [],
actionAuth: ['ACCOUNT'],
}),
},
],
@ -218,7 +218,7 @@ test('[DOCUMENT_AUTH]: should allow field signing when required for recipient au
const { actionAuth } = ZRecipientAuthOptionsSchema.parse(recipient.authOptions);
// This document has no global action auth, so only account should require auth.
const isAuthRequired = actionAuth === 'ACCOUNT';
const isAuthRequired = actionAuth.includes('ACCOUNT');
const signUrl = `/sign/${token}`;
@ -292,28 +292,28 @@ test('[DOCUMENT_AUTH]: should allow field signing when required for recipient an
recipientsCreateOptions: [
{
authOptions: createRecipientAuthOptions({
accessAuth: null,
actionAuth: null,
accessAuth: [],
actionAuth: [],
}),
},
{
authOptions: createRecipientAuthOptions({
accessAuth: null,
actionAuth: 'EXPLICIT_NONE',
accessAuth: [],
actionAuth: ['EXPLICIT_NONE'],
}),
},
{
authOptions: createRecipientAuthOptions({
accessAuth: null,
actionAuth: 'ACCOUNT',
accessAuth: [],
actionAuth: ['ACCOUNT'],
}),
},
],
fields: [FieldType.DATE, FieldType.SIGNATURE],
updateDocumentOptions: {
authOptions: createDocumentAuthOptions({
globalAccessAuth: null,
globalActionAuth: 'ACCOUNT',
globalAccessAuth: [],
globalActionAuth: ['ACCOUNT'],
}),
},
});
@ -323,7 +323,7 @@ test('[DOCUMENT_AUTH]: should allow field signing when required for recipient an
const { actionAuth } = ZRecipientAuthOptionsSchema.parse(recipient.authOptions);
// This document HAS global action auth, so account and inherit should require auth.
const isAuthRequired = actionAuth === 'ACCOUNT' || actionAuth === null;
const isAuthRequired = actionAuth.includes('ACCOUNT') || actionAuth.length === 0;
const signUrl = `/sign/${token}`;

View File

@ -40,7 +40,7 @@ test.describe('[EE_ONLY]', () => {
// Set EE action auth.
await page.getByTestId('documentActionSelectValue').click();
await page.getByLabel('Require passkey').getByText('Require passkey').click();
await page.getByRole('option').filter({ hasText: 'Require passkey' }).click();
await expect(page.getByTestId('documentActionSelectValue')).toContainText('Require passkey');
// Save the settings by going to the next step.
@ -82,7 +82,7 @@ test.describe('[EE_ONLY]', () => {
// Set EE action auth.
await page.getByTestId('documentActionSelectValue').click();
await page.getByLabel('Require passkey').getByText('Require passkey').click();
await page.getByRole('option').filter({ hasText: 'Require passkey' }).click();
await expect(page.getByTestId('documentActionSelectValue')).toContainText('Require passkey');
// Save the settings by going to the next step.
@ -143,7 +143,7 @@ test('[DOCUMENT_FLOW]: add settings', async ({ page }) => {
// Set access auth.
await page.getByTestId('documentAccessSelectValue').click();
await page.getByLabel('Require account').getByText('Require account').click();
await page.getByRole('option').filter({ hasText: 'Require account' }).click();
await expect(page.getByTestId('documentAccessSelectValue')).toContainText('Require account');
// Action auth should NOT be visible.

View File

@ -37,7 +37,7 @@ test.describe('[EE_ONLY]', () => {
// Set EE action auth.
await page.getByTestId('documentActionSelectValue').click();
await page.getByLabel('Require passkey').getByText('Require passkey').click();
await page.getByRole('option').filter({ hasText: 'Require passkey' }).click();
await expect(page.getByTestId('documentActionSelectValue')).toContainText('Require passkey');
// Save the settings by going to the next step.
@ -79,7 +79,7 @@ test.describe('[EE_ONLY]', () => {
// Set EE action auth.
await page.getByTestId('documentActionSelectValue').click();
await page.getByLabel('Require passkey').getByText('Require passkey').click();
await page.getByRole('option').filter({ hasText: 'Require passkey' }).click();
await expect(page.getByTestId('documentActionSelectValue')).toContainText('Require passkey');
// Save the settings by going to the next step.
@ -140,7 +140,7 @@ test('[TEMPLATE_FLOW]: add settings', async ({ page }) => {
// Set access auth.
await page.getByTestId('documentAccessSelectValue').click();
await page.getByLabel('Require account').getByText('Require account').click();
await page.getByRole('option').filter({ hasText: 'Require account' }).click();
await expect(page.getByTestId('documentAccessSelectValue')).toContainText('Require account');
// Action auth should NOT be visible.

View File

@ -58,8 +58,8 @@ test.describe('[EE_ONLY]', () => {
// Add advanced settings for a single recipient.
await page.getByLabel('Show advanced settings').check();
await page.getByRole('combobox').first().click();
await page.getByLabel('Require passkey').click();
await page.getByTestId('documentActionSelectValue').click();
await page.getByRole('option').filter({ hasText: 'Require passkey' }).click();
// Navigate to the next step and back.
await page.getByRole('button', { name: 'Continue' }).click();

View File

@ -48,13 +48,13 @@ test('[TEMPLATE]: should create a document from a template', async ({ page }) =>
// Set template document access.
await page.getByTestId('documentAccessSelectValue').click();
await page.getByLabel('Require account').getByText('Require account').click();
await page.getByRole('option').filter({ hasText: 'Require account' }).click();
await expect(page.getByTestId('documentAccessSelectValue')).toContainText('Require account');
// Set EE action auth.
if (isBillingEnabled) {
await page.getByTestId('documentActionSelectValue').click();
await page.getByLabel('Require passkey').getByText('Require passkey').click();
await page.getByRole('option').filter({ hasText: 'Require passkey' }).click();
await expect(page.getByTestId('documentActionSelectValue')).toContainText('Require passkey');
}
@ -85,8 +85,8 @@ test('[TEMPLATE]: should create a document from a template', async ({ page }) =>
// Apply require passkey for Recipient 1.
if (isBillingEnabled) {
await page.getByLabel('Show advanced settings').check();
await page.getByRole('combobox').first().click();
await page.getByLabel('Require passkey').click();
await page.getByTestId('documentActionSelectValue').click();
await page.getByRole('option').filter({ hasText: 'Require passkey' }).click();
}
await page.getByRole('button', { name: 'Continue' }).click();
@ -119,10 +119,12 @@ test('[TEMPLATE]: should create a document from a template', async ({ page }) =>
});
expect(document.title).toEqual('TEMPLATE_TITLE');
expect(documentAuth.documentAuthOption.globalAccessAuth).toEqual('ACCOUNT');
expect(documentAuth.documentAuthOption.globalActionAuth).toEqual(
isBillingEnabled ? 'PASSKEY' : null,
);
expect(documentAuth.documentAuthOption.globalAccessAuth).toContain('ACCOUNT');
if (isBillingEnabled) {
expect(documentAuth.documentAuthOption.globalActionAuth).toContain('PASSKEY');
}
expect(document.documentMeta?.dateFormat).toEqual('dd/MM/yyyy hh:mm a');
expect(document.documentMeta?.message).toEqual('MESSAGE');
expect(document.documentMeta?.redirectUrl).toEqual('https://documenso.com');
@ -143,11 +145,11 @@ test('[TEMPLATE]: should create a document from a template', async ({ page }) =>
});
if (isBillingEnabled) {
expect(recipientOneAuth.derivedRecipientActionAuth).toEqual('PASSKEY');
expect(recipientOneAuth.derivedRecipientActionAuth).toContain('PASSKEY');
}
expect(recipientOneAuth.derivedRecipientAccessAuth).toEqual('ACCOUNT');
expect(recipientTwoAuth.derivedRecipientAccessAuth).toEqual('ACCOUNT');
expect(recipientOneAuth.derivedRecipientAccessAuth).toContain('ACCOUNT');
expect(recipientTwoAuth.derivedRecipientAccessAuth).toContain('ACCOUNT');
});
/**
@ -183,13 +185,13 @@ test('[TEMPLATE]: should create a team document from a team template', async ({
// Set template document access.
await page.getByTestId('documentAccessSelectValue').click();
await page.getByLabel('Require account').getByText('Require account').click();
await page.getByRole('option').filter({ hasText: 'Require account' }).click();
await expect(page.getByTestId('documentAccessSelectValue')).toContainText('Require account');
// Set EE action auth.
if (isBillingEnabled) {
await page.getByTestId('documentActionSelectValue').click();
await page.getByLabel('Require passkey').getByText('Require passkey').click();
await page.getByRole('option').filter({ hasText: 'Require passkey' }).click();
await expect(page.getByTestId('documentActionSelectValue')).toContainText('Require passkey');
}
@ -220,8 +222,8 @@ test('[TEMPLATE]: should create a team document from a team template', async ({
// Apply require passkey for Recipient 1.
if (isBillingEnabled) {
await page.getByLabel('Show advanced settings').check();
await page.getByRole('combobox').first().click();
await page.getByLabel('Require passkey').click();
await page.getByTestId('documentActionSelectValue').click();
await page.getByRole('option').filter({ hasText: 'Require passkey' }).click();
}
await page.getByRole('button', { name: 'Continue' }).click();
@ -256,10 +258,12 @@ test('[TEMPLATE]: should create a team document from a team template', async ({
});
expect(document.title).toEqual('TEMPLATE_TITLE');
expect(documentAuth.documentAuthOption.globalAccessAuth).toEqual('ACCOUNT');
expect(documentAuth.documentAuthOption.globalActionAuth).toEqual(
isBillingEnabled ? 'PASSKEY' : null,
);
expect(documentAuth.documentAuthOption.globalAccessAuth).toContain('ACCOUNT');
if (isBillingEnabled) {
expect(documentAuth.documentAuthOption.globalActionAuth).toContain('PASSKEY');
}
expect(document.documentMeta?.dateFormat).toEqual('dd/MM/yyyy hh:mm a');
expect(document.documentMeta?.message).toEqual('MESSAGE');
expect(document.documentMeta?.redirectUrl).toEqual('https://documenso.com');
@ -280,11 +284,11 @@ test('[TEMPLATE]: should create a team document from a team template', async ({
});
if (isBillingEnabled) {
expect(recipientOneAuth.derivedRecipientActionAuth).toEqual('PASSKEY');
expect(recipientOneAuth.derivedRecipientActionAuth).toContain('PASSKEY');
}
expect(recipientOneAuth.derivedRecipientAccessAuth).toEqual('ACCOUNT');
expect(recipientTwoAuth.derivedRecipientAccessAuth).toEqual('ACCOUNT');
expect(recipientOneAuth.derivedRecipientAccessAuth).toContain('ACCOUNT');
expect(recipientTwoAuth.derivedRecipientAccessAuth).toContain('ACCOUNT');
});
/**

View File

@ -172,8 +172,8 @@ test('[DIRECT_TEMPLATES]: direct template link auth access', async ({ page }) =>
userId: user.id,
createTemplateOptions: {
authOptions: createDocumentAuthOptions({
globalAccessAuth: 'ACCOUNT',
globalActionAuth: null,
globalAccessAuth: ['ACCOUNT'],
globalActionAuth: [],
}),
},
});

View File

@ -19,6 +19,10 @@ export const DOCUMENT_AUTH_TYPES: Record<string, DocumentAuthTypeData> = {
key: DocumentAuth.TWO_FACTOR_AUTH,
value: 'Require 2FA',
},
[DocumentAuth.PASSWORD]: {
key: DocumentAuth.PASSWORD,
value: 'Require password',
},
[DocumentAuth.EXPLICIT_NONE]: {
key: DocumentAuth.EXPLICIT_NONE,
value: 'None (Overrides global settings)',

View File

@ -0,0 +1,20 @@
import { compare } from '@node-rs/bcrypt';
import { prisma } from '@documenso/prisma';
type VerifyPasswordOptions = {
userId: number;
password: string;
};
export const verifyPassword = async ({ userId, password }: VerifyPasswordOptions) => {
const user = await prisma.user.findUnique({
where: { id: userId },
});
if (!user || !user.password) {
return false;
}
return await compare(password, user.password);
};

View File

@ -23,6 +23,7 @@ import {
ZWebhookDocumentSchema,
mapDocumentToWebhookDocumentPayload,
} from '../../types/webhook-payload';
import { extractDocumentAuthMethods } from '../../utils/document-auth';
import { getIsRecipientsTurnToSign } from '../recipient/get-is-recipient-turn';
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
import { sendPendingEmail } from './send-pending-email';
@ -140,6 +141,11 @@ export const completeDocumentWithToken = async ({
},
});
const authOptions = extractDocumentAuthMethods({
documentAuth: document.authOptions,
recipientAuth: recipient.authOptions,
});
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_COMPLETED,
@ -154,6 +160,7 @@ export const completeDocumentWithToken = async ({
recipientName: recipient.name,
recipientId: recipient.id,
recipientRole: recipient.role,
actionAuth: authOptions.derivedRecipientActionAuth,
},
}),
});

View File

@ -39,8 +39,8 @@ export type CreateDocumentOptions = {
title: string;
externalId?: string;
visibility?: DocumentVisibility;
globalAccessAuth?: TDocumentAccessAuthTypes;
globalActionAuth?: TDocumentActionAuthTypes;
globalAccessAuth?: TDocumentAccessAuthTypes[];
globalActionAuth?: TDocumentActionAuthTypes[];
formValues?: TDocumentFormValues;
recipients: TCreateDocumentV2Request['recipients'];
};
@ -113,14 +113,16 @@ export const createDocumentV2 = async ({
}
const authOptions = createDocumentAuthOptions({
globalAccessAuth: data?.globalAccessAuth || null,
globalActionAuth: data?.globalActionAuth || null,
globalAccessAuth: data?.globalAccessAuth || [],
globalActionAuth: data?.globalActionAuth || [],
});
const recipientsHaveActionAuth = data.recipients?.some((recipient) => recipient.actionAuth);
const recipientsHaveActionAuth = data.recipients?.some(
(recipient) => recipient.actionAuth && recipient.actionAuth.length > 0,
);
// Check if user has permission to set the global action auth.
if (authOptions.globalActionAuth || recipientsHaveActionAuth) {
if (authOptions.globalActionAuth.length > 0 || recipientsHaveActionAuth) {
const isDocumentEnterprise = await isUserEnterprise({
userId,
teamId,
@ -171,8 +173,8 @@ export const createDocumentV2 = async ({
await Promise.all(
(data.recipients || []).map(async (recipient) => {
const recipientAuthOptions = createRecipientAuthOptions({
accessAuth: recipient.accessAuth || null,
actionAuth: recipient.actionAuth || null,
accessAuth: recipient.accessAuth ?? [],
actionAuth: recipient.actionAuth ?? [],
});
await tx.recipient.create({

View File

@ -17,6 +17,7 @@ export const getDocumentCertificateAuditLogs = async ({
in: [
DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_COMPLETED,
DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_REJECTED,
DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_FIELD_INSERTED,
DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_OPENED,
DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT,
],
@ -36,6 +37,9 @@ export const getDocumentCertificateAuditLogs = async ({
[DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_OPENED]: auditLogs.filter(
(log) => log.type === DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_OPENED,
),
[DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_FIELD_INSERTED]: auditLogs.filter(
(log) => log.type === DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_FIELD_INSERTED,
),
[DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT]: auditLogs.filter(
(log) =>
log.type === DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT &&

View File

@ -5,6 +5,7 @@ import { match } from 'ts-pattern';
import { prisma } from '@documenso/prisma';
import { verifyTwoFactorAuthenticationToken } from '../2fa/verify-2fa-token';
import { verifyPassword } from '../2fa/verify-password';
import { AppError, AppErrorCode } from '../../errors/app-error';
import type { TDocumentAuth, TDocumentAuthMethods } from '../../types/document-auth';
import { DocumentAuth } from '../../types/document-auth';
@ -60,23 +61,26 @@ export const isRecipientAuthorized = async ({
recipientAuth: recipient.authOptions,
});
const authMethod: TDocumentAuth | null =
const authMethods: TDocumentAuth[] =
type === 'ACCESS' ? derivedRecipientAccessAuth : derivedRecipientActionAuth;
// Early true return when auth is not required.
if (!authMethod || authMethod === DocumentAuth.EXPLICIT_NONE) {
if (
authMethods.length === 0 ||
authMethods.some((method) => method === DocumentAuth.EXPLICIT_NONE)
) {
return true;
}
// Create auth options when none are passed for account.
if (!authOptions && authMethod === DocumentAuth.ACCOUNT) {
if (!authOptions && authMethods.some((method) => method === DocumentAuth.ACCOUNT)) {
authOptions = {
type: DocumentAuth.ACCOUNT,
};
}
// Authentication required does not match provided method.
if (!authOptions || authOptions.type !== authMethod || !userId) {
if (!authOptions || !authMethods.includes(authOptions.type) || !userId) {
return false;
}
@ -117,6 +121,15 @@ export const isRecipientAuthorized = async ({
window: 10, // 5 minutes worth of tokens
});
})
.with({ type: DocumentAuth.PASSWORD }, async ({ password }) => {
return await verifyPassword({
userId,
password,
});
})
.with({ type: DocumentAuth.EXPLICIT_NONE }, () => {
return true;
})
.exhaustive();
};
@ -160,7 +173,7 @@ const verifyPasskey = async ({
}: VerifyPasskeyOptions): Promise<void> => {
const passkey = await prisma.passkey.findFirst({
where: {
credentialId: Buffer.from(authenticationResponse.id, 'base64'),
credentialId: new Uint8Array(Buffer.from(authenticationResponse.id, 'base64')),
userId,
},
});

View File

@ -21,8 +21,8 @@ export type UpdateDocumentOptions = {
title?: string;
externalId?: string | null;
visibility?: DocumentVisibility | null;
globalAccessAuth?: TDocumentAccessAuthTypes | null;
globalActionAuth?: TDocumentActionAuthTypes | null;
globalAccessAuth?: TDocumentAccessAuthTypes[];
globalActionAuth?: TDocumentActionAuthTypes[];
useLegacyFieldInsertion?: boolean;
};
requestMetadata: ApiRequestMetadata;
@ -119,7 +119,6 @@ export const updateDocument = async ({
// If no data just return the document since this function is normally chained after a meta update.
if (!data || Object.values(data).length === 0) {
console.log('no data');
return document;
}
@ -137,7 +136,7 @@ export const updateDocument = async ({
data?.globalActionAuth === undefined ? documentGlobalActionAuth : data.globalActionAuth;
// Check if user has permission to set the global action auth.
if (newGlobalActionAuth) {
if (newGlobalActionAuth && newGlobalActionAuth.length > 0) {
const isDocumentEnterprise = await isUserEnterprise({
userId,
teamId,

View File

@ -3,7 +3,6 @@ import { FieldType } from '@prisma/client';
import { AppError, AppErrorCode } from '../../errors/app-error';
import type { TRecipientActionAuth } from '../../types/document-auth';
import { extractDocumentAuthMethods } from '../../utils/document-auth';
import { isRecipientAuthorized } from './is-recipient-authorized';
export type ValidateFieldAuthOptions = {
@ -26,14 +25,9 @@ export const validateFieldAuth = async ({
userId,
authOptions,
}: ValidateFieldAuthOptions) => {
const { derivedRecipientActionAuth } = extractDocumentAuthMethods({
documentAuth: documentAuthOptions,
recipientAuth: recipient.authOptions,
});
// Override all non-signature fields to not require any auth.
if (field.type !== FieldType.SIGNATURE) {
return null;
return undefined;
}
const isValid = await isRecipientAuthorized({
@ -50,5 +44,5 @@ export const validateFieldAuth = async ({
});
}
return derivedRecipientActionAuth;
return authOptions?.type;
};

View File

@ -15,7 +15,7 @@ import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
export type ViewedDocumentOptions = {
token: string;
recipientAccessAuth?: TDocumentAccessAuthTypes | null;
recipientAccessAuth?: TDocumentAccessAuthTypes[];
requestMetadata?: RequestMetadata;
};
@ -63,7 +63,7 @@ export const viewedDocument = async ({
recipientId: recipient.id,
recipientName: recipient.name,
recipientRole: recipient.role,
accessAuth: recipientAccessAuth || undefined,
accessAuth: recipientAccessAuth ?? [],
},
}),
});

View File

@ -27,7 +27,6 @@ export const createEmbeddingPresignToken = async ({
// In development mode, allow setting expiresIn to 0 for testing
// In production, enforce a minimum expiration time
const isDevelopment = env('NODE_ENV') !== 'production';
console.log('isDevelopment', isDevelopment);
const minExpirationMinutes = isDevelopment ? 0 : 5;
// Ensure expiresIn is at least the minimum allowed value

View File

@ -22,8 +22,8 @@ export interface CreateDocumentRecipientsOptions {
name: string;
role: RecipientRole;
signingOrder?: number | null;
accessAuth?: TRecipientAccessAuthTypes | null;
actionAuth?: TRecipientActionAuthTypes | null;
accessAuth?: TRecipientAccessAuthTypes[];
actionAuth?: TRecipientActionAuthTypes[];
}[];
requestMetadata: ApiRequestMetadata;
}
@ -71,7 +71,9 @@ export const createDocumentRecipients = async ({
});
}
const recipientsHaveActionAuth = recipientsToCreate.some((recipient) => recipient.actionAuth);
const recipientsHaveActionAuth = recipientsToCreate.some(
(recipient) => recipient.actionAuth && recipient.actionAuth.length > 0,
);
// Check if user has permission to set the global action auth.
if (recipientsHaveActionAuth) {
@ -110,8 +112,8 @@ export const createDocumentRecipients = async ({
return await Promise.all(
normalizedRecipients.map(async (recipient) => {
const authOptions = createRecipientAuthOptions({
accessAuth: recipient.accessAuth || null,
actionAuth: recipient.actionAuth || null,
accessAuth: recipient.accessAuth ?? [],
actionAuth: recipient.actionAuth ?? [],
});
const createdRecipient = await tx.recipient.create({
@ -140,8 +142,8 @@ export const createDocumentRecipients = async ({
recipientName: createdRecipient.name,
recipientId: createdRecipient.id,
recipientRole: createdRecipient.role,
accessAuth: recipient.accessAuth || undefined,
actionAuth: recipient.actionAuth || undefined,
accessAuth: recipient.accessAuth ?? [],
actionAuth: recipient.actionAuth ?? [],
},
}),
});

View File

@ -19,8 +19,8 @@ export interface CreateTemplateRecipientsOptions {
name: string;
role: RecipientRole;
signingOrder?: number | null;
accessAuth?: TRecipientAccessAuthTypes | null;
actionAuth?: TRecipientActionAuthTypes | null;
accessAuth?: TRecipientAccessAuthTypes[];
actionAuth?: TRecipientActionAuthTypes[];
}[];
}
@ -60,7 +60,9 @@ export const createTemplateRecipients = async ({
});
}
const recipientsHaveActionAuth = recipientsToCreate.some((recipient) => recipient.actionAuth);
const recipientsHaveActionAuth = recipientsToCreate.some(
(recipient) => recipient.actionAuth && recipient.actionAuth.length > 0,
);
// Check if user has permission to set the global action auth.
if (recipientsHaveActionAuth) {
@ -99,8 +101,8 @@ export const createTemplateRecipients = async ({
return await Promise.all(
normalizedRecipients.map(async (recipient) => {
const authOptions = createRecipientAuthOptions({
accessAuth: recipient.accessAuth || null,
actionAuth: recipient.actionAuth || null,
accessAuth: recipient.accessAuth ?? [],
actionAuth: recipient.actionAuth ?? [],
});
const createdRecipient = await tx.recipient.create({

View File

@ -4,6 +4,7 @@ import { msg } from '@lingui/core/macro';
import type { Recipient } from '@prisma/client';
import { RecipientRole } from '@prisma/client';
import { SendStatus, SigningStatus } from '@prisma/client';
import { isDeepEqual } from 'remeda';
import { isUserEnterprise } from '@documenso/ee/server-only/util/is-document-enterprise';
import { mailer } from '@documenso/email/mailer';
@ -96,7 +97,9 @@ export const setDocumentRecipients = async ({
throw new Error('Document already complete');
}
const recipientsHaveActionAuth = recipients.some((recipient) => recipient.actionAuth);
const recipientsHaveActionAuth = recipients.some(
(recipient) => recipient.actionAuth && recipient.actionAuth.length > 0,
);
// Check if user has permission to set the global action auth.
if (recipientsHaveActionAuth) {
@ -245,8 +248,8 @@ export const setDocumentRecipients = async ({
metadata: requestMetadata,
data: {
...baseAuditLog,
accessAuth: recipient.accessAuth || undefined,
actionAuth: recipient.actionAuth || undefined,
accessAuth: recipient.accessAuth || [],
actionAuth: recipient.actionAuth || [],
},
}),
});
@ -361,8 +364,8 @@ type RecipientData = {
name: string;
role: RecipientRole;
signingOrder?: number | null;
accessAuth?: TRecipientAccessAuthTypes | null;
actionAuth?: TRecipientActionAuthTypes | null;
accessAuth?: TRecipientAccessAuthTypes[];
actionAuth?: TRecipientActionAuthTypes[];
};
type RecipientDataWithClientId = Recipient & {
@ -372,15 +375,15 @@ type RecipientDataWithClientId = Recipient & {
const hasRecipientBeenChanged = (recipient: Recipient, newRecipientData: RecipientData) => {
const authOptions = ZRecipientAuthOptionsSchema.parse(recipient.authOptions);
const newRecipientAccessAuth = newRecipientData.accessAuth || null;
const newRecipientActionAuth = newRecipientData.actionAuth || null;
const newRecipientAccessAuth = newRecipientData.accessAuth || [];
const newRecipientActionAuth = newRecipientData.actionAuth || [];
return (
recipient.email !== newRecipientData.email ||
recipient.name !== newRecipientData.name ||
recipient.role !== newRecipientData.role ||
recipient.signingOrder !== newRecipientData.signingOrder ||
authOptions.accessAuth !== newRecipientAccessAuth ||
authOptions.actionAuth !== newRecipientActionAuth
!isDeepEqual(authOptions.accessAuth, newRecipientAccessAuth) ||
!isDeepEqual(authOptions.actionAuth, newRecipientActionAuth)
);
};

View File

@ -26,7 +26,7 @@ export type SetTemplateRecipientsOptions = {
name: string;
role: RecipientRole;
signingOrder?: number | null;
actionAuth?: TRecipientActionAuthTypes | null;
actionAuth?: TRecipientActionAuthTypes[];
}[];
};
@ -64,7 +64,9 @@ export const setTemplateRecipients = async ({
throw new Error('Template not found');
}
const recipientsHaveActionAuth = recipients.some((recipient) => recipient.actionAuth);
const recipientsHaveActionAuth = recipients.some(
(recipient) => recipient.actionAuth && recipient.actionAuth.length > 0,
);
// Check if user has permission to set the global action auth.
if (recipientsHaveActionAuth) {

View File

@ -1,6 +1,7 @@
import type { Recipient } from '@prisma/client';
import { RecipientRole } from '@prisma/client';
import { SendStatus, SigningStatus } from '@prisma/client';
import { isDeepEqual } from 'remeda';
import { isUserEnterprise } from '@documenso/ee/server-only/util/is-document-enterprise';
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
@ -72,7 +73,9 @@ export const updateDocumentRecipients = async ({
});
}
const recipientsHaveActionAuth = recipients.some((recipient) => recipient.actionAuth);
const recipientsHaveActionAuth = recipients.some(
(recipient) => recipient.actionAuth && recipient.actionAuth.length > 0,
);
// Check if user has permission to set the global action auth.
if (recipientsHaveActionAuth) {
@ -218,8 +221,8 @@ type RecipientData = {
name?: string;
role?: RecipientRole;
signingOrder?: number | null;
accessAuth?: TRecipientAccessAuthTypes | null;
actionAuth?: TRecipientActionAuthTypes | null;
accessAuth?: TRecipientAccessAuthTypes[];
actionAuth?: TRecipientActionAuthTypes[];
};
const hasRecipientBeenChanged = (recipient: Recipient, newRecipientData: RecipientData) => {
@ -233,7 +236,7 @@ const hasRecipientBeenChanged = (recipient: Recipient, newRecipientData: Recipie
recipient.name !== newRecipientData.name ||
recipient.role !== newRecipientData.role ||
recipient.signingOrder !== newRecipientData.signingOrder ||
authOptions.accessAuth !== newRecipientAccessAuth ||
authOptions.actionAuth !== newRecipientActionAuth
!isDeepEqual(authOptions.accessAuth, newRecipientAccessAuth) ||
!isDeepEqual(authOptions.actionAuth, newRecipientActionAuth)
);
};

View File

@ -20,7 +20,7 @@ export type UpdateRecipientOptions = {
name?: string;
role?: RecipientRole;
signingOrder?: number | null;
actionAuth?: TRecipientActionAuthTypes | null;
actionAuth?: TRecipientActionAuthTypes[];
userId: number;
teamId?: number;
requestMetadata?: RequestMetadata;
@ -90,7 +90,7 @@ export const updateRecipient = async ({
throw new Error('Recipient not found');
}
if (actionAuth) {
if (actionAuth && actionAuth.length > 0) {
const isDocumentEnterprise = await isUserEnterprise({
userId,
teamId,
@ -117,7 +117,7 @@ export const updateRecipient = async ({
signingOrder,
authOptions: createRecipientAuthOptions({
accessAuth: recipientAuthOptions.accessAuth,
actionAuth: actionAuth ?? null,
actionAuth: actionAuth ?? [],
}),
},
});

View File

@ -22,8 +22,8 @@ export interface UpdateTemplateRecipientsOptions {
name?: string;
role?: RecipientRole;
signingOrder?: number | null;
accessAuth?: TRecipientAccessAuthTypes | null;
actionAuth?: TRecipientActionAuthTypes | null;
accessAuth?: TRecipientAccessAuthTypes[];
actionAuth?: TRecipientActionAuthTypes[];
}[];
}
@ -63,7 +63,9 @@ export const updateTemplateRecipients = async ({
});
}
const recipientsHaveActionAuth = recipients.some((recipient) => recipient.actionAuth);
const recipientsHaveActionAuth = recipients.some(
(recipient) => recipient.actionAuth && recipient.actionAuth.length > 0,
);
// Check if user has permission to set the global action auth.
if (recipientsHaveActionAuth) {

View File

@ -70,7 +70,7 @@ export type CreateDocumentFromDirectTemplateOptions = {
type CreatedDirectRecipientField = {
field: Field & { signature?: Signature | null };
derivedRecipientActionAuth: TRecipientActionAuthTypes | null;
derivedRecipientActionAuth?: TRecipientActionAuthTypes;
};
export const ZCreateDocumentFromDirectTemplateResponseSchema = z.object({
@ -151,9 +151,9 @@ export const createDocumentFromDirectTemplate = async ({
const directRecipientName = user?.name || initialDirectRecipientName;
// Ensure typesafety when we add more options.
const isAccessAuthValid = match(derivedRecipientAccessAuth)
const isAccessAuthValid = match(derivedRecipientAccessAuth.at(0))
.with(DocumentAccessAuth.ACCOUNT, () => user && user?.email === directRecipientEmail)
.with(null, () => true)
.with(undefined, () => true)
.exhaustive();
if (!isAccessAuthValid) {
@ -460,7 +460,7 @@ export const createDocumentFromDirectTemplate = async ({
const createdDirectRecipientFields: CreatedDirectRecipientField[] = [
...createdDirectRecipient.fields.map((field) => ({
field,
derivedRecipientActionAuth: null,
derivedRecipientActionAuth: undefined,
})),
...createdDirectRecipientSignatureFields,
];
@ -567,6 +567,7 @@ export const createDocumentFromDirectTemplate = async ({
recipientId: createdDirectRecipient.id,
recipientName: createdDirectRecipient.name,
recipientRole: createdDirectRecipient.role,
actionAuth: createdDirectRecipient.authOptions?.actionAuth ?? [],
},
}),
];

View File

@ -15,8 +15,8 @@ export type UpdateTemplateOptions = {
title?: string;
externalId?: string | null;
visibility?: DocumentVisibility;
globalAccessAuth?: TDocumentAccessAuthTypes | null;
globalActionAuth?: TDocumentActionAuthTypes | null;
globalAccessAuth?: TDocumentAccessAuthTypes[];
globalActionAuth?: TDocumentActionAuthTypes[];
publicTitle?: string;
publicDescription?: string;
type?: Template['type'];
@ -74,7 +74,7 @@ export const updateTemplate = async ({
data?.globalActionAuth === undefined ? documentGlobalActionAuth : data.globalActionAuth;
// Check if user has permission to set the global action auth.
if (newGlobalActionAuth) {
if (newGlobalActionAuth && newGlobalActionAuth.length > 0) {
const isDocumentEnterprise = await isUserEnterprise({
userId,
teamId,

View File

@ -123,8 +123,8 @@ export const ZDocumentAuditLogFieldDiffSchema = z.union([
]);
export const ZGenericFromToSchema = z.object({
from: z.string().nullable(),
to: z.string().nullable(),
from: z.union([z.string(), z.array(z.string())]).nullable(),
to: z.union([z.string(), z.array(z.string())]).nullable(),
});
export const ZRecipientDiffActionAuthSchema = ZGenericFromToSchema.extend({
@ -296,7 +296,7 @@ export const ZDocumentAuditLogEventDocumentFieldInsertedSchema = z.object({
},
z
.object({
type: ZRecipientActionAuthTypesSchema,
type: ZRecipientActionAuthTypesSchema.optional(),
})
.optional(),
),
@ -384,7 +384,7 @@ export const ZDocumentAuditLogEventDocumentFieldPrefilledSchema = z.object({
},
z
.object({
type: ZRecipientActionAuthTypesSchema,
type: ZRecipientActionAuthTypesSchema.optional(),
})
.optional(),
),
@ -428,7 +428,13 @@ export const ZDocumentAuditLogEventDocumentMetaUpdatedSchema = z.object({
export const ZDocumentAuditLogEventDocumentOpenedSchema = z.object({
type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_OPENED),
data: ZBaseRecipientDataSchema.extend({
accessAuth: z.string().optional(),
accessAuth: z.preprocess((unknownValue) => {
if (!unknownValue) {
return [];
}
return Array.isArray(unknownValue) ? unknownValue : [unknownValue];
}, z.array(ZRecipientAccessAuthTypesSchema)),
}),
});
@ -438,7 +444,13 @@ export const ZDocumentAuditLogEventDocumentOpenedSchema = z.object({
export const ZDocumentAuditLogEventDocumentRecipientCompleteSchema = z.object({
type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_COMPLETED),
data: ZBaseRecipientDataSchema.extend({
actionAuth: z.string().optional(),
actionAuth: z.preprocess((unknownValue) => {
if (!unknownValue) {
return [];
}
return Array.isArray(unknownValue) ? unknownValue : [unknownValue];
}, z.array(ZRecipientActionAuthTypesSchema)),
}),
});
@ -516,8 +528,20 @@ export const ZDocumentAuditLogEventFieldUpdatedSchema = z.object({
export const ZDocumentAuditLogEventRecipientAddedSchema = z.object({
type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_CREATED),
data: ZBaseRecipientDataSchema.extend({
accessAuth: ZRecipientAccessAuthTypesSchema.optional(),
actionAuth: ZRecipientActionAuthTypesSchema.optional(),
accessAuth: z.preprocess((unknownValue) => {
if (!unknownValue) {
return [];
}
return Array.isArray(unknownValue) ? unknownValue : [unknownValue];
}, z.array(ZRecipientAccessAuthTypesSchema)),
actionAuth: z.preprocess((unknownValue) => {
if (!unknownValue) {
return [];
}
return Array.isArray(unknownValue) ? unknownValue : [unknownValue];
}, z.array(ZRecipientActionAuthTypesSchema)),
}),
});

View File

@ -9,8 +9,10 @@ export const ZDocumentAuthTypesSchema = z.enum([
'ACCOUNT',
'PASSKEY',
'TWO_FACTOR_AUTH',
'PASSWORD',
'EXPLICIT_NONE',
]);
export const DocumentAuth = ZDocumentAuthTypesSchema.Enum;
const ZDocumentAuthAccountSchema = z.object({
@ -27,6 +29,11 @@ const ZDocumentAuthPasskeySchema = z.object({
tokenReference: z.string().min(1),
});
const ZDocumentAuthPasswordSchema = z.object({
type: z.literal(DocumentAuth.PASSWORD),
password: z.string().min(1),
});
const ZDocumentAuth2FASchema = z.object({
type: z.literal(DocumentAuth.TWO_FACTOR_AUTH),
token: z.string().min(4).max(10),
@ -40,6 +47,7 @@ export const ZDocumentAuthMethodsSchema = z.discriminatedUnion('type', [
ZDocumentAuthExplicitNoneSchema,
ZDocumentAuthPasskeySchema,
ZDocumentAuth2FASchema,
ZDocumentAuthPasswordSchema,
]);
/**
@ -61,9 +69,15 @@ export const ZDocumentActionAuthSchema = z.discriminatedUnion('type', [
ZDocumentAuthAccountSchema,
ZDocumentAuthPasskeySchema,
ZDocumentAuth2FASchema,
ZDocumentAuthPasswordSchema,
]);
export const ZDocumentActionAuthTypesSchema = z
.enum([DocumentAuth.ACCOUNT, DocumentAuth.PASSKEY, DocumentAuth.TWO_FACTOR_AUTH])
.enum([
DocumentAuth.ACCOUNT,
DocumentAuth.PASSKEY,
DocumentAuth.TWO_FACTOR_AUTH,
DocumentAuth.PASSWORD,
])
.describe(
'The type of authentication required for the recipient to sign the document. This field is restricted to Enterprise plan users only.',
);
@ -89,6 +103,7 @@ export const ZRecipientActionAuthSchema = z.discriminatedUnion('type', [
ZDocumentAuthAccountSchema,
ZDocumentAuthPasskeySchema,
ZDocumentAuth2FASchema,
ZDocumentAuthPasswordSchema,
ZDocumentAuthExplicitNoneSchema,
]);
export const ZRecipientActionAuthTypesSchema = z
@ -96,6 +111,7 @@ export const ZRecipientActionAuthTypesSchema = z
DocumentAuth.ACCOUNT,
DocumentAuth.PASSKEY,
DocumentAuth.TWO_FACTOR_AUTH,
DocumentAuth.PASSWORD,
DocumentAuth.EXPLICIT_NONE,
])
.describe('The type of authentication required for the recipient to sign the document.');
@ -110,18 +126,26 @@ export const RecipientActionAuth = ZRecipientActionAuthTypesSchema.Enum;
*/
export const ZDocumentAuthOptionsSchema = z.preprocess(
(unknownValue) => {
if (unknownValue) {
return unknownValue;
if (!unknownValue || typeof unknownValue !== 'object') {
return {
globalAccessAuth: [],
globalActionAuth: [],
};
}
const globalAccessAuth =
'globalAccessAuth' in unknownValue ? processAuthValue(unknownValue.globalAccessAuth) : [];
const globalActionAuth =
'globalActionAuth' in unknownValue ? processAuthValue(unknownValue.globalActionAuth) : [];
return {
globalAccessAuth: null,
globalActionAuth: null,
globalAccessAuth,
globalActionAuth,
};
},
z.object({
globalAccessAuth: ZDocumentAccessAuthTypesSchema.nullable(),
globalActionAuth: ZDocumentActionAuthTypesSchema.nullable(),
globalAccessAuth: z.array(ZDocumentAccessAuthTypesSchema),
globalActionAuth: z.array(ZDocumentActionAuthTypesSchema),
}),
);
@ -130,21 +154,46 @@ export const ZDocumentAuthOptionsSchema = z.preprocess(
*/
export const ZRecipientAuthOptionsSchema = z.preprocess(
(unknownValue) => {
if (unknownValue) {
return unknownValue;
if (!unknownValue || typeof unknownValue !== 'object') {
return {
accessAuth: [],
actionAuth: [],
};
}
const accessAuth =
'accessAuth' in unknownValue ? processAuthValue(unknownValue.accessAuth) : [];
const actionAuth =
'actionAuth' in unknownValue ? processAuthValue(unknownValue.actionAuth) : [];
return {
accessAuth: null,
actionAuth: null,
accessAuth,
actionAuth,
};
},
z.object({
accessAuth: ZRecipientAccessAuthTypesSchema.nullable(),
actionAuth: ZRecipientActionAuthTypesSchema.nullable(),
accessAuth: z.array(ZRecipientAccessAuthTypesSchema),
actionAuth: z.array(ZRecipientActionAuthTypesSchema),
}),
);
/**
* Utility function to process the auth value.
*
* Converts the old singular auth value to an array of auth values.
*/
const processAuthValue = (value: unknown) => {
if (value === null || value === undefined) {
return [];
}
if (Array.isArray(value)) {
return value;
}
return [value];
};
export type TDocumentAuth = z.infer<typeof ZDocumentAuthTypesSchema>;
export type TDocumentAuthMethods = z.infer<typeof ZDocumentAuthMethodsSchema>;
export type TDocumentAuthOptions = z.infer<typeof ZDocumentAuthOptionsSchema>;

View File

@ -2,6 +2,7 @@ import type { I18n } from '@lingui/core';
import { msg } from '@lingui/core/macro';
import type { DocumentAuditLog, DocumentMeta, Field, Recipient } from '@prisma/client';
import { RecipientRole } from '@prisma/client';
import { isDeepEqual } from 'remeda';
import { match } from 'ts-pattern';
import type {
@ -106,7 +107,7 @@ export const diffRecipientChanges = (
const newActionAuth =
newAuthOptions?.actionAuth === undefined ? oldActionAuth : newAuthOptions.actionAuth;
if (oldAccessAuth !== newAccessAuth) {
if (!isDeepEqual(oldAccessAuth, newAccessAuth)) {
diffs.push({
type: RECIPIENT_DIFF_TYPE.ACCESS_AUTH,
from: oldAccessAuth ?? '',
@ -114,7 +115,7 @@ export const diffRecipientChanges = (
});
}
if (oldActionAuth !== newActionAuth) {
if (!isDeepEqual(oldActionAuth, newActionAuth)) {
diffs.push({
type: RECIPIENT_DIFF_TYPE.ACTION_AUTH,
from: oldActionAuth ?? '',

View File

@ -27,17 +27,21 @@ export const extractDocumentAuthMethods = ({
const documentAuthOption = ZDocumentAuthOptionsSchema.parse(documentAuth);
const recipientAuthOption = ZRecipientAuthOptionsSchema.parse(recipientAuth);
const derivedRecipientAccessAuth: TRecipientAccessAuthTypes | null =
recipientAuthOption.accessAuth || documentAuthOption.globalAccessAuth;
const derivedRecipientAccessAuth: TRecipientAccessAuthTypes[] =
recipientAuthOption.accessAuth.length > 0
? recipientAuthOption.accessAuth
: documentAuthOption.globalAccessAuth;
const derivedRecipientActionAuth: TRecipientActionAuthTypes | null =
recipientAuthOption.actionAuth || documentAuthOption.globalActionAuth;
const derivedRecipientActionAuth: TRecipientActionAuthTypes[] =
recipientAuthOption.actionAuth.length > 0
? recipientAuthOption.actionAuth
: documentAuthOption.globalActionAuth;
const recipientAccessAuthRequired = derivedRecipientAccessAuth !== null;
const recipientAccessAuthRequired = derivedRecipientAccessAuth.length > 0;
const recipientActionAuthRequired =
derivedRecipientActionAuth !== DocumentAuth.EXPLICIT_NONE &&
derivedRecipientActionAuth !== null;
derivedRecipientActionAuth.length > 0 &&
!derivedRecipientActionAuth.includes(DocumentAuth.EXPLICIT_NONE);
return {
derivedRecipientAccessAuth,
@ -54,8 +58,8 @@ export const extractDocumentAuthMethods = ({
*/
export const createDocumentAuthOptions = (options: TDocumentAuthOptions): TDocumentAuthOptions => {
return {
globalAccessAuth: options?.globalAccessAuth ?? null,
globalActionAuth: options?.globalActionAuth ?? null,
globalAccessAuth: options?.globalAccessAuth ?? [],
globalActionAuth: options?.globalActionAuth ?? [],
};
};
@ -66,7 +70,7 @@ export const createRecipientAuthOptions = (
options: TRecipientAuthOptions,
): TRecipientAuthOptions => {
return {
accessAuth: options?.accessAuth ?? null,
actionAuth: options?.actionAuth ?? null,
accessAuth: options?.accessAuth ?? [],
actionAuth: options?.actionAuth ?? [],
};
};

View File

@ -206,8 +206,8 @@ export const ZCreateDocumentV2RequestSchema = z.object({
title: ZDocumentTitleSchema,
externalId: ZDocumentExternalIdSchema.optional(),
visibility: ZDocumentVisibilitySchema.optional(),
globalAccessAuth: ZDocumentAccessAuthTypesSchema.optional(),
globalActionAuth: ZDocumentActionAuthTypesSchema.optional(),
globalAccessAuth: z.array(ZDocumentAccessAuthTypesSchema).optional(),
globalActionAuth: z.array(ZDocumentActionAuthTypesSchema).optional(),
formValues: ZDocumentFormValuesSchema.optional(),
recipients: z
.array(

View File

@ -42,8 +42,8 @@ export const ZUpdateDocumentRequestSchema = z.object({
title: ZDocumentTitleSchema.optional(),
externalId: ZDocumentExternalIdSchema.nullish(),
visibility: ZDocumentVisibilitySchema.optional(),
globalAccessAuth: ZDocumentAccessAuthTypesSchema.nullish(),
globalActionAuth: ZDocumentActionAuthTypesSchema.nullish(),
globalAccessAuth: z.array(ZDocumentAccessAuthTypesSchema).optional(),
globalActionAuth: z.array(ZDocumentActionAuthTypesSchema).optional(),
useLegacyFieldInsertion: z.boolean().optional(),
})
.optional(),

View File

@ -26,8 +26,8 @@ export const ZCreateRecipientSchema = z.object({
name: z.string(),
role: z.nativeEnum(RecipientRole),
signingOrder: z.number().optional(),
accessAuth: ZRecipientAccessAuthTypesSchema.optional().nullable(),
actionAuth: ZRecipientActionAuthTypesSchema.optional().nullable(),
accessAuth: z.array(ZRecipientAccessAuthTypesSchema).optional().default([]),
actionAuth: z.array(ZRecipientActionAuthTypesSchema).optional().default([]),
});
export const ZUpdateRecipientSchema = z.object({
@ -36,8 +36,8 @@ export const ZUpdateRecipientSchema = z.object({
name: z.string().optional(),
role: z.nativeEnum(RecipientRole).optional(),
signingOrder: z.number().optional(),
accessAuth: ZRecipientAccessAuthTypesSchema.optional().nullable(),
actionAuth: ZRecipientActionAuthTypesSchema.optional().nullable(),
accessAuth: z.array(ZRecipientAccessAuthTypesSchema).optional().default([]),
actionAuth: z.array(ZRecipientActionAuthTypesSchema).optional().default([]),
});
export const ZCreateDocumentRecipientRequestSchema = z.object({
@ -106,7 +106,7 @@ export const ZSetDocumentRecipientsRequestSchema = z
name: z.string(),
role: z.nativeEnum(RecipientRole),
signingOrder: z.number().optional(),
actionAuth: ZRecipientActionAuthTypesSchema.optional().nullable(),
actionAuth: z.array(ZRecipientActionAuthTypesSchema).optional().default([]),
}),
),
})
@ -190,7 +190,7 @@ export const ZSetTemplateRecipientsRequestSchema = z
name: z.string(),
role: z.nativeEnum(RecipientRole),
signingOrder: z.number().optional(),
actionAuth: ZRecipientActionAuthTypesSchema.optional().nullable(),
actionAuth: z.array(ZRecipientActionAuthTypesSchema).optional().default([]),
}),
),
})

View File

@ -133,8 +133,8 @@ export const ZUpdateTemplateRequestSchema = z.object({
title: z.string().min(1).optional(),
externalId: z.string().nullish(),
visibility: z.nativeEnum(DocumentVisibility).optional(),
globalAccessAuth: ZDocumentAccessAuthTypesSchema.nullable().optional(),
globalActionAuth: ZDocumentActionAuthTypesSchema.nullable().optional(),
globalAccessAuth: z.array(ZDocumentAccessAuthTypesSchema).optional().default([]),
globalActionAuth: z.array(ZDocumentActionAuthTypesSchema).optional().default([]),
publicTitle: z
.string()
.trim()

View File

@ -1,51 +1,75 @@
import { forwardRef } from 'react';
import React from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import type { SelectProps } from '@radix-ui/react-select';
import { InfoIcon } from 'lucide-react';
import { DOCUMENT_AUTH_TYPES } from '@documenso/lib/constants/document-auth';
import { DocumentAccessAuth } from '@documenso/lib/types/document-auth';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@documenso/ui/primitives/select';
import { MultiSelect, type Option } from '@documenso/ui/primitives/multiselect';
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
export const DocumentGlobalAuthAccessSelect = forwardRef<HTMLButtonElement, SelectProps>(
(props, ref) => {
const { _ } = useLingui();
export interface DocumentGlobalAuthAccessSelectProps {
value?: string[];
defaultValue?: string[];
onValueChange?: (value: string[]) => void;
disabled?: boolean;
placeholder?: string;
}
return (
<Select {...props}>
<SelectTrigger ref={ref} className="bg-background text-muted-foreground">
<SelectValue
data-testid="documentAccessSelectValue"
placeholder={_(msg`No restrictions`)}
/>
</SelectTrigger>
export const DocumentGlobalAuthAccessSelect = ({
value,
defaultValue,
onValueChange,
disabled,
placeholder,
}: DocumentGlobalAuthAccessSelectProps) => {
const { _ } = useLingui();
<SelectContent position="popper">
{/* Note: -1 is remapped in the Zod schema to the required value. */}
<SelectItem value={'-1'}>
<Trans>No restrictions</Trans>
</SelectItem>
// Convert auth types to MultiSelect options
const authOptions: Option[] = [
{
value: '-1',
label: _(msg`No restrictions`),
},
...Object.values(DocumentAccessAuth).map((authType) => ({
value: authType,
label: DOCUMENT_AUTH_TYPES[authType].value,
})),
];
{Object.values(DocumentAccessAuth).map((authType) => (
<SelectItem key={authType} value={authType}>
{DOCUMENT_AUTH_TYPES[authType].value}
</SelectItem>
))}
</SelectContent>
</Select>
);
},
);
// Convert string array to Option array for MultiSelect
const selectedOptions =
(value
?.map((val) => authOptions.find((option) => option.value === val))
.filter(Boolean) as Option[]) || [];
// Convert default value to Option array
const defaultOptions =
(defaultValue
?.map((val) => authOptions.find((option) => option.value === val))
.filter(Boolean) as Option[]) || [];
const handleChange = (options: Option[]) => {
const values = options.map((option) => option.value);
onValueChange?.(values);
};
return (
<MultiSelect
value={selectedOptions}
defaultOptions={defaultOptions}
options={authOptions}
onChange={handleChange}
disabled={disabled}
placeholder={placeholder || _(msg`Select access methods`)}
className="bg-background text-muted-foreground"
hideClearAllButton={false}
data-testid="documentAccessSelectValue"
/>
);
};
DocumentGlobalAuthAccessSelect.displayName = 'DocumentGlobalAuthAccessSelect';
@ -63,7 +87,11 @@ export const DocumentGlobalAuthAccessTooltip = () => (
</h2>
<p>
<Trans>The authentication required for recipients to view the document.</Trans>
<Trans>The authentication methods required for recipients to view the document.</Trans>
</p>
<p className="mt-2">
<Trans>Multiple access methods can be selected.</Trans>
</p>
<ul className="ml-3.5 list-outside list-disc space-y-0.5 py-2">

View File

@ -1,54 +1,75 @@
import { forwardRef } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import type { SelectProps } from '@radix-ui/react-select';
import { InfoIcon } from 'lucide-react';
import { DOCUMENT_AUTH_TYPES } from '@documenso/lib/constants/document-auth';
import { DocumentActionAuth, DocumentAuth } from '@documenso/lib/types/document-auth';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@documenso/ui/primitives/select';
import { MultiSelect, type Option } from '@documenso/ui/primitives/multiselect';
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
export const DocumentGlobalAuthActionSelect = forwardRef<HTMLButtonElement, SelectProps>(
(props, ref) => {
const { _ } = useLingui();
export interface DocumentGlobalAuthActionSelectProps {
value?: string[];
defaultValue?: string[];
onValueChange?: (value: string[]) => void;
disabled?: boolean;
placeholder?: string;
}
return (
<Select {...props}>
<SelectTrigger className="bg-background text-muted-foreground">
<SelectValue
ref={ref}
data-testid="documentActionSelectValue"
placeholder={_(msg`No restrictions`)}
/>
</SelectTrigger>
export const DocumentGlobalAuthActionSelect = ({
value,
defaultValue,
onValueChange,
disabled,
placeholder,
}: DocumentGlobalAuthActionSelectProps) => {
const { _ } = useLingui();
<SelectContent position="popper">
{/* Note: -1 is remapped in the Zod schema to the required value. */}
<SelectItem value={'-1'}>
<Trans>No restrictions</Trans>
</SelectItem>
// Convert auth types to MultiSelect options
const authOptions: Option[] = [
{
value: '-1',
label: _(msg`No restrictions`),
},
...Object.values(DocumentActionAuth)
.filter((auth) => auth !== DocumentAuth.ACCOUNT)
.map((authType) => ({
value: authType,
label: DOCUMENT_AUTH_TYPES[authType].value,
})),
];
{Object.values(DocumentActionAuth)
.filter((auth) => auth !== DocumentAuth.ACCOUNT)
.map((authType) => (
<SelectItem key={authType} value={authType}>
{DOCUMENT_AUTH_TYPES[authType].value}
</SelectItem>
))}
</SelectContent>
</Select>
);
},
);
// Convert string array to Option array for MultiSelect
const selectedOptions =
(value
?.map((val) => authOptions.find((option) => option.value === val))
.filter(Boolean) as Option[]) || [];
// Convert default value to Option array
const defaultOptions =
(defaultValue
?.map((val) => authOptions.find((option) => option.value === val))
.filter(Boolean) as Option[]) || [];
const handleChange = (options: Option[]) => {
const values = options.map((option) => option.value);
onValueChange?.(values);
};
return (
<MultiSelect
value={selectedOptions}
defaultOptions={defaultOptions}
options={authOptions}
onChange={handleChange}
disabled={disabled}
placeholder={placeholder || _(msg`Select authentication methods`)}
className="bg-background text-muted-foreground"
hideClearAllButton={false}
data-testid="documentActionSelectValue"
/>
);
};
DocumentGlobalAuthActionSelect.displayName = 'DocumentGlobalAuthActionSelect';
@ -64,20 +85,19 @@ export const DocumentGlobalAuthActionTooltip = () => (
</h2>
<p>
<Trans>The authentication required for recipients to sign the signature field.</Trans>
<Trans>
The authentication methods required for recipients to sign the signature field.
</Trans>
</p>
<p>
<Trans>
This can be overriden by setting the authentication requirements directly on each
recipient in the next step.
These can be overriden by setting the authentication requirements directly on each
recipient in the next step. Multiple methods can be selected.
</Trans>
</p>
<ul className="ml-3.5 list-outside list-disc space-y-0.5 py-2">
{/* <li>
<strong>Require account</strong> - The recipient must be signed in
</li> */}
<li>
<Trans>
<strong>Require passkey</strong> - The recipient must have an account and passkey
@ -90,6 +110,14 @@ export const DocumentGlobalAuthActionTooltip = () => (
their settings
</Trans>
</li>
<li>
<Trans>
<strong>Require password</strong> - The recipient must have an account and password
configured via their settings, the password will be verified during signing
</Trans>
</li>
<li>
<Trans>
<strong>No restrictions</strong> - No authentication required

View File

@ -3,97 +3,124 @@ import React from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import type { SelectProps } from '@radix-ui/react-select';
import { InfoIcon } from 'lucide-react';
import { DOCUMENT_AUTH_TYPES } from '@documenso/lib/constants/document-auth';
import { RecipientActionAuth } from '@documenso/lib/types/document-auth';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@documenso/ui/primitives/select';
import { MultiSelect, type Option } from '@documenso/ui/primitives/multiselect';
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
export type RecipientActionAuthSelectProps = SelectProps;
export interface RecipientActionAuthSelectProps {
value?: string[];
defaultValue?: string[];
onValueChange?: (value: string[]) => void;
disabled?: boolean;
placeholder?: string;
}
export const RecipientActionAuthSelect = (props: RecipientActionAuthSelectProps) => {
export const RecipientActionAuthSelect = ({
value,
defaultValue,
onValueChange,
disabled,
placeholder,
}: RecipientActionAuthSelectProps) => {
const { _ } = useLingui();
// Convert auth types to MultiSelect options
const authOptions: Option[] = [
{
value: '-1',
label: _(msg`Inherit authentication method`),
},
...Object.values(RecipientActionAuth)
.filter((auth) => auth !== RecipientActionAuth.ACCOUNT)
.map((authType) => ({
value: authType,
label: DOCUMENT_AUTH_TYPES[authType].value,
})),
];
// Convert string array to Option array for MultiSelect
const selectedOptions =
(value
?.map((val) => authOptions.find((option) => option.value === val))
.filter(Boolean) as Option[]) || [];
// Convert default value to Option array
const defaultOptions =
(defaultValue
?.map((val) => authOptions.find((option) => option.value === val))
.filter(Boolean) as Option[]) || [];
const handleChange = (options: Option[]) => {
const values = options.map((option) => option.value);
onValueChange?.(values);
};
return (
<Select {...props}>
<SelectTrigger className="bg-background text-muted-foreground">
<SelectValue placeholder={_(msg`Inherit authentication method`)} />
<div className="relative">
<MultiSelect
value={selectedOptions}
defaultOptions={defaultOptions}
options={authOptions}
onChange={handleChange}
disabled={disabled}
placeholder={placeholder || _(msg`Select authentication methods`)}
className="bg-background text-muted-foreground"
maxSelected={4} // Allow selecting up to 4 auth methods
hideClearAllButton={false}
/>
<Tooltip>
<TooltipTrigger className="-mr-1 ml-auto">
<InfoIcon className="mx-2 h-4 w-4" />
</TooltipTrigger>
<Tooltip>
<TooltipTrigger className="absolute right-2 top-1/2 -translate-y-1/2">
<InfoIcon className="h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="text-foreground max-w-md p-4">
<h2>
<strong>
<Trans>Recipient action authentication</Trans>
</strong>
</h2>
<TooltipContent className="text-foreground max-w-md p-4">
<h2>
<strong>
<Trans>Recipient action authentication</Trans>
</strong>
</h2>
<p>
<Trans>The authentication required for recipients to sign fields</Trans>
</p>
<p>
<Trans>The authentication methods required for recipients to sign fields</Trans>
</p>
<p className="mt-2">
<Trans>This will override any global settings.</Trans>
</p>
<p className="mt-2">
<Trans>
These will override any global settings. Multiple methods can be selected.
</Trans>
</p>
<ul className="ml-3.5 list-outside list-disc space-y-0.5 py-2">
<li>
<Trans>
<strong>Inherit authentication method</strong> - Use the global action signing
authentication method configured in the "General Settings" step
</Trans>
</li>
{/* <li>
<strong>Require account</strong> - The recipient must be
signed in
</li> */}
<li>
<Trans>
<strong>Require passkey</strong> - The recipient must have an account and passkey
configured via their settings
</Trans>
</li>
<li>
<Trans>
<strong>Require 2FA</strong> - The recipient must have an account and 2FA enabled
via their settings
</Trans>
</li>
<li>
<Trans>
<strong>None</strong> - No authentication required
</Trans>
</li>
</ul>
</TooltipContent>
</Tooltip>
</SelectTrigger>
<SelectContent position="popper">
{/* Note: -1 is remapped in the Zod schema to the required value. */}
<SelectItem value="-1">
<Trans>Inherit authentication method</Trans>
</SelectItem>
{Object.values(RecipientActionAuth)
.filter((auth) => auth !== RecipientActionAuth.ACCOUNT)
.map((authType) => (
<SelectItem key={authType} value={authType}>
{DOCUMENT_AUTH_TYPES[authType].value}
</SelectItem>
))}
</SelectContent>
</Select>
<ul className="ml-3.5 list-outside list-disc space-y-0.5 py-2">
<li>
<Trans>
<strong>Inherit authentication method</strong> - Use the global action signing
authentication method configured in the "General Settings" step
</Trans>
</li>
<li>
<Trans>
<strong>Require passkey</strong> - The recipient must have an account and passkey
configured via their settings
</Trans>
</li>
<li>
<Trans>
<strong>Require 2FA</strong> - The recipient must have an account and 2FA enabled
via their settings
</Trans>
</li>
<li>
<Trans>
<strong>None</strong> - No authentication required
</Trans>
</li>
</ul>
</TooltipContent>
</Tooltip>
</div>
);
};

View File

@ -98,8 +98,8 @@ export const AddSettingsFormPartial = ({
title: document.title,
externalId: document.externalId || '',
visibility: document.visibility || '',
globalAccessAuth: documentAuthOption?.globalAccessAuth || undefined,
globalActionAuth: documentAuthOption?.globalActionAuth || undefined,
globalAccessAuth: documentAuthOption?.globalAccessAuth || [],
globalActionAuth: documentAuthOption?.globalActionAuth || [],
meta: {
timezone:
@ -131,6 +131,12 @@ export const AddSettingsFormPartial = ({
)
.otherwise(() => false);
const onFormSubmit = form.handleSubmit(onSubmit);
const onGoNextClick = () => {
void onFormSubmit().catch(console.error);
};
// We almost always want to set the timezone to the user's local timezone to avoid confusion
// when the document is signed.
useEffect(() => {
@ -214,7 +220,11 @@ export const AddSettingsFormPartial = ({
</FormLabel>
<FormControl>
<Select {...field} onValueChange={field.onChange}>
<Select
value={field.value}
disabled={field.disabled}
onValueChange={field.onChange}
>
<SelectTrigger className="bg-background">
<SelectValue />
</SelectTrigger>
@ -244,7 +254,11 @@ export const AddSettingsFormPartial = ({
</FormLabel>
<FormControl>
<DocumentGlobalAuthAccessSelect {...field} onValueChange={field.onChange} />
<DocumentGlobalAuthAccessSelect
value={field.value}
disabled={field.disabled}
onValueChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
@ -286,7 +300,11 @@ export const AddSettingsFormPartial = ({
</FormLabel>
<FormControl>
<DocumentGlobalAuthActionSelect {...field} onValueChange={field.onChange} />
<DocumentGlobalAuthActionSelect
value={field.value}
disabled={field.disabled}
onValueChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
@ -370,7 +388,7 @@ export const AddSettingsFormPartial = ({
<FormControl>
<Select
{...field}
value={field.value}
onValueChange={field.onChange}
disabled={documentHasBeenSent}
>
@ -406,7 +424,7 @@ export const AddSettingsFormPartial = ({
<Combobox
className="bg-background"
options={TIME_ZONES}
{...field}
value={field.value}
onChange={(value) => value && field.onChange(value)}
disabled={documentHasBeenSent}
/>
@ -461,7 +479,7 @@ export const AddSettingsFormPartial = ({
disabled={form.formState.isSubmitting}
canGoBack={stepIndex !== 0}
onGoBackClick={previousStep}
onGoNextClick={form.handleSubmit(onSubmit)}
onGoNextClick={onGoNextClick}
/>
</DocumentFlowFormContainerFooter>
</>

View File

@ -16,17 +16,6 @@ import {
ZDocumentMetaTimezoneSchema,
} from '@documenso/trpc/server/document-router/schema';
export const ZMapNegativeOneToUndefinedSchema = z
.string()
.optional()
.transform((val) => {
if (val === '-1') {
return undefined;
}
return val;
});
export const ZAddSettingsFormSchema = z.object({
title: z
.string()
@ -34,12 +23,8 @@ export const ZAddSettingsFormSchema = z.object({
.min(1, { message: msg`Title cannot be empty`.id }),
externalId: z.string().optional(),
visibility: z.nativeEnum(DocumentVisibility).optional(),
globalAccessAuth: ZMapNegativeOneToUndefinedSchema.pipe(
ZDocumentAccessAuthTypesSchema.optional(),
),
globalActionAuth: ZMapNegativeOneToUndefinedSchema.pipe(
ZDocumentActionAuthTypesSchema.optional(),
),
globalAccessAuth: z.array(ZDocumentAccessAuthTypesSchema),
globalActionAuth: z.array(ZDocumentActionAuthTypesSchema),
meta: z.object({
timezone: ZDocumentMetaTimezoneSchema.optional().default(DEFAULT_DOCUMENT_TIME_ZONE),
dateFormat: ZDocumentMetaDateFormatSchema.optional().default(DEFAULT_DOCUMENT_DATE_FORMAT),

View File

@ -85,7 +85,7 @@ export const AddSignersFormPartial = ({
email: '',
role: RecipientRole.SIGNER,
signingOrder: 1,
actionAuth: undefined,
actionAuth: [],
},
];
@ -119,10 +119,14 @@ export const AddSignersFormPartial = ({
const recipientHasAuthOptions = recipients.find((recipient) => {
const recipientAuthOptions = ZRecipientAuthOptionsSchema.parse(recipient.authOptions);
return recipientAuthOptions?.accessAuth || recipientAuthOptions?.actionAuth;
return (
recipientAuthOptions.accessAuth.length > 0 || recipientAuthOptions.actionAuth.length > 0
);
});
const formHasActionAuth = form.getValues('signers').find((signer) => signer.actionAuth);
const formHasActionAuth = form
.getValues('signers')
.find((signer) => signer.actionAuth.length > 0);
return recipientHasAuthOptions !== undefined || formHasActionAuth !== undefined;
}, [recipients, form]);
@ -190,7 +194,7 @@ export const AddSignersFormPartial = ({
name: '',
email: '',
role: RecipientRole.SIGNER,
actionAuth: undefined,
actionAuth: [],
signingOrder: signers.length > 0 ? (signers[signers.length - 1]?.signingOrder ?? 0) + 1 : 1,
});
};
@ -226,7 +230,7 @@ export const AddSignersFormPartial = ({
name: user?.name ?? '',
email: user?.email ?? '',
role: RecipientRole.SIGNER,
actionAuth: undefined,
actionAuth: [],
signingOrder: signers.length > 0 ? (signers[signers.length - 1]?.signingOrder ?? 0) + 1 : 1,
});
}

View File

@ -4,8 +4,6 @@ import { z } from 'zod';
import { ZRecipientActionAuthTypesSchema } from '@documenso/lib/types/document-auth';
import { ZMapNegativeOneToUndefinedSchema } from './add-settings.types';
export const ZAddSignersFormSchema = z
.object({
signers: z.array(
@ -19,9 +17,7 @@ export const ZAddSignersFormSchema = z
name: z.string(),
role: z.nativeEnum(RecipientRole),
signingOrder: z.number().optional(),
actionAuth: ZMapNegativeOneToUndefinedSchema.pipe(
ZRecipientActionAuthTypesSchema.optional(),
),
actionAuth: z.array(ZRecipientActionAuthTypesSchema).optional().default([]),
}),
),
signingOrder: z.nativeEnum(DocumentSigningOrder),

View File

@ -78,6 +78,8 @@ interface MultiSelectProps {
>;
/** hide the clear all button. */
hideClearAllButton?: boolean;
/** test id for the select value. */
'data-testid'?: string;
}
export interface MultiSelectRef {
@ -170,6 +172,7 @@ const MultiSelect = ({
commandProps,
inputProps,
hideClearAllButton = false,
'data-testid': dataTestId,
}: MultiSelectProps) => {
const inputRef = React.useRef<HTMLInputElement>(null);
const [open, setOpen] = React.useState(false);
@ -403,6 +406,7 @@ const MultiSelect = ({
commandProps?.shouldFilter !== undefined ? commandProps.shouldFilter : !onSearch
} // When onSearch is provided, we don&lsquo;t want to filter the options. You can still override it.
filter={commandFilter()}
data-testid={dataTestId}
>
<div
className={cn(

View File

@ -86,7 +86,7 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
{
formId: initialId,
role: RecipientRole.SIGNER,
actionAuth: undefined,
actionAuth: [],
...generateRecipientPlaceholder(1),
signingOrder: 1,
},
@ -136,10 +136,14 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
const recipientHasAuthOptions = recipients.find((recipient) => {
const recipientAuthOptions = ZRecipientAuthOptionsSchema.parse(recipient.authOptions);
return recipientAuthOptions?.accessAuth || recipientAuthOptions?.actionAuth;
return (
recipientAuthOptions.accessAuth.length > 0 || recipientAuthOptions.actionAuth.length > 0
);
});
const formHasActionAuth = form.getValues('signers').find((signer) => signer.actionAuth);
const formHasActionAuth = form
.getValues('signers')
.find((signer) => signer.actionAuth.length > 0);
return recipientHasAuthOptions !== undefined || formHasActionAuth !== undefined;
}, [recipients, form]);
@ -179,6 +183,7 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
email: user.email ?? '',
role: RecipientRole.SIGNER,
signingOrder: signers.length > 0 ? (signers[signers.length - 1]?.signingOrder ?? 0) + 1 : 1,
actionAuth: [],
});
};
@ -188,6 +193,7 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
role: RecipientRole.SIGNER,
...generateRecipientPlaceholder(placeholderRecipientCount),
signingOrder: signers.length > 0 ? (signers[signers.length - 1]?.signingOrder ?? 0) + 1 : 1,
actionAuth: [],
});
setPlaceholderRecipientCount((count) => count + 1);

View File

@ -3,8 +3,6 @@ import { z } from 'zod';
import { ZRecipientActionAuthTypesSchema } from '@documenso/lib/types/document-auth';
import { ZMapNegativeOneToUndefinedSchema } from '../document-flow/add-settings.types';
export const ZAddTemplatePlacholderRecipientsFormSchema = z
.object({
signers: z.array(
@ -15,9 +13,7 @@ export const ZAddTemplatePlacholderRecipientsFormSchema = z
name: z.string(),
role: z.nativeEnum(RecipientRole),
signingOrder: z.number().optional(),
actionAuth: ZMapNegativeOneToUndefinedSchema.pipe(
ZRecipientActionAuthTypesSchema.optional(),
),
actionAuth: z.array(ZRecipientActionAuthTypesSchema).optional().default([]),
}),
),
signingOrder: z.nativeEnum(DocumentSigningOrder),

View File

@ -106,8 +106,8 @@ export const AddTemplateSettingsFormPartial = ({
title: template.title,
externalId: template.externalId || undefined,
visibility: template.visibility || '',
globalAccessAuth: documentAuthOption?.globalAccessAuth || undefined,
globalActionAuth: documentAuthOption?.globalActionAuth || undefined,
globalAccessAuth: documentAuthOption?.globalAccessAuth || [],
globalActionAuth: documentAuthOption?.globalActionAuth || [],
meta: {
subject: template.templateMeta?.subject ?? '',
message: template.templateMeta?.message ?? '',
@ -237,7 +237,11 @@ export const AddTemplateSettingsFormPartial = ({
</FormLabel>
<FormControl>
<DocumentGlobalAuthAccessSelect {...field} onValueChange={field.onChange} />
<DocumentGlobalAuthAccessSelect
value={field.value}
disabled={field.disabled}
onValueChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
@ -378,7 +382,11 @@ export const AddTemplateSettingsFormPartial = ({
</FormLabel>
<FormControl>
<DocumentGlobalAuthActionSelect {...field} onValueChange={field.onChange} />
<DocumentGlobalAuthActionSelect
value={field.value}
disabled={field.disabled}
onValueChange={field.onChange}
/>
</FormControl>
</FormItem>
)}

View File

@ -18,18 +18,12 @@ import {
ZDocumentMetaTimezoneSchema,
} from '@documenso/trpc/server/document-router/schema';
import { ZMapNegativeOneToUndefinedSchema } from '../document-flow/add-settings.types';
export const ZAddTemplateSettingsFormSchema = z.object({
title: z.string().trim().min(1, { message: "Title can't be empty" }),
externalId: z.string().optional(),
visibility: z.nativeEnum(DocumentVisibility).optional(),
globalAccessAuth: ZMapNegativeOneToUndefinedSchema.pipe(
ZDocumentAccessAuthTypesSchema.optional(),
),
globalActionAuth: ZMapNegativeOneToUndefinedSchema.pipe(
ZDocumentActionAuthTypesSchema.optional(),
),
globalAccessAuth: z.array(ZDocumentAccessAuthTypesSchema).optional().default([]),
globalActionAuth: z.array(ZDocumentActionAuthTypesSchema).optional().default([]),
meta: z.object({
subject: z.string(),
message: z.string(),