Merge branch 'main' into mk/oidc-auth

This commit is contained in:
Lucas Smith
2024-05-30 15:41:36 +10:00
committed by GitHub
175 changed files with 7718 additions and 58270 deletions
+32
View File
@@ -11,6 +11,9 @@ import {
ZDeleteDocumentMutationSchema,
ZDeleteFieldMutationSchema,
ZDeleteRecipientMutationSchema,
ZDownloadDocumentSuccessfulSchema,
ZGenerateDocumentFromTemplateMutationResponseSchema,
ZGenerateDocumentFromTemplateMutationSchema,
ZGetDocumentsQuerySchema,
ZSendDocumentForSigningMutationSchema,
ZSuccessfulDocumentResponseSchema,
@@ -51,6 +54,17 @@ export const ApiContractV1 = c.router(
summary: 'Get a single document',
},
downloadSignedDocument: {
method: 'GET',
path: '/api/v1/documents/:id/download',
responses: {
200: ZDownloadDocumentSuccessfulSchema,
401: ZUnsuccessfulResponseSchema,
404: ZUnsuccessfulResponseSchema,
},
summary: 'Download a signed document when the storage transport is S3',
},
createDocument: {
method: 'POST',
path: '/api/v1/documents',
@@ -73,6 +87,24 @@ export const ApiContractV1 = c.router(
404: ZUnsuccessfulResponseSchema,
},
summary: 'Create a new document from an existing template',
deprecated: true,
description: `This has been deprecated in favour of "/api/v1/templates/:templateId/generate-document". You may face unpredictable behavior using this endpoint as it is no longer maintained.`,
},
generateDocumentFromTemplate: {
method: 'POST',
path: '/api/v1/templates/:templateId/generate-document',
body: ZGenerateDocumentFromTemplateMutationSchema,
responses: {
200: ZGenerateDocumentFromTemplateMutationResponseSchema,
400: ZUnsuccessfulResponseSchema,
401: ZUnsuccessfulResponseSchema,
404: ZUnsuccessfulResponseSchema,
500: ZUnsuccessfulResponseSchema,
},
summary: 'Create a new document from an existing template',
description:
'Create a new document from an existing template. Passing in values for title and meta will override the original values defined in the template. If you do not pass in values for recipients, it will use the values defined in the template.',
},
sendDocument: {
+181 -10
View File
@@ -1,6 +1,8 @@
import { createNextRoute } from '@ts-rest/next';
import { getServerLimits } from '@documenso/ee/server-only/limits/server';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { AppError } from '@documenso/lib/errors/app-error';
import { createDocumentData } from '@documenso/lib/server-only/document-data/create-document-data';
import { upsertDocumentMeta } from '@documenso/lib/server-only/document-meta/upsert-document-meta';
import { createDocument } from '@documenso/lib/server-only/document/create-document';
@@ -19,11 +21,16 @@ import { getRecipientById } from '@documenso/lib/server-only/recipient/get-recip
import { getRecipientsForDocument } from '@documenso/lib/server-only/recipient/get-recipients-for-document';
import { setRecipientsForDocument } from '@documenso/lib/server-only/recipient/set-recipients-for-document';
import { updateRecipient } from '@documenso/lib/server-only/recipient/update-recipient';
import type { CreateDocumentFromTemplateResponse } from '@documenso/lib/server-only/template/create-document-from-template';
import { createDocumentFromTemplate } from '@documenso/lib/server-only/template/create-document-from-template';
import { createDocumentFromTemplateLegacy } from '@documenso/lib/server-only/template/create-document-from-template-legacy';
import { extractNextApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { getFile } from '@documenso/lib/universal/upload/get-file';
import { putFile } from '@documenso/lib/universal/upload/put-file';
import { getPresignPostUrl } from '@documenso/lib/universal/upload/server-actions';
import { putPdfFile } from '@documenso/lib/universal/upload/put-file';
import {
getPresignGetUrl,
getPresignPostUrl,
} from '@documenso/lib/universal/upload/server-actions';
import { DocumentDataType, DocumentStatus, SigningStatus } from '@documenso/prisma/client';
import { ApiContractV1 } from './contract';
@@ -70,7 +77,10 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
status: 200,
body: {
...document,
recipients,
recipients: recipients.map((recipient) => ({
...recipient,
signingUrl: `${NEXT_PUBLIC_WEBAPP_URL()}/sign/${recipient.token}`,
})),
},
};
} catch (err) {
@@ -83,6 +93,68 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
}
}),
downloadSignedDocument: authenticatedMiddleware(async (args, user, team) => {
const { id: documentId } = args.params;
try {
if (process.env.NEXT_PUBLIC_UPLOAD_TRANSPORT !== 's3') {
return {
status: 500,
body: {
message: 'Please make sure the storage transport is set to S3.',
},
};
}
const document = await getDocumentById({
id: Number(documentId),
userId: user.id,
teamId: team?.id,
});
if (!document || !document.documentDataId) {
return {
status: 404,
body: {
message: 'Document not found',
},
};
}
if (DocumentDataType.S3_PATH !== document.documentData.type) {
return {
status: 400,
body: {
message: 'Invalid document data type',
},
};
}
if (document.status !== DocumentStatus.COMPLETED) {
return {
status: 400,
body: {
message: 'Document is not completed yet.',
},
};
}
const { url } = await getPresignGetUrl(document.documentData.data);
return {
status: 200,
body: { downloadUrl: url },
};
} catch (err) {
return {
status: 500,
body: {
message: 'Error downloading the document. Please try again.',
},
};
}
}),
deleteDocument: authenticatedMiddleware(async (args, user, team) => {
const { id: documentId } = args.params;
@@ -164,6 +236,13 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
requestMetadata: extractNextApiRequestMetadata(args.req),
});
await upsertDocumentMeta({
documentId: document.id,
userId: user.id,
...body.meta,
requestMetadata: extractNextApiRequestMetadata(args.req),
});
const recipients = await setRecipientsForDocument({
userId: user.id,
teamId: team?.id,
@@ -183,6 +262,8 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
email: recipient.email,
token: recipient.token,
role: recipient.role,
signingUrl: `${NEXT_PUBLIC_WEBAPP_URL()}/sign/${recipient.token}`,
})),
},
};
@@ -214,7 +295,7 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
const fileName = body.title.endsWith('.pdf') ? body.title : `${body.title}.pdf`;
const document = await createDocumentFromTemplate({
const document = await createDocumentFromTemplateLegacy({
templateId,
userId: user.id,
teamId: team?.id,
@@ -231,7 +312,7 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
formValues: body.formValues,
});
const newDocumentData = await putFile({
const newDocumentData = await putPdfFile({
name: fileName,
type: 'application/pdf',
arrayBuffer: async () => Promise.resolve(prefilled),
@@ -259,10 +340,7 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
await upsertDocumentMeta({
documentId: document.id,
userId: user.id,
subject: body.meta.subject,
message: body.meta.message,
dateFormat: body.meta.dateFormat,
timezone: body.meta.timezone,
...body.meta,
requestMetadata: extractNextApiRequestMetadata(args.req),
});
}
@@ -277,6 +355,89 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
email: recipient.email,
token: recipient.token,
role: recipient.role,
signingUrl: `${NEXT_PUBLIC_WEBAPP_URL()}/sign/${recipient.token}`,
})),
},
};
}),
generateDocumentFromTemplate: authenticatedMiddleware(async (args, user, team) => {
const { body, params } = args;
const { remaining } = await getServerLimits({ email: user.email, teamId: team?.id });
if (remaining.documents <= 0) {
return {
status: 400,
body: {
message: 'You have reached the maximum number of documents allowed for this month',
},
};
}
const templateId = Number(params.templateId);
let document: CreateDocumentFromTemplateResponse | null = null;
try {
document = await createDocumentFromTemplate({
templateId,
userId: user.id,
teamId: team?.id,
recipients: body.recipients,
override: {
title: body.title,
...body.meta,
},
});
} catch (err) {
return AppError.toRestAPIError(err);
}
if (body.formValues) {
const fileName = document.title.endsWith('.pdf') ? document.title : `${document.title}.pdf`;
const pdf = await getFile(document.documentData);
const prefilled = await insertFormValuesInPdf({
pdf: Buffer.from(pdf),
formValues: body.formValues,
});
const newDocumentData = await putPdfFile({
name: fileName,
type: 'application/pdf',
arrayBuffer: async () => Promise.resolve(prefilled),
});
await updateDocument({
documentId: document.id,
userId: user.id,
teamId: team?.id,
data: {
formValues: body.formValues,
documentData: {
connect: {
id: newDocumentData.id,
},
},
},
});
}
return {
status: 200,
body: {
documentId: document.id,
recipients: document.Recipient.map((recipient) => ({
recipientId: recipient.id,
name: recipient.name,
email: recipient.email,
token: recipient.token,
role: recipient.role,
signingUrl: `${NEXT_PUBLIC_WEBAPP_URL()}/sign/${recipient.token}`,
})),
},
};
@@ -284,6 +445,7 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
sendDocument: authenticatedMiddleware(async (args, user, team) => {
const { id } = args.params;
const { sendEmail = true } = args.body ?? {};
const document = await getDocumentById({ id: Number(id), userId: user.id, teamId: team?.id });
@@ -339,10 +501,11 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
// });
// }
await sendDocument({
const { Recipient: recipients, ...sentDocument } = await sendDocument({
documentId: Number(id),
userId: user.id,
teamId: team?.id,
sendEmail,
requestMetadata: extractNextApiRequestMetadata(args.req),
});
@@ -350,6 +513,11 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
status: 200,
body: {
message: 'Document sent for signing successfully',
...sentDocument,
recipients: recipients.map((recipient) => ({
...recipient,
signingUrl: `${NEXT_PUBLIC_WEBAPP_URL()}/sign/${recipient.token}`,
})),
},
};
} catch (err) {
@@ -434,6 +602,7 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
body: {
...newRecipient,
documentId: Number(documentId),
signingUrl: `${NEXT_PUBLIC_WEBAPP_URL()}/sign/${newRecipient.token}`,
},
};
} catch (err) {
@@ -499,6 +668,7 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
body: {
...updatedRecipient,
documentId: Number(documentId),
signingUrl: `${NEXT_PUBLIC_WEBAPP_URL()}/sign/${updatedRecipient.token}`,
},
};
}),
@@ -552,6 +722,7 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
body: {
...deletedRecipient,
documentId: Number(documentId),
signingUrl: '',
},
};
}),
+27 -9
View File
@@ -2,16 +2,34 @@ import { generateOpenApi } from '@ts-rest/open-api';
import { ApiContractV1 } from './contract';
export const OpenAPIV1 = generateOpenApi(
ApiContractV1,
{
info: {
title: 'Documenso API',
version: '1.0.0',
description: 'The Documenso API for retrieving, creating, updating and deleting documents.',
export const OpenAPIV1 = Object.assign(
generateOpenApi(
ApiContractV1,
{
info: {
title: 'Documenso API',
version: '1.0.0',
description: 'The Documenso API for retrieving, creating, updating and deleting documents.',
},
},
},
{
setOperationId: true,
},
),
{
setOperationId: true,
components: {
securitySchemes: {
authorization: {
type: 'apiKey',
in: 'header',
name: 'Authorization',
},
},
},
security: [
{
authorization: [],
},
],
},
);
+78 -4
View File
@@ -1,5 +1,6 @@
import { z } from 'zod';
import { ZUrlSchema } from '@documenso/lib/schemas/common';
import {
FieldType,
ReadStatus,
@@ -44,7 +45,11 @@ export type TSuccessfulGetDocumentResponseSchema = z.infer<
export type TSuccessfulDocumentResponseSchema = z.infer<typeof ZSuccessfulDocumentResponseSchema>;
export const ZSendDocumentForSigningMutationSchema = null;
export const ZSendDocumentForSigningMutationSchema = z
.object({
sendEmail: z.boolean().optional().default(true),
})
.or(z.literal('').transform(() => ({ sendEmail: true })));
export type TSendDocumentForSigningMutationSchema = typeof ZSendDocumentForSigningMutationSchema;
@@ -53,6 +58,10 @@ export const ZUploadDocumentSuccessfulSchema = z.object({
key: z.string(),
});
export const ZDownloadDocumentSuccessfulSchema = z.object({
downloadUrl: z.string(),
});
export type TUploadDocumentSuccessfulSchema = z.infer<typeof ZUploadDocumentSuccessfulSchema>;
export const ZCreateDocumentMutationSchema = z.object({
@@ -84,8 +93,12 @@ export const ZCreateDocumentMutationResponseSchema = z.object({
recipients: z.array(
z.object({
recipientId: z.number(),
name: z.string(),
email: z.string().email().min(1),
token: z.string(),
role: z.nativeEnum(RecipientRole),
signingUrl: z.string(),
}),
),
});
@@ -129,6 +142,8 @@ export const ZCreateDocumentFromTemplateMutationResponseSchema = z.object({
email: z.string().email().min(1),
token: z.string(),
role: z.nativeEnum(RecipientRole).optional().default(RecipientRole.SIGNER),
signingUrl: z.string(),
}),
),
});
@@ -137,6 +152,61 @@ export type TCreateDocumentFromTemplateMutationResponseSchema = z.infer<
typeof ZCreateDocumentFromTemplateMutationResponseSchema
>;
export const ZGenerateDocumentFromTemplateMutationSchema = z.object({
title: z.string().optional(),
recipients: z
.array(
z.object({
id: z.number(),
name: z.string().optional(),
email: z.string().email().min(1),
}),
)
.refine(
(schema) => {
const emails = schema.map((signer) => signer.email.toLowerCase());
const ids = schema.map((signer) => signer.id);
return new Set(emails).size === emails.length && new Set(ids).size === ids.length;
},
{ message: 'Recipient IDs and emails must be unique' },
),
meta: z
.object({
subject: z.string(),
message: z.string(),
timezone: z.string(),
dateFormat: z.string(),
redirectUrl: ZUrlSchema,
})
.partial()
.optional(),
formValues: z.record(z.string(), z.union([z.string(), z.boolean(), z.number()])).optional(),
});
export type TGenerateDocumentFromTemplateMutationSchema = z.infer<
typeof ZGenerateDocumentFromTemplateMutationSchema
>;
export const ZGenerateDocumentFromTemplateMutationResponseSchema = z.object({
documentId: z.number(),
recipients: z.array(
z.object({
recipientId: z.number(),
name: z.string(),
email: z.string().email().min(1),
token: z.string(),
role: z.nativeEnum(RecipientRole),
signingUrl: z.string(),
}),
),
});
export type TGenerateDocumentFromTemplateMutationResponseSchema = z.infer<
typeof ZGenerateDocumentFromTemplateMutationResponseSchema
>;
export const ZCreateRecipientMutationSchema = z.object({
name: z.string().min(1),
email: z.string().email().min(1),
@@ -171,6 +241,8 @@ export const ZSuccessfulRecipientResponseSchema = z.object({
readStatus: z.nativeEnum(ReadStatus),
signingStatus: z.nativeEnum(SigningStatus),
sendStatus: z.nativeEnum(SendStatus),
signingUrl: z.string(),
});
export type TSuccessfulRecipientResponseSchema = z.infer<typeof ZSuccessfulRecipientResponseSchema>;
@@ -221,9 +293,11 @@ export const ZSuccessfulResponseSchema = z.object({
export type TSuccessfulResponseSchema = z.infer<typeof ZSuccessfulResponseSchema>;
export const ZSuccessfulSigningResponseSchema = z.object({
message: z.string(),
});
export const ZSuccessfulSigningResponseSchema = z
.object({
message: z.string(),
})
.and(ZSuccessfulGetDocumentResponseSchema);
export type TSuccessfulSigningResponseSchema = z.infer<typeof ZSuccessfulSigningResponseSchema>;
@@ -41,8 +41,8 @@ test.describe('[EE_ONLY]', () => {
// Set EE action auth.
await page.getByTestId('documentActionSelectValue').click();
await page.getByLabel('Require account').getByText('Require account').click();
await expect(page.getByTestId('documentActionSelectValue')).toContainText('Require account');
await page.getByLabel('Require passkey').getByText('Require passkey').click();
await expect(page.getByTestId('documentActionSelectValue')).toContainText('Require passkey');
// Save the settings by going to the next step.
await page.getByRole('button', { name: 'Continue' }).click();
@@ -52,11 +52,7 @@ test.describe('[EE_ONLY]', () => {
await page.getByRole('button', { name: 'Go Back' }).click();
await expect(page.getByRole('heading', { name: 'General' })).toBeVisible();
// Todo: Verify that the values are correct once we fix the issue where going back
// does not show the updated values.
// await expect(page.getByLabel('Title')).toContainText('New Title');
// await expect(page.getByTestId('documentAccessSelectValue')).toContainText('Require account');
// await expect(page.getByTestId('documentActionSelectValue')).toContainText('Require account');
await expect(page.getByTestId('documentActionSelectValue')).toContainText('Require passkey');
await unseedUser(user.id);
});
@@ -89,8 +85,8 @@ test.describe('[EE_ONLY]', () => {
// Set EE action auth.
await page.getByTestId('documentActionSelectValue').click();
await page.getByLabel('Require account').getByText('Require account').click();
await expect(page.getByTestId('documentActionSelectValue')).toContainText('Require account');
await page.getByLabel('Require passkey').getByText('Require passkey').click();
await expect(page.getByTestId('documentActionSelectValue')).toContainText('Require passkey');
// Save the settings by going to the next step.
await page.getByRole('button', { name: 'Continue' }).click();
@@ -168,11 +164,8 @@ test('[DOCUMENT_FLOW]: add settings', async ({ page }) => {
await page.getByRole('button', { name: 'Go Back' }).click();
await expect(page.getByRole('heading', { name: 'General' })).toBeVisible();
// Todo: Verify that the values are correct once we fix the issue where going back
// does not show the updated values.
// await expect(page.getByLabel('Title')).toContainText('New Title');
// await expect(page.getByTestId('documentAccessSelectValue')).toContainText('Require account');
// await expect(page.getByTestId('documentActionSelectValue')).toContainText('Require account');
await expect(page.getByLabel('Title')).toHaveValue('New Title');
await expect(page.getByTestId('documentAccessSelectValue')).toContainText('Require account');
await unseedUser(user.id);
});
@@ -45,10 +45,10 @@ test.describe('[EE_ONLY]', () => {
await page
.getByRole('textbox', { name: 'Email', exact: true })
.fill('recipient2@documenso.com');
await page.getByRole('textbox', { name: 'Name', exact: true }).fill('Recipient 2');
await page.getByRole('textbox', { name: 'Name', exact: true }).nth(1).fill('Recipient 2');
// Display advanced settings.
await page.getByLabel('Show advanced settings').click();
await page.getByLabel('Show advanced settings').check();
// Navigate to the next step and back.
await page.getByRole('button', { name: 'Continue' }).click();
@@ -62,7 +62,6 @@ test.describe('[EE_ONLY]', () => {
});
});
// Note: Not complete yet due to issue with back button.
test('[DOCUMENT_FLOW]: add signers', async ({ page }) => {
const user = await seedUser();
const document = await seedBlankDocument(user);
@@ -82,7 +81,7 @@ test('[DOCUMENT_FLOW]: add signers', async ({ page }) => {
await page.getByPlaceholder('Name').fill('Recipient 1');
await page.getByRole('button', { name: 'Add Signer' }).click();
await page.getByRole('textbox', { name: 'Email', exact: true }).fill('recipient2@documenso.com');
await page.getByRole('textbox', { name: 'Name', exact: true }).fill('Recipient 2');
await page.getByRole('textbox', { name: 'Name', exact: true }).nth(1).fill('Recipient 2');
// Advanced settings should not be visible for non EE users.
await expect(page.getByLabel('Show advanced settings')).toBeHidden();
@@ -93,26 +92,5 @@ test('[DOCUMENT_FLOW]: add signers', async ({ page }) => {
await page.getByRole('button', { name: 'Go Back' }).click();
await expect(page.getByRole('heading', { name: 'Add Signers' })).toBeVisible();
// Todo: Fix stepper component back issue before finishing test.
// // Expect that the advanced settings is unchecked, since no advanced settings were applied.
// await expect(page.getByLabel('Show advanced settings')).toBeChecked({ checked: false });
// // Add advanced settings for a single recipient.
// await page.getByLabel('Show advanced settings').click();
// await page.getByRole('combobox').first().click();
// await page.getByLabel('Require account').click();
// // Navigate to the next step and back.
// await page.getByRole('button', { name: 'Continue' }).click();
// await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
// await page.getByRole('button', { name: 'Go Back' }).click();
// await expect(page.getByRole('heading', { name: 'Add Signers' })).toBeVisible();
// Expect that the advanced settings is visible, and the checkbox is hidden. Since advanced
// settings were applied.
// Todo: Fix stepper component back issue before finishing test.
await unseedUser(user.id);
});
@@ -1,10 +1,14 @@
import { expect, test } from '@playwright/test';
import { DateTime } from 'luxon';
import path from 'node:path';
import { getRecipientByEmail } from '@documenso/lib/server-only/recipient/get-recipient-by-email';
import { prisma } from '@documenso/prisma';
import { DocumentStatus } from '@documenso/prisma/client';
import { seedBlankDocument } from '@documenso/prisma/seed/documents';
import { DocumentStatus, FieldType, RecipientRole } from '@documenso/prisma/client';
import {
seedBlankDocument,
seedPendingDocumentWithFullFields,
} from '@documenso/prisma/seed/documents';
import { seedUser, unseedUser } from '@documenso/prisma/seed/users';
import { apiSignin } from '../fixtures/authentication';
@@ -136,7 +140,7 @@ test('[DOCUMENT_FLOW]: should be able to create a document with multiple recipie
await page.getByPlaceholder('Name').fill('User 1');
await page.getByRole('button', { name: 'Add Signer' }).click();
await page.getByRole('textbox', { name: 'Email', exact: true }).fill('user2@example.com');
await page.getByRole('textbox', { name: 'Name', exact: true }).fill('User 2');
await page.getByRole('textbox', { name: 'Name', exact: true }).nth(1).fill('User 2');
await page.getByRole('button', { name: 'Continue' }).click();
@@ -192,6 +196,102 @@ test('[DOCUMENT_FLOW]: should be able to create a document with multiple recipie
await unseedUser(user.id);
});
test('[DOCUMENT_FLOW]: should be able to create a document with multiple recipients with different roles', async ({
page,
}) => {
const user = await seedUser();
const document = await seedBlankDocument(user);
await apiSignin({
page,
email: user.email,
redirectPath: `/documents/${document.id}/edit`,
});
// Set title
await expect(page.getByRole('heading', { name: 'General' })).toBeVisible();
await page.getByLabel('Title').fill('Test Title');
await page.getByRole('button', { name: 'Continue' }).click();
// Add signers
await expect(page.getByRole('heading', { name: 'Add Signers' })).toBeVisible();
// Add 2 signers.
await page.getByPlaceholder('Email').fill('user1@example.com');
await page.getByPlaceholder('Name').fill('User 1');
await page.getByRole('button', { name: 'Add Signer' }).click();
await page.getByRole('textbox', { name: 'Email', exact: true }).fill('user2@example.com');
await page.getByRole('textbox', { name: 'Name', exact: true }).nth(1).fill('User 2');
await page.locator('button[role="combobox"]').nth(1).click();
await page.getByLabel('Receives copy').click();
await page.getByRole('button', { name: 'Add Signer' }).click();
await page.getByRole('textbox', { name: 'Email', exact: true }).nth(1).fill('user3@example.com');
await page.getByRole('textbox', { name: 'Name', exact: true }).nth(2).fill('User 3');
await page.locator('button[role="combobox"]').nth(2).click();
await page.getByLabel('Needs to approve').click();
await page.getByRole('button', { name: 'Add Signer' }).click();
await page.getByRole('textbox', { name: 'Email', exact: true }).nth(2).fill('user4@example.com');
await page.getByRole('textbox', { name: 'Name', exact: true }).nth(3).fill('User 4');
await page.locator('button[role="combobox"]').nth(3).click();
await page.getByLabel('Needs to view').click();
await page.getByRole('button', { name: 'Continue' }).click();
// Add fields
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
await page.getByRole('button', { name: 'User 1 Signature' }).click();
await page.locator('canvas').click({
position: {
x: 100,
y: 100,
},
});
await page.getByRole('button', { name: 'Email Email' }).click();
await page.locator('canvas').click({
position: {
x: 100,
y: 200,
},
});
await page.getByText('User 1 (user1@example.com)').click();
await page.getByText('User 3 (user3@example.com)').click();
await page.getByRole('button', { name: 'User 3 Signature' }).click();
await page.locator('canvas').click({
position: {
x: 500,
y: 100,
},
});
await page.getByRole('button', { name: 'Email Email' }).click();
await page.locator('canvas').click({
position: {
x: 500,
y: 200,
},
});
await page.getByRole('button', { name: 'Continue' }).click();
// Add subject and send
await expect(page.getByRole('heading', { name: 'Add Subject' })).toBeVisible();
await page.getByRole('button', { name: 'Send' }).click();
await page.waitForURL('/documents');
// Assert document was created
await expect(page.getByRole('link', { name: 'Test Title' })).toBeVisible();
await unseedUser(user.id);
});
test('[DOCUMENT_FLOW]: should be able to create, send and sign a document', async ({ page }) => {
const user = await seedUser();
const document = await seedBlankDocument(user);
@@ -234,6 +334,7 @@ test('[DOCUMENT_FLOW]: should be able to create, send and sign a document', asyn
await page.getByRole('link', { name: documentTitle }).click();
await page.waitForURL(/\/documents\/\d+/);
// Start signing process
const url = page.url().split('/');
const documentId = url[url.length - 1];
@@ -263,6 +364,63 @@ test('[DOCUMENT_FLOW]: should be able to create, send and sign a document', asyn
await unseedUser(user.id);
});
test('[DOCUMENT_FLOW]: should be able to approve a document', async ({ page }) => {
const user = await seedUser();
const { recipients } = await seedPendingDocumentWithFullFields({
owner: user,
recipients: ['user@documenso.com', 'approver@documenso.com'],
recipientsCreateOptions: [
{
email: 'user@documenso.com',
role: RecipientRole.SIGNER,
},
{
email: 'approver@documenso.com',
role: RecipientRole.APPROVER,
},
],
fields: [FieldType.SIGNATURE],
});
for (const recipient of recipients) {
const { token, Field, role } = recipient;
const signUrl = `/sign/${token}`;
await page.goto(signUrl);
await expect(
page.getByRole('heading', {
name: role === RecipientRole.SIGNER ? 'Sign Document' : 'Approve Document',
}),
).toBeVisible();
// Add signature.
const canvas = page.locator('canvas');
const box = await canvas.boundingBox();
if (box) {
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
await page.mouse.down();
await page.mouse.move(box.x + box.width / 4, box.y + box.height / 4);
await page.mouse.up();
}
for (const field of Field) {
await page.locator(`#field-${field.id}`).getByRole('button').click();
await expect(page.locator(`#field-${field.id}`)).toHaveAttribute('data-inserted', 'true');
}
await page.getByRole('button', { name: 'Complete' }).click();
await page
.getByRole('button', { name: role === RecipientRole.SIGNER ? 'Sign' : 'Approve' })
.click();
await page.waitForURL(`${signUrl}/complete`);
}
await unseedUser(user.id);
});
test('[DOCUMENT_FLOW]: should be able to create, send with redirect url, sign a document and redirect to redirect url', async ({
page,
}) => {
@@ -333,3 +491,46 @@ test('[DOCUMENT_FLOW]: should be able to create, send with redirect url, sign a
await unseedUser(user.id);
});
test('[DOCUMENT_FLOW]: should be able to sign a document with custom date', async ({ page }) => {
const user = await seedUser();
const customDate = DateTime.local().toFormat('yyyy-MM-dd hh:mm a');
const { document, recipients } = await seedPendingDocumentWithFullFields({
owner: user,
recipients: ['user1@example.com'],
fields: [FieldType.DATE],
});
const { token, Field } = recipients[0];
const [recipientField] = Field;
await page.goto(`/sign/${token}`);
await page.waitForURL(`/sign/${token}`);
await page.locator(`#field-${recipientField.id}`).getByRole('button').click();
await page.getByRole('button', { name: 'Complete' }).click();
await expect(page.getByRole('dialog').getByText('Complete Signing').first()).toBeVisible();
await page.getByRole('button', { name: 'Sign' }).click();
await page.waitForURL(`/sign/${token}/complete`);
await expect(page.getByText('Document Signed')).toBeVisible();
const field = await prisma.field.findFirst({
where: {
Recipient: {
email: 'user1@example.com',
},
documentId: Number(document.id),
},
});
expect(field?.customText).toBe(customDate);
// Check if document has been signed
const { status: completedStatus } = await getDocumentByToken(token);
expect(completedStatus).toBe(DocumentStatus.COMPLETED);
await unseedUser(user.id);
});
@@ -8,6 +8,7 @@ import {
import { seedUser } from '@documenso/prisma/seed/users';
import { apiSignin, apiSignout } from '../fixtures/authentication';
import { checkDocumentTabCount } from '../fixtures/documents';
test.describe.configure({ mode: 'serial' });
@@ -74,7 +75,7 @@ test('[DOCUMENTS]: deleting a completed document should not remove it from recip
email: sender.email,
});
// open actions menu
// Open document action menu.
await page
.locator('tr', { hasText: 'Document 1 - Completed' })
.getByRole('cell', { name: 'Download' })
@@ -115,7 +116,7 @@ test('[DOCUMENTS]: deleting a pending document should remove it from recipients'
email: sender.email,
});
// open actions menu
// Open document action menu.
await page.locator('tr', { hasText: 'Document 1 - Pending' }).getByRole('button').nth(1).click();
// delete document
@@ -135,20 +136,11 @@ test('[DOCUMENTS]: deleting a pending document should remove it from recipients'
});
await expect(page.getByRole('link', { name: 'Document 1 - Pending' })).not.toBeVisible();
await page.goto(`/sign/${recipient.token}`);
await expect(page.getByText(/document.*cancelled/i).nth(0)).toBeVisible();
await page.goto('/documents');
await page.waitForURL('/documents');
await apiSignout({ page });
}
});
test('[DOCUMENTS]: deleting a draft document should remove it without additional prompting', async ({
page,
}) => {
test('[DOCUMENTS]: deleting draft documents should permanently remove it', async ({ page }) => {
const { sender } = await seedDeleteDocumentsTestRequirements();
await apiSignin({
@@ -156,11 +148,10 @@ test('[DOCUMENTS]: deleting a draft document should remove it without additional
email: sender.email,
});
// open actions menu
// Open document action menu.
await page
.locator('tr', { hasText: 'Document 1 - Draft' })
.getByRole('cell', { name: 'Edit' })
.getByRole('button')
.getByTestId('document-table-action-btn')
.click();
// delete document
@@ -169,4 +160,155 @@ test('[DOCUMENTS]: deleting a draft document should remove it without additional
await page.getByRole('button', { name: 'Delete' }).click();
await expect(page.getByRole('row', { name: /Document 1 - Draft/ })).not.toBeVisible();
// Check document counts.
await checkDocumentTabCount(page, 'Inbox', 0);
await checkDocumentTabCount(page, 'Pending', 1);
await checkDocumentTabCount(page, 'Completed', 1);
await checkDocumentTabCount(page, 'Draft', 0);
await checkDocumentTabCount(page, 'All', 2);
});
test('[DOCUMENTS]: deleting pending documents should permanently remove it', async ({ page }) => {
const { sender } = await seedDeleteDocumentsTestRequirements();
await apiSignin({
page,
email: sender.email,
});
// Open document action menu.
await page
.locator('tr', { hasText: 'Document 1 - Pending' })
.getByTestId('document-table-action-btn')
.click();
// Delete document.
await page.getByRole('menuitem', { name: 'Delete' }).click();
await page.getByPlaceholder("Type 'delete' to confirm").fill('delete');
await page.getByRole('button', { name: 'Delete' }).click();
await expect(page.getByRole('row', { name: /Document 1 - Pending/ })).not.toBeVisible();
// Check document counts.
await checkDocumentTabCount(page, 'Inbox', 0);
await checkDocumentTabCount(page, 'Pending', 0);
await checkDocumentTabCount(page, 'Completed', 1);
await checkDocumentTabCount(page, 'Draft', 1);
await checkDocumentTabCount(page, 'All', 2);
});
test('[DOCUMENTS]: deleting completed documents as an owner should hide it from only the owner', async ({
page,
}) => {
const { sender, recipients } = await seedDeleteDocumentsTestRequirements();
await apiSignin({
page,
email: sender.email,
});
// Open document action menu.
await page
.locator('tr', { hasText: 'Document 1 - Completed' })
.getByTestId('document-table-action-btn')
.click();
// Delete document.
await page.getByRole('menuitem', { name: 'Delete' }).click();
await page.getByPlaceholder("Type 'delete' to confirm").fill('delete');
await page.getByRole('button', { name: 'Delete' }).click();
// Check document counts.
await expect(page.getByRole('row', { name: /Document 1 - Completed/ })).not.toBeVisible();
await checkDocumentTabCount(page, 'Inbox', 0);
await checkDocumentTabCount(page, 'Pending', 1);
await checkDocumentTabCount(page, 'Completed', 0);
await checkDocumentTabCount(page, 'Draft', 1);
await checkDocumentTabCount(page, 'All', 2);
// Sign into the recipient account.
await apiSignout({ page });
await apiSignin({
page,
email: recipients[0].email,
});
// Check document counts.
await expect(page.getByRole('row', { name: /Document 1 - Completed/ })).toBeVisible();
await checkDocumentTabCount(page, 'Inbox', 1);
await checkDocumentTabCount(page, 'Pending', 0);
await checkDocumentTabCount(page, 'Completed', 1);
await checkDocumentTabCount(page, 'Draft', 0);
await checkDocumentTabCount(page, 'All', 2);
});
test('[DOCUMENTS]: deleting documents as a recipient should only hide it for them', async ({
page,
}) => {
const { sender, recipients } = await seedDeleteDocumentsTestRequirements();
const recipientA = recipients[0];
const recipientB = recipients[1];
await apiSignin({
page,
email: recipientA.email,
});
// Open document action menu.
await page
.locator('tr', { hasText: 'Document 1 - Completed' })
.getByTestId('document-table-action-btn')
.click();
// Delete document.
await page.getByRole('menuitem', { name: 'Hide' }).click();
await page.getByRole('button', { name: 'Hide' }).click();
// Open document action menu.
await page
.locator('tr', { hasText: 'Document 1 - Pending' })
.getByTestId('document-table-action-btn')
.click();
// Delete document.
await page.getByRole('menuitem', { name: 'Hide' }).click();
await page.getByRole('button', { name: 'Hide' }).click();
// Check document counts.
await expect(page.getByRole('row', { name: /Document 1 - Completed/ })).not.toBeVisible();
await expect(page.getByRole('row', { name: /Document 1 - Pending/ })).not.toBeVisible();
await checkDocumentTabCount(page, 'Inbox', 0);
await checkDocumentTabCount(page, 'Pending', 0);
await checkDocumentTabCount(page, 'Completed', 0);
await checkDocumentTabCount(page, 'Draft', 0);
await checkDocumentTabCount(page, 'All', 0);
// Sign into the sender account.
await apiSignout({ page });
await apiSignin({
page,
email: sender.email,
});
// Check document counts for sender.
await checkDocumentTabCount(page, 'Inbox', 0);
await checkDocumentTabCount(page, 'Pending', 1);
await checkDocumentTabCount(page, 'Completed', 1);
await checkDocumentTabCount(page, 'Draft', 1);
await checkDocumentTabCount(page, 'All', 3);
// Sign into the other recipient account.
await apiSignout({ page });
await apiSignin({
page,
email: recipientB.email,
});
// Check document counts for other recipient.
await checkDocumentTabCount(page, 'Inbox', 1);
await checkDocumentTabCount(page, 'Pending', 0);
await checkDocumentTabCount(page, 'Completed', 1);
await checkDocumentTabCount(page, 'Draft', 0);
await checkDocumentTabCount(page, 'All', 2);
});
@@ -0,0 +1,17 @@
import type { Page } from '@playwright/test';
import { expect } from '@playwright/test';
export const checkDocumentTabCount = async (page: Page, tabName: string, count: number) => {
await page.getByRole('tab', { name: tabName }).click();
if (tabName !== 'All') {
await expect(page.getByRole('tab', { name: tabName })).toContainText(count.toString());
}
if (count === 0) {
await expect(page.getByTestId('empty-document-state')).toBeVisible();
return;
}
await expect(page.getByRole('main')).toContainText(`Showing ${count}`);
};
@@ -1,4 +1,3 @@
import type { Page } from '@playwright/test';
import { expect, test } from '@playwright/test';
import { DocumentStatus } from '@documenso/prisma/client';
@@ -7,24 +6,10 @@ import { seedTeamEmail, unseedTeam, unseedTeamEmail } from '@documenso/prisma/se
import { seedUser } from '@documenso/prisma/seed/users';
import { apiSignin, apiSignout } from '../fixtures/authentication';
import { checkDocumentTabCount } from '../fixtures/documents';
test.describe.configure({ mode: 'parallel' });
const checkDocumentTabCount = async (page: Page, tabName: string, count: number) => {
await page.getByRole('tab', { name: tabName }).click();
if (tabName !== 'All') {
await expect(page.getByRole('tab', { name: tabName })).toContainText(count.toString());
}
if (count === 0) {
await expect(page.getByRole('main')).toContainText(`Nothing to do`);
return;
}
await expect(page.getByRole('main')).toContainText(`Showing ${count}`);
};
test('[TEAMS]: check team documents count', async ({ page }) => {
const { team, teamMember2 } = await seedTeamDocuments();
@@ -245,24 +230,6 @@ test('[TEAMS]: check team documents count with external team email', async ({ pa
await unseedTeam(team.url);
});
test('[TEAMS]: delete pending team document', async ({ page }) => {
const { team, teamMember2: currentUser } = await seedTeamDocuments();
await apiSignin({
page,
email: currentUser.email,
redirectPath: `/t/${team.url}/documents?status=PENDING`,
});
await page.getByRole('row').getByRole('button').nth(1).click();
await page.getByRole('menuitem', { name: 'Delete' }).click();
await page.getByPlaceholder("Type 'delete' to confirm").fill('delete');
await page.getByRole('button', { name: 'Delete' }).click();
await checkDocumentTabCount(page, 'Pending', 1);
});
test('[TEAMS]: resend pending team document', async ({ page }) => {
const { team, teamMember2: currentUser } = await seedTeamDocuments();
@@ -280,3 +247,125 @@ test('[TEAMS]: resend pending team document', async ({ page }) => {
await expect(page.getByRole('status')).toContainText('Document re-sent');
});
test('[TEAMS]: delete draft team document', async ({ page }) => {
const { team, teamMember2: teamEmailMember, teamMember3 } = await seedTeamDocuments();
await apiSignin({
page,
email: teamMember3.email,
redirectPath: `/t/${team.url}/documents?status=DRAFT`,
});
await page.getByRole('row').getByRole('button').nth(1).click();
await page.getByRole('menuitem', { name: 'Delete' }).click();
await page.getByRole('button', { name: 'Delete' }).click();
await checkDocumentTabCount(page, 'Draft', 1);
// Should be hidden for all team members.
await apiSignout({ page });
// Run the test twice, one with the team owner and once with the team member email to ensure the counts are the same.
for (const user of [team.owner, teamEmailMember]) {
await apiSignin({
page,
email: user.email,
redirectPath: `/t/${team.url}/documents`,
});
// Check document counts.
await checkDocumentTabCount(page, 'Inbox', 0);
await checkDocumentTabCount(page, 'Pending', 2);
await checkDocumentTabCount(page, 'Completed', 1);
await checkDocumentTabCount(page, 'Draft', 1);
await checkDocumentTabCount(page, 'All', 4);
await apiSignout({ page });
}
await unseedTeam(team.url);
});
test('[TEAMS]: delete pending team document', async ({ page }) => {
const { team, teamMember2: teamEmailMember, teamMember3 } = await seedTeamDocuments();
await apiSignin({
page,
email: teamMember3.email,
redirectPath: `/t/${team.url}/documents?status=PENDING`,
});
await page.getByRole('row').getByRole('button').nth(1).click();
await page.getByRole('menuitem', { name: 'Delete' }).click();
await page.getByPlaceholder("Type 'delete' to confirm").fill('delete');
await page.getByRole('button', { name: 'Delete' }).click();
await checkDocumentTabCount(page, 'Pending', 1);
// Should be hidden for all team members.
await apiSignout({ page });
// Run the test twice, one with the team owner and once with the team member email to ensure the counts are the same.
for (const user of [team.owner, teamEmailMember]) {
await apiSignin({
page,
email: user.email,
redirectPath: `/t/${team.url}/documents`,
});
// Check document counts.
await checkDocumentTabCount(page, 'Inbox', 0);
await checkDocumentTabCount(page, 'Pending', 1);
await checkDocumentTabCount(page, 'Completed', 1);
await checkDocumentTabCount(page, 'Draft', 2);
await checkDocumentTabCount(page, 'All', 4);
await apiSignout({ page });
}
await unseedTeam(team.url);
});
test('[TEAMS]: delete completed team document', async ({ page }) => {
const { team, teamMember2: teamEmailMember, teamMember3 } = await seedTeamDocuments();
await apiSignin({
page,
email: teamMember3.email,
redirectPath: `/t/${team.url}/documents?status=COMPLETED`,
});
await page.getByRole('row').getByRole('button').nth(2).click();
await page.getByRole('menuitem', { name: 'Delete' }).click();
await page.getByPlaceholder("Type 'delete' to confirm").fill('delete');
await page.getByRole('button', { name: 'Delete' }).click();
await checkDocumentTabCount(page, 'Completed', 0);
// Should be hidden for all team members.
await apiSignout({ page });
// Run the test twice, one with the team owner and once with the team member email to ensure the counts are the same.
for (const user of [team.owner, teamEmailMember]) {
await apiSignin({
page,
email: user.email,
redirectPath: `/t/${team.url}/documents`,
});
// Check document counts.
await checkDocumentTabCount(page, 'Inbox', 0);
await checkDocumentTabCount(page, 'Pending', 2);
await checkDocumentTabCount(page, 'Completed', 0);
await checkDocumentTabCount(page, 'Draft', 2);
await checkDocumentTabCount(page, 'All', 4);
await apiSignout({ page });
}
await unseedTeam(team.url);
});
@@ -0,0 +1,167 @@
import { expect, test } from '@playwright/test';
import { seedUserSubscription } from '@documenso/prisma/seed/subscriptions';
import { seedTeam, unseedTeam } from '@documenso/prisma/seed/teams';
import { seedBlankTemplate } from '@documenso/prisma/seed/templates';
import { seedUser, unseedUser } from '@documenso/prisma/seed/users';
import { apiSignin } from '../fixtures/authentication';
test.describe.configure({ mode: 'parallel' });
test.describe('[EE_ONLY]', () => {
const enterprisePriceId = process.env.NEXT_PUBLIC_STRIPE_ENTERPRISE_PLAN_MONTHLY_PRICE_ID || '';
test.beforeEach(() => {
test.skip(
process.env.NEXT_PUBLIC_FEATURE_BILLING_ENABLED !== 'true' || !enterprisePriceId,
'Billing required for this test',
);
});
test('[TEMPLATE_FLOW] add action auth settings', async ({ page }) => {
const user = await seedUser();
await seedUserSubscription({
userId: user.id,
priceId: enterprisePriceId,
});
const template = await seedBlankTemplate(user);
await apiSignin({
page,
email: user.email,
redirectPath: `/templates/${template.id}`,
});
// Set EE action auth.
await page.getByTestId('documentActionSelectValue').click();
await page.getByLabel('Require passkey').getByText('Require passkey').click();
await expect(page.getByTestId('documentActionSelectValue')).toContainText('Require passkey');
// Save the settings by going to the next step.
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('heading', { name: 'Add Placeholders' })).toBeVisible();
// Return to the settings step to check that the results are saved correctly.
await page.getByRole('button', { name: 'Go Back' }).click();
await expect(page.getByRole('heading', { name: 'General' })).toBeVisible();
await expect(page.getByTestId('documentActionSelectValue')).toContainText('Require passkey');
await unseedUser(user.id);
});
test('[TEMPLATE_FLOW] enterprise team member can add action auth settings', async ({ page }) => {
const team = await seedTeam({
createTeamMembers: 1,
});
const owner = team.owner;
const teamMemberUser = team.members[1].user;
// Make the team enterprise by giving the owner the enterprise subscription.
await seedUserSubscription({
userId: team.ownerUserId,
priceId: enterprisePriceId,
});
const template = await seedBlankTemplate(owner, {
createTemplateOptions: {
teamId: team.id,
},
});
await apiSignin({
page,
email: teamMemberUser.email,
redirectPath: `/t/${team.url}/templates/${template.id}`,
});
// Set EE action auth.
await page.getByTestId('documentActionSelectValue').click();
await page.getByLabel('Require passkey').getByText('Require passkey').click();
await expect(page.getByTestId('documentActionSelectValue')).toContainText('Require passkey');
// Save the settings by going to the next step.
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('heading', { name: 'Add Placeholders' })).toBeVisible();
// Advanced settings should be visible.
await expect(page.getByLabel('Show advanced settings')).toBeVisible();
await unseedTeam(team.url);
});
test('[TEMPLATE_FLOW] enterprise team member should not have access to enterprise on personal account', async ({
page,
}) => {
const team = await seedTeam({
createTeamMembers: 1,
});
const teamMemberUser = team.members[1].user;
// Make the team enterprise by giving the owner the enterprise subscription.
await seedUserSubscription({
userId: team.ownerUserId,
priceId: enterprisePriceId,
});
const template = await seedBlankTemplate(teamMemberUser);
await apiSignin({
page,
email: teamMemberUser.email,
redirectPath: `/templates/${template.id}`,
});
// Global action auth should not be visible.
await expect(page.getByTestId('documentActionSelectValue')).not.toBeVisible();
// Next step.
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('heading', { name: 'Add Placeholders' })).toBeVisible();
// Advanced settings should not be visible.
await expect(page.getByLabel('Show advanced settings')).not.toBeVisible();
await unseedTeam(team.url);
});
});
test('[TEMPLATE_FLOW]: add settings', async ({ page }) => {
const user = await seedUser();
const template = await seedBlankTemplate(user);
await apiSignin({
page,
email: user.email,
redirectPath: `/templates/${template.id}`,
});
// Set title.
await page.getByLabel('Title').fill('New Title');
// Set access auth.
await page.getByTestId('documentAccessSelectValue').click();
await page.getByLabel('Require account').getByText('Require account').click();
await expect(page.getByTestId('documentAccessSelectValue')).toContainText('Require account');
// Action auth should NOT be visible.
await expect(page.getByTestId('documentActionSelectValue')).not.toBeVisible();
// Save the settings by going to the next step.
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('heading', { name: 'Add Placeholders' })).toBeVisible();
// Return to the settings step to check that the results are saved correctly.
await page.getByRole('button', { name: 'Go Back' }).click();
await expect(page.getByRole('heading', { name: 'General' })).toBeVisible();
await expect(page.getByLabel('Title')).toHaveValue('New Title');
await expect(page.getByTestId('documentAccessSelectValue')).toContainText('Require account');
await unseedUser(user.id);
});
@@ -0,0 +1,106 @@
import { expect, test } from '@playwright/test';
import { seedUserSubscription } from '@documenso/prisma/seed/subscriptions';
import { seedBlankTemplate } from '@documenso/prisma/seed/templates';
import { seedUser, unseedUser } from '@documenso/prisma/seed/users';
import { apiSignin } from '../fixtures/authentication';
test.describe.configure({ mode: 'parallel' });
test.describe('[EE_ONLY]', () => {
const enterprisePriceId = process.env.NEXT_PUBLIC_STRIPE_ENTERPRISE_PLAN_MONTHLY_PRICE_ID || '';
test.beforeEach(() => {
test.skip(
process.env.NEXT_PUBLIC_FEATURE_BILLING_ENABLED !== 'true' || !enterprisePriceId,
'Billing required for this test',
);
});
test('[TEMPLATE_FLOW] add EE settings', async ({ page }) => {
const user = await seedUser();
await seedUserSubscription({
userId: user.id,
priceId: enterprisePriceId,
});
const template = await seedBlankTemplate(user);
await apiSignin({
page,
email: user.email,
redirectPath: `/templates/${template.id}`,
});
// Save the settings by going to the next step.
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('heading', { name: 'Add Placeholder' })).toBeVisible();
// Add 2 signers.
await page.getByPlaceholder('Email').fill('recipient1@documenso.com');
await page.getByPlaceholder('Name').fill('Recipient 1');
await page.getByRole('button', { name: 'Add Placeholder Recipient' }).click();
await page
.getByRole('textbox', { name: 'Email', exact: true })
.fill('recipient2@documenso.com');
await page.getByRole('textbox', { name: 'Name', exact: true }).nth(1).fill('Recipient 2');
// Display advanced settings.
await page.getByLabel('Show advanced settings').check();
// Navigate to the next step and back.
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
await page.getByRole('button', { name: 'Go Back' }).click();
await expect(page.getByRole('heading', { name: 'Add Placeholder' })).toBeVisible();
// Expect that the advanced settings is unchecked, since no advanced settings were applied.
await expect(page.getByLabel('Show advanced settings')).toBeChecked({ checked: false });
// 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();
// Navigate to the next step and back.
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
await page.getByRole('button', { name: 'Go Back' }).click();
await expect(page.getByRole('heading', { name: 'Add Placeholder' })).toBeVisible();
// Expect that the advanced settings is visible, and the checkbox is hidden. Since advanced
// settings were applied.
await expect(page.getByLabel('Show advanced settings')).toBeHidden();
await unseedUser(user.id);
});
});
test('[TEMPLATE_FLOW]: add placeholder', async ({ page }) => {
const user = await seedUser();
const template = await seedBlankTemplate(user);
await apiSignin({
page,
email: user.email,
redirectPath: `/templates/${template.id}`,
});
// Save the settings by going to the next step.
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('heading', { name: 'Add Placeholder' })).toBeVisible();
// Add 2 signers.
await page.getByPlaceholder('Email').fill('recipient1@documenso.com');
await page.getByPlaceholder('Name').fill('Recipient 1');
await page.getByRole('button', { name: 'Add Placeholder Recipient' }).click();
await page.getByRole('textbox', { name: 'Email', exact: true }).fill('recipient2@documenso.com');
await page.getByRole('textbox', { name: 'Name', exact: true }).nth(1).fill('Recipient 2');
// Advanced settings should not be visible for non EE users.
await expect(page.getByLabel('Show advanced settings')).toBeHidden();
await unseedUser(user.id);
});
@@ -0,0 +1,285 @@
import { expect, test } from '@playwright/test';
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
import { prisma } from '@documenso/prisma';
import { seedUserSubscription } from '@documenso/prisma/seed/subscriptions';
import { seedTeam } from '@documenso/prisma/seed/teams';
import { seedBlankTemplate } from '@documenso/prisma/seed/templates';
import { seedUser } from '@documenso/prisma/seed/users';
import { apiSignin } from '../fixtures/authentication';
test.describe.configure({ mode: 'parallel' });
const enterprisePriceId = process.env.NEXT_PUBLIC_STRIPE_ENTERPRISE_PLAN_MONTHLY_PRICE_ID || '';
/**
* 1. Create a template with all settings filled out
* 2. Create a document from the template
* 3. Ensure all values are correct
*
* Note: There is a direct copy paste of this test below for teams.
*
* If you update this test please update that test as well.
*/
test('[TEMPLATE]: should create a document from a template', async ({ page }) => {
const user = await seedUser();
const template = await seedBlankTemplate(user);
const isBillingEnabled =
process.env.NEXT_PUBLIC_FEATURE_BILLING_ENABLED === 'true' && enterprisePriceId;
await seedUserSubscription({
userId: user.id,
priceId: enterprisePriceId,
});
await apiSignin({
page,
email: user.email,
redirectPath: `/templates/${template.id}`,
});
// Set template title.
await page.getByLabel('Title').fill('TEMPLATE_TITLE');
// Set template document access.
await page.getByTestId('documentAccessSelectValue').click();
await page.getByLabel('Require account').getByText('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 expect(page.getByTestId('documentActionSelectValue')).toContainText('Require passkey');
}
// Set email options.
await page.getByRole('button', { name: 'Email Options' }).click();
await page.getByLabel('Subject (Optional)').fill('SUBJECT');
await page.getByLabel('Message (Optional)').fill('MESSAGE');
// Set advanced options.
await page.getByRole('button', { name: 'Advanced Options' }).click();
await page.locator('button').filter({ hasText: 'YYYY-MM-DD HH:mm a' }).click();
await page.getByLabel('DD/MM/YYYY').click();
await page.locator('.time-zone-field').click();
await page.getByRole('option', { name: 'Etc/UTC' }).click();
await page.getByLabel('Redirect URL').fill('https://documenso.com');
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('heading', { name: 'Add Placeholder' })).toBeVisible();
// Add 2 signers.
await page.getByPlaceholder('Email').fill('recipient1@documenso.com');
await page.getByPlaceholder('Name').fill('Recipient 1');
await page.getByRole('button', { name: 'Add Placeholder Recipient' }).click();
await page.getByRole('textbox', { name: 'Email', exact: true }).fill('recipient2@documenso.com');
await page.getByRole('textbox', { name: 'Name', exact: true }).nth(1).fill('Recipient 2');
// 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.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
await page.getByRole('button', { name: 'Save template' }).click();
// Use template
await page.waitForURL('/templates');
await page.getByRole('button', { name: 'Use Template' }).click();
await page.getByRole('button', { name: 'Create as draft' }).click();
// Review that the document was created with the correct values.
await page.waitForURL(/documents/);
const documentId = Number(page.url().split('/').pop());
const document = await prisma.document.findFirstOrThrow({
where: {
id: documentId,
},
include: {
Recipient: true,
documentMeta: true,
},
});
const documentAuth = extractDocumentAuthMethods({
documentAuth: document.authOptions,
});
expect(document.title).toEqual('TEMPLATE_TITLE');
expect(documentAuth.documentAuthOption.globalAccessAuth).toEqual('ACCOUNT');
expect(documentAuth.documentAuthOption.globalActionAuth).toEqual(
isBillingEnabled ? 'PASSKEY' : null,
);
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');
expect(document.documentMeta?.subject).toEqual('SUBJECT');
expect(document.documentMeta?.timezone).toEqual('Etc/UTC');
const recipientOne = document.Recipient[0];
const recipientTwo = document.Recipient[1];
const recipientOneAuth = extractDocumentAuthMethods({
documentAuth: document.authOptions,
recipientAuth: recipientOne.authOptions,
});
const recipientTwoAuth = extractDocumentAuthMethods({
documentAuth: document.authOptions,
recipientAuth: recipientTwo.authOptions,
});
if (isBillingEnabled) {
expect(recipientOneAuth.derivedRecipientActionAuth).toEqual('PASSKEY');
}
expect(recipientOneAuth.derivedRecipientAccessAuth).toEqual('ACCOUNT');
expect(recipientTwoAuth.derivedRecipientAccessAuth).toEqual('ACCOUNT');
});
/**
* This is a direct copy paste of the above test but for teams.
*/
test('[TEMPLATE]: should create a team document from a team template', async ({ page }) => {
const { owner, ...team } = await seedTeam({
createTeamMembers: 2,
});
const template = await seedBlankTemplate(owner, {
createTemplateOptions: {
teamId: team.id,
},
});
const isBillingEnabled =
process.env.NEXT_PUBLIC_FEATURE_BILLING_ENABLED === 'true' && enterprisePriceId;
await seedUserSubscription({
userId: owner.id,
priceId: enterprisePriceId,
});
await apiSignin({
page,
email: owner.email,
redirectPath: `/t/${team.url}/templates/${template.id}`,
});
// Set template title.
await page.getByLabel('Title').fill('TEMPLATE_TITLE');
// Set template document access.
await page.getByTestId('documentAccessSelectValue').click();
await page.getByLabel('Require account').getByText('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 expect(page.getByTestId('documentActionSelectValue')).toContainText('Require passkey');
}
// Set email options.
await page.getByRole('button', { name: 'Email Options' }).click();
await page.getByLabel('Subject (Optional)').fill('SUBJECT');
await page.getByLabel('Message (Optional)').fill('MESSAGE');
// Set advanced options.
await page.getByRole('button', { name: 'Advanced Options' }).click();
await page.locator('button').filter({ hasText: 'YYYY-MM-DD HH:mm a' }).click();
await page.getByLabel('DD/MM/YYYY').click();
await page.locator('.time-zone-field').click();
await page.getByRole('option', { name: 'Etc/UTC' }).click();
await page.getByLabel('Redirect URL').fill('https://documenso.com');
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('heading', { name: 'Add Placeholder' })).toBeVisible();
// Add 2 signers.
await page.getByPlaceholder('Email').fill('recipient1@documenso.com');
await page.getByPlaceholder('Name').fill('Recipient 1');
await page.getByRole('button', { name: 'Add Placeholder Recipient' }).click();
await page.getByRole('textbox', { name: 'Email', exact: true }).fill('recipient2@documenso.com');
await page.getByRole('textbox', { name: 'Name', exact: true }).nth(1).fill('Recipient 2');
// 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.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
await page.getByRole('button', { name: 'Save template' }).click();
// Use template
await page.waitForURL(`/t/${team.url}/templates`);
await page.getByRole('button', { name: 'Use Template' }).click();
await page.getByRole('button', { name: 'Create as draft' }).click();
// Review that the document was created with the correct values.
await page.waitForURL(/documents/);
const documentId = Number(page.url().split('/').pop());
const document = await prisma.document.findFirstOrThrow({
where: {
id: documentId,
},
include: {
Recipient: true,
documentMeta: true,
},
});
expect(document.teamId).toEqual(team.id);
const documentAuth = extractDocumentAuthMethods({
documentAuth: document.authOptions,
});
expect(document.title).toEqual('TEMPLATE_TITLE');
expect(documentAuth.documentAuthOption.globalAccessAuth).toEqual('ACCOUNT');
expect(documentAuth.documentAuthOption.globalActionAuth).toEqual(
isBillingEnabled ? 'PASSKEY' : null,
);
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');
expect(document.documentMeta?.subject).toEqual('SUBJECT');
expect(document.documentMeta?.timezone).toEqual('Etc/UTC');
const recipientOne = document.Recipient[0];
const recipientTwo = document.Recipient[1];
const recipientOneAuth = extractDocumentAuthMethods({
documentAuth: document.authOptions,
recipientAuth: recipientOne.authOptions,
});
const recipientTwoAuth = extractDocumentAuthMethods({
documentAuth: document.authOptions,
recipientAuth: recipientTwo.authOptions,
});
if (isBillingEnabled) {
expect(recipientOneAuth.derivedRecipientActionAuth).toEqual('PASSKEY');
}
expect(recipientOneAuth.derivedRecipientAccessAuth).toEqual('ACCOUNT');
expect(recipientTwoAuth.derivedRecipientAccessAuth).toEqual('ACCOUNT');
});
@@ -189,7 +189,14 @@ test('[TEMPLATES]: use template', async ({ page }) => {
// Use personal template.
await page.getByRole('button', { name: 'Use Template' }).click();
await page.getByRole('button', { name: 'Create Document' }).click();
// Enter template values.
await page.getByPlaceholder('recipient.1@documenso.com').click();
await page.getByPlaceholder('recipient.1@documenso.com').fill(teamMemberUser.email);
await page.getByPlaceholder('Recipient 1').click();
await page.getByPlaceholder('Recipient 1').fill('name');
await page.getByRole('button', { name: 'Create as draft' }).click();
await page.waitForURL(/documents/);
await page.getByRole('main').getByRole('link', { name: 'Documents' }).click();
await page.waitForURL('/documents');
@@ -200,7 +207,14 @@ test('[TEMPLATES]: use template', async ({ page }) => {
// Use team template.
await page.getByRole('button', { name: 'Use Template' }).click();
await page.getByRole('button', { name: 'Create Document' }).click();
// Enter template values.
await page.getByPlaceholder('recipient.1@documenso.com').click();
await page.getByPlaceholder('recipient.1@documenso.com').fill(teamMemberUser.email);
await page.getByPlaceholder('Recipient 1').click();
await page.getByPlaceholder('Recipient 1').fill('name');
await page.getByRole('button', { name: 'Create as draft' }).click();
await page.waitForURL(/\/t\/.+\/documents/);
await page.getByRole('main').getByRole('link', { name: 'Documents' }).click();
await page.waitForURL(`/t/${team.url}/documents`);
Binary file not shown.
@@ -5,7 +5,7 @@ import { sealDocument } from '@documenso/lib/server-only/document/seal-document'
import { redis } from '@documenso/lib/server-only/redis';
import { stripe } from '@documenso/lib/server-only/stripe';
import { alphaid, nanoid } from '@documenso/lib/universal/id';
import { putFile } from '@documenso/lib/universal/upload/put-file';
import { putPdfFile } from '@documenso/lib/universal/upload/put-file';
import { prisma } from '@documenso/prisma';
import {
DocumentStatus,
@@ -74,7 +74,7 @@ export const onEarlyAdoptersCheckout = async ({ session }: OnEarlyAdoptersChecko
new URL('@documenso/assets/documenso-supporter-pledge.pdf', import.meta.url),
).then(async (res) => res.arrayBuffer());
const { id: documentDataId } = await putFile({
const { id: documentDataId } = await putPdfFile({
name: 'Documenso Supporter Pledge.pdf',
type: 'application/pdf',
arrayBuffer: async () => Promise.resolve(documentBuffer),
@@ -23,6 +23,10 @@ export const TemplateDocumentCancel = ({
<br />"{documentName}"
</Text>
<Text className="my-1 text-center text-base text-slate-400">
All signatures have been voided.
</Text>
<Text className="my-1 text-center text-base text-slate-400">
You don't need to sign it anymore.
</Text>
@@ -11,6 +11,7 @@ export interface TemplateDocumentInviteProps {
signDocumentLink: string;
assetBaseUrl: string;
role: RecipientRole;
selfSigner: boolean;
}
export const TemplateDocumentInvite = ({
@@ -19,6 +20,7 @@ export const TemplateDocumentInvite = ({
signDocumentLink,
assetBaseUrl,
role,
selfSigner,
}: TemplateDocumentInviteProps) => {
const { actionVerb, progressiveVerb } = RECIPIENT_ROLES_DESCRIPTION[role];
@@ -28,8 +30,19 @@ export const TemplateDocumentInvite = ({
<Section>
<Text className="text-primary mx-auto mb-0 max-w-[80%] text-center text-lg font-semibold">
{inviterName} has invited you to {actionVerb.toLowerCase()}
<br />"{documentName}"
{selfSigner ? (
<>
{`Please ${actionVerb.toLowerCase()} your document`}
<br />
{`"${documentName}"`}
</>
) : (
<>
{`${inviterName} has invited you to ${actionVerb.toLowerCase()}`}
<br />
{`"${documentName}"`}
</>
)}
</Text>
<Text className="my-1 text-center text-base text-slate-400">
+6 -1
View File
@@ -22,6 +22,7 @@ import { TemplateFooter } from '../template-components/template-footer';
export type DocumentInviteEmailTemplateProps = Partial<TemplateDocumentInviteProps> & {
customBody?: string;
role: RecipientRole;
selfSigner?: boolean;
};
export const DocumentInviteEmailTemplate = ({
@@ -32,10 +33,13 @@ export const DocumentInviteEmailTemplate = ({
assetBaseUrl = 'http://localhost:3002',
customBody,
role,
selfSigner = false,
}: DocumentInviteEmailTemplateProps) => {
const action = RECIPIENT_ROLES_DESCRIPTION[role].actionVerb.toLowerCase();
const previewText = `${inviterName} has invited you to ${action} ${documentName}`;
const previewText = selfSigner
? `Please ${action} your document ${documentName}`
: `${inviterName} has invited you to ${action} ${documentName}`;
const getAssetUrl = (path: string) => {
return new URL(path, assetBaseUrl).toString();
@@ -71,6 +75,7 @@ export const DocumentInviteEmailTemplate = ({
signDocumentLink={signDocumentLink}
assetBaseUrl={assetBaseUrl}
role={role}
selfSigner={selfSigner}
/>
</Section>
</Container>
+1
View File
@@ -21,6 +21,7 @@ export const FEATURE_FLAG_POLL_INTERVAL = 30000;
* Does not take any person or group properties into account.
*/
export const LOCAL_FEATURE_FLAGS: Record<string, boolean> = {
app_allow_encrypted_documents: false,
app_billing: NEXT_PUBLIC_FEATURE_BILLING_ENABLED() === 'true',
app_document_page_view_history_sheet: false,
app_passkey: WEBAPP_BASE_URL === 'http://localhost:3000', // Temp feature flag.
+1 -1
View File
@@ -1,6 +1,6 @@
import { APP_BASE_URL } from './app';
export const DEFAULT_STANDARD_FONT_SIZE = 15;
export const DEFAULT_STANDARD_FONT_SIZE = 12;
export const DEFAULT_HANDWRITING_FONT_SIZE = 50;
export const MIN_STANDARD_FONT_SIZE = 8;
+2
View File
@@ -0,0 +1,2 @@
export const TEMPLATE_RECIPIENT_EMAIL_PLACEHOLDER_REGEX = /recipient\.\d+@documenso\.com/i;
export const TEMPLATE_RECIPIENT_NAME_PLACEHOLDER_REGEX = /Recipient \d+/i;
+21
View File
@@ -1,4 +1,5 @@
import { TRPCError } from '@trpc/server';
import { match } from 'ts-pattern';
import { z } from 'zod';
import { TRPCClientError } from '@documenso/trpc/client';
@@ -149,4 +150,24 @@ export class AppError extends Error {
return null;
}
}
static toRestAPIError(err: unknown): {
status: 400 | 401 | 404 | 500;
body: { message: string };
} {
const error = AppError.parseError(err);
const status = match(error.code)
.with(AppErrorCode.INVALID_BODY, AppErrorCode.INVALID_REQUEST, () => 400 as const)
.with(AppErrorCode.UNAUTHORIZED, () => 401 as const)
.with(AppErrorCode.NOT_FOUND, () => 404 as const)
.otherwise(() => 500 as const);
return {
status,
body: {
message: status !== 500 ? error.message : 'Something went wrong',
},
};
}
}
+8 -4
View File
@@ -16,6 +16,7 @@
"clean": "rimraf node_modules"
},
"dependencies": {
"@auth/kysely-adapter": "^0.6.0",
"@aws-sdk/client-s3": "^3.410.0",
"@aws-sdk/cloudfront-signer": "^3.410.0",
"@aws-sdk/s3-request-presigner": "^3.410.0",
@@ -27,19 +28,21 @@
"@next-auth/prisma-adapter": "1.0.7",
"@noble/ciphers": "0.4.0",
"@noble/hashes": "1.3.2",
"@node-rs/bcrypt": "^1.10.0",
"@pdf-lib/fontkit": "^1.1.1",
"@scure/base": "^1.1.3",
"@sindresorhus/slugify": "^2.2.1",
"@upstash/redis": "^1.20.6",
"@vvo/tzdb": "^6.117.0",
"@node-rs/bcrypt": "^1.10.0",
"kysely": "^0.26.3",
"luxon": "^3.4.0",
"nanoid": "^4.0.2",
"next": "14.0.3",
"next-auth": "4.24.5",
"oslo": "^0.17.0",
"pdf-lib": "^1.17.1",
"playwright": "^1.43.0",
"pg": "^8.11.3",
"playwright": "1.43.0",
"react": "18.2.0",
"remeda": "^1.27.1",
"stripe": "^12.7.0",
@@ -48,6 +51,7 @@
},
"devDependencies": {
"@types/luxon": "^3.3.1",
"@playwright/browser-chromium": "^1.43.0"
"@types/pg": "^8.11.4",
"@playwright/browser-chromium": "1.43.0"
}
}
}
+12
View File
@@ -0,0 +1,12 @@
import { z } from 'zod';
import { URL_REGEX } from '../constants/url-regex';
/**
* Note this allows empty strings.
*/
export const ZUrlSchema = z
.string()
.refine((value) => value === undefined || value === '' || URL_REGEX.test(value), {
message: 'Please enter a valid URL',
});
@@ -49,8 +49,8 @@ export const completeDocumentWithToken = async ({
const document = await getDocument({ token, documentId });
if (document.status === DocumentStatus.COMPLETED) {
throw new Error(`Document ${document.id} has already been completed`);
if (document.status !== DocumentStatus.PENDING) {
throw new Error(`Document ${document.id} must be pending`);
}
if (document.Recipient.length === 0) {
@@ -137,7 +137,7 @@ export const completeDocumentWithToken = async ({
await sendPendingEmail({ documentId, recipientId: recipient.id });
}
const documents = await prisma.document.updateMany({
const haveAllRecipientsSigned = await prisma.document.findFirst({
where: {
id: document.id,
Recipient: {
@@ -146,13 +146,9 @@ export const completeDocumentWithToken = async ({
},
},
},
data: {
status: DocumentStatus.COMPLETED,
completedAt: new Date(),
},
});
if (documents.count > 0) {
if (haveAllRecipientsSigned) {
await sealDocument({ documentId: document.id, requestMetadata });
}
@@ -6,6 +6,7 @@ import { mailer } from '@documenso/email/mailer';
import { render } from '@documenso/email/render';
import DocumentCancelTemplate from '@documenso/email/templates/document-cancel';
import { prisma } from '@documenso/prisma';
import type { Document, DocumentMeta, Recipient, User } from '@documenso/prisma/client';
import { DocumentStatus } from '@documenso/prisma/client';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
@@ -27,110 +28,180 @@ export const deleteDocument = async ({
teamId,
requestMetadata,
}: DeleteDocumentOptions) => {
const user = await prisma.user.findUnique({
where: {
id: userId,
},
});
if (!user) {
throw new Error('User not found');
}
const document = await prisma.document.findUnique({
where: {
id,
...(teamId
? {
team: {
id: teamId,
members: {
some: {
userId,
},
},
},
}
: {
userId,
teamId: null,
}),
},
include: {
Recipient: true,
documentMeta: true,
User: true,
team: {
select: {
members: true,
},
},
},
});
if (!document) {
if (!document || (teamId !== undefined && teamId !== document.teamId)) {
throw new Error('Document not found');
}
const { status, User: user } = document;
const isUserOwner = document.userId === userId;
const isUserTeamMember = document.team?.members.some((member) => member.userId === userId);
const userRecipient = document.Recipient.find((recipient) => recipient.email === user.email);
// if the document is a draft, hard-delete
if (status === DocumentStatus.DRAFT) {
if (!isUserOwner && !isUserTeamMember && !userRecipient) {
throw new Error('Not allowed');
}
// Handle hard or soft deleting the actual document if user has permission.
if (isUserOwner || isUserTeamMember) {
await handleDocumentOwnerDelete({
document,
user,
requestMetadata,
});
}
// Continue to hide the document from the user if they are a recipient.
// Dirty way of doing this but it's faster than refetching the document.
if (userRecipient?.documentDeletedAt === null) {
await prisma.recipient
.update({
where: {
id: userRecipient.id,
},
data: {
documentDeletedAt: new Date().toISOString(),
},
})
.catch(() => {
// Do nothing.
});
}
// Return partial document for API v1 response.
return {
id: document.id,
userId: document.userId,
teamId: document.teamId,
title: document.title,
status: document.status,
documentDataId: document.documentDataId,
createdAt: document.createdAt,
updatedAt: document.updatedAt,
completedAt: document.completedAt,
};
};
type HandleDocumentOwnerDeleteOptions = {
document: Document & {
Recipient: Recipient[];
documentMeta: DocumentMeta | null;
};
user: User;
requestMetadata?: RequestMetadata;
};
const handleDocumentOwnerDelete = async ({
document,
user,
requestMetadata,
}: HandleDocumentOwnerDeleteOptions) => {
if (document.deletedAt) {
return;
}
// Soft delete completed documents.
if (document.status === DocumentStatus.COMPLETED) {
return await prisma.$transaction(async (tx) => {
// Currently redundant since deleting a document will delete the audit logs.
// However may be useful if we disassociate audit lgos and documents if required.
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
documentId: id,
documentId: document.id,
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_DELETED,
user,
requestMetadata,
data: {
type: 'HARD',
type: 'SOFT',
},
}),
});
return await tx.document.delete({ where: { id, status: DocumentStatus.DRAFT } });
return await tx.document.update({
where: {
id: document.id,
},
data: {
deletedAt: new Date().toISOString(),
},
});
});
}
// if the document is pending, send cancellation emails to all recipients
if (status === DocumentStatus.PENDING && document.Recipient.length > 0) {
await Promise.all(
document.Recipient.map(async (recipient) => {
const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000';
const template = createElement(DocumentCancelTemplate, {
documentName: document.title,
inviterName: user.name || undefined,
inviterEmail: user.email,
assetBaseUrl,
});
await mailer.sendMail({
to: {
address: recipient.email,
name: recipient.name,
},
from: {
name: FROM_NAME,
address: FROM_ADDRESS,
},
subject: 'Document Cancelled',
html: render(template),
text: render(template, { plainText: true }),
});
}),
);
}
// If the document is not a draft, only soft-delete.
return await prisma.$transaction(async (tx) => {
// Hard delete draft and pending documents.
const deletedDocument = await prisma.$transaction(async (tx) => {
// Currently redundant since deleting a document will delete the audit logs.
// However may be useful if we disassociate audit logs and documents if required.
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
documentId: id,
documentId: document.id,
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_DELETED,
user,
requestMetadata,
data: {
type: 'SOFT',
type: 'HARD',
},
}),
});
return await tx.document.update({
return await tx.document.delete({
where: {
id,
},
data: {
deletedAt: new Date().toISOString(),
id: document.id,
status: {
not: DocumentStatus.COMPLETED,
},
},
});
});
// Send cancellation emails to recipients.
await Promise.all(
document.Recipient.map(async (recipient) => {
const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000';
const template = createElement(DocumentCancelTemplate, {
documentName: document.title,
inviterName: user.name || undefined,
inviterEmail: user.email,
assetBaseUrl,
});
await mailer.sendMail({
to: {
address: recipient.email,
name: recipient.name,
},
from: {
name: FROM_NAME,
address: FROM_ADDRESS,
},
subject: 'Document Cancelled',
html: render(template),
text: render(template, { plainText: true }),
});
}),
);
return deletedDocument;
};
@@ -94,24 +94,65 @@ export const findDocuments = async ({
};
}
const whereClause: Prisma.DocumentWhereInput = {
...termFilters,
...filters,
let deletedFilter: Prisma.DocumentWhereInput = {
AND: {
OR: [
{
status: ExtendedDocumentStatus.COMPLETED,
userId: user.id,
deletedAt: null,
},
{
status: {
not: ExtendedDocumentStatus.COMPLETED,
Recipient: {
some: {
email: user.email,
documentDeletedAt: null,
},
},
deletedAt: null,
},
],
},
};
if (team) {
deletedFilter = {
AND: {
OR: team.teamEmail
? [
{
teamId: team.id,
deletedAt: null,
},
{
User: {
email: team.teamEmail.email,
},
deletedAt: null,
},
{
Recipient: {
some: {
email: team.teamEmail.email,
documentDeletedAt: null,
},
},
},
]
: [
{
teamId: team.id,
deletedAt: null,
},
],
},
};
}
const whereClause: Prisma.DocumentWhereInput = {
...termFilters,
...filters,
...deletedFilter,
};
if (period) {
const daysAgo = parseInt(period.replace(/d$/, ''), 10);
@@ -72,6 +72,7 @@ type GetCountsOption = {
const getCounts = async ({ user, createdAt }: GetCountsOption) => {
return Promise.all([
// Owner counts.
prisma.document.groupBy({
by: ['status'],
_count: {
@@ -84,6 +85,7 @@ const getCounts = async ({ user, createdAt }: GetCountsOption) => {
deletedAt: null,
},
}),
// Not signed counts.
prisma.document.groupBy({
by: ['status'],
_count: {
@@ -95,12 +97,13 @@ const getCounts = async ({ user, createdAt }: GetCountsOption) => {
some: {
email: user.email,
signingStatus: SigningStatus.NOT_SIGNED,
documentDeletedAt: null,
},
},
createdAt,
deletedAt: null,
},
}),
// Has signed counts.
prisma.document.groupBy({
by: ['status'],
_count: {
@@ -120,9 +123,9 @@ const getCounts = async ({ user, createdAt }: GetCountsOption) => {
some: {
email: user.email,
signingStatus: SigningStatus.SIGNED,
documentDeletedAt: null,
},
},
deletedAt: null,
},
{
status: ExtendedDocumentStatus.COMPLETED,
@@ -130,6 +133,7 @@ const getCounts = async ({ user, createdAt }: GetCountsOption) => {
some: {
email: user.email,
signingStatus: SigningStatus.SIGNED,
documentDeletedAt: null,
},
},
},
@@ -198,6 +202,7 @@ const getTeamCounts = async (options: GetTeamCountsOption) => {
some: {
email: teamEmail,
signingStatus: SigningStatus.NOT_SIGNED,
documentDeletedAt: null,
},
},
deletedAt: null,
@@ -219,6 +224,7 @@ const getTeamCounts = async (options: GetTeamCountsOption) => {
some: {
email: teamEmail,
signingStatus: SigningStatus.SIGNED,
documentDeletedAt: null,
},
},
deletedAt: null,
@@ -229,6 +235,7 @@ const getTeamCounts = async (options: GetTeamCountsOption) => {
some: {
email: teamEmail,
signingStatus: SigningStatus.SIGNED,
documentDeletedAt: null,
},
},
deletedAt: null,
@@ -88,6 +88,11 @@ export const resendDocument = async ({
const recipientEmailType = RECIPIENT_ROLE_TO_EMAIL_TYPE[recipient.role];
const { email, name } = recipient;
const selfSigner = email === user.email;
const selfSignerCustomEmail = `You have initiated the document ${`"${document.title}"`} that requires you to ${RECIPIENT_ROLES_DESCRIPTION[
recipient.role
].actionVerb.toLowerCase()} it.`;
const customEmailTemplate = {
'signer.name': name,
@@ -104,12 +109,20 @@ export const resendDocument = async ({
inviterEmail: user.email,
assetBaseUrl,
signDocumentLink,
customBody: renderCustomEmailTemplate(customEmail?.message || '', customEmailTemplate),
customBody: renderCustomEmailTemplate(
selfSigner && !customEmail?.message ? selfSignerCustomEmail : customEmail?.message || '',
customEmailTemplate,
),
role: recipient.role,
selfSigner,
});
const { actionVerb } = RECIPIENT_ROLES_DESCRIPTION[recipient.role];
const emailSubject = selfSigner
? `Reminder: Please ${actionVerb.toLowerCase()} your document`
: `Reminder: Please ${actionVerb.toLowerCase()} this document`;
await prisma.$transaction(
async (tx) => {
await mailer.sendMail({
@@ -122,8 +135,8 @@ export const resendDocument = async ({
address: FROM_ADDRESS,
},
subject: customEmail?.subject
? renderCustomEmailTemplate(customEmail.subject, customEmailTemplate)
: `Please ${actionVerb.toLowerCase()} this document`,
? renderCustomEmailTemplate(`Reminder: ${customEmail.subject}`, customEmailTemplate)
: emailSubject,
html: render(template),
text: render(template, { plainText: true }),
});
@@ -14,9 +14,10 @@ import { signPdf } from '@documenso/signing';
import type { RequestMetadata } from '../../universal/extract-request-metadata';
import { getFile } from '../../universal/upload/get-file';
import { putFile } from '../../universal/upload/put-file';
import { putPdfFile } from '../../universal/upload/put-file';
import { getCertificatePdf } from '../htmltopdf/get-certificate-pdf';
import { flattenAnnotations } from '../pdf/flatten-annotations';
import { flattenForm } from '../pdf/flatten-form';
import { insertFieldInPDF } from '../pdf/insert-field-in-pdf';
import { normalizeSignatureAppearances } from '../pdf/normalize-signature-appearances';
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
@@ -40,6 +41,11 @@ export const sealDocument = async ({
const document = await prisma.document.findFirstOrThrow({
where: {
id: documentId,
Recipient: {
every: {
signingStatus: SigningStatus.SIGNED,
},
},
},
include: {
documentData: true,
@@ -53,10 +59,6 @@ export const sealDocument = async ({
throw new Error(`Document ${document.id} has no document data`);
}
if (document.status !== DocumentStatus.COMPLETED) {
throw new Error(`Document ${document.id} has not been completed`);
}
const recipients = await prisma.recipient.findMany({
where: {
documentId: document.id,
@@ -92,22 +94,24 @@ export const sealDocument = async ({
// !: Need to write the fields onto the document as a hard copy
const pdfData = await getFile(documentData);
const certificate = await getCertificatePdf({ documentId }).then(async (doc) =>
PDFDocument.load(doc),
);
const certificate = await getCertificatePdf({ documentId })
.then(async (doc) => PDFDocument.load(doc))
.catch(() => null);
const doc = await PDFDocument.load(pdfData);
// Normalize and flatten layers that could cause issues with the signature
normalizeSignatureAppearances(doc);
doc.getForm().flatten();
flattenForm(doc);
flattenAnnotations(doc);
const certificatePages = await doc.copyPages(certificate, certificate.getPageIndices());
if (certificate) {
const certificatePages = await doc.copyPages(certificate, certificate.getPageIndices());
certificatePages.forEach((page) => {
doc.addPage(page);
});
certificatePages.forEach((page) => {
doc.addPage(page);
});
}
for (const field of fields) {
await insertFieldInPDF(doc, field);
@@ -119,7 +123,7 @@ export const sealDocument = async ({
const { name, ext } = path.parse(document.title);
const { data: newData } = await putFile({
const { data: newData } = await putPdfFile({
name: `${name}_signed${ext}`,
type: 'application/pdf',
arrayBuffer: async () => Promise.resolve(pdfBuffer),
@@ -138,6 +142,16 @@ export const sealDocument = async ({
}
await prisma.$transaction(async (tx) => {
await tx.document.update({
where: {
id: document.id,
},
data: {
status: DocumentStatus.COMPLETED,
completedAt: new Date(),
},
});
await tx.documentData.update({
where: {
id: documentData.id,
@@ -1,7 +1,6 @@
import { prisma } from '@documenso/prisma';
import { DocumentStatus } from '@documenso/prisma/client';
import { maskRecipientTokensForDocument } from '../../utils/mask-recipient-tokens-for-document';
import type { Document, Recipient, User } from '@documenso/prisma/client';
export type SearchDocumentsWithKeywordOptions = {
query: string;
@@ -79,12 +78,19 @@ export const searchDocumentsWithKeyword = async ({
take: limit,
});
const maskedDocuments = documents.map((document) =>
maskRecipientTokensForDocument({
document,
user,
}),
);
const isOwner = (document: Document, user: User) => document.userId === user.id;
const getSigningLink = (recipients: Recipient[], user: User) =>
`/sign/${recipients.find((r) => r.email === user.email)?.token}`;
const maskedDocuments = documents.map((document) => {
const { Recipient, ...documentWithoutRecipient } = document;
return {
...documentWithoutRecipient,
path: isOwner(document, user) ? `/documents/${document.id}` : getSigningLink(Recipient, user),
value: [document.id, document.title, ...document.Recipient.map((r) => r.email)].join(' '),
};
});
return maskedDocuments;
};
@@ -80,7 +80,7 @@ export const sendCompletedEmail = async ({ documentId, requestMetadata }: SendDo
text: render(template, { plainText: true }),
attachments: [
{
filename: document.title,
filename: document.title.endsWith('.pdf') ? document.title : document.title + '.pdf',
content: Buffer.from(completedDocument),
},
],
@@ -130,7 +130,7 @@ export const sendCompletedEmail = async ({ documentId, requestMetadata }: SendDo
text: render(template, { plainText: true }),
attachments: [
{
filename: document.title,
filename: document.title.endsWith('.pdf') ? document.title : document.title + '.pdf',
content: Buffer.from(completedDocument),
},
],
@@ -4,8 +4,11 @@ import { mailer } from '@documenso/email/mailer';
import { render } from '@documenso/email/render';
import { DocumentInviteEmailTemplate } from '@documenso/email/templates/document-invite';
import { FROM_ADDRESS, FROM_NAME } from '@documenso/lib/constants/email';
import { sealDocument } from '@documenso/lib/server-only/document/seal-document';
import { updateDocument } from '@documenso/lib/server-only/document/update-document';
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
import type { RequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { putPdfFile } from '@documenso/lib/universal/upload/put-file';
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
import { renderCustomEmailTemplate } from '@documenso/lib/utils/render-custom-email-template';
import { prisma } from '@documenso/prisma';
@@ -18,7 +21,6 @@ import {
RECIPIENT_ROLE_TO_EMAIL_TYPE,
} from '../../constants/recipient-roles';
import { getFile } from '../../universal/upload/get-file';
import { putFile } from '../../universal/upload/put-file';
import { insertFormValuesInPdf } from '../pdf/insert-form-values-in-pdf';
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
@@ -26,6 +28,7 @@ export type SendDocumentOptions = {
documentId: number;
userId: number;
teamId?: number;
sendEmail?: boolean;
requestMetadata?: RequestMetadata;
};
@@ -33,6 +36,7 @@ export const sendDocument = async ({
documentId,
userId,
teamId,
sendEmail = true,
requestMetadata,
}: SendDocumentOptions) => {
const user = await prisma.user.findFirstOrThrow({
@@ -100,7 +104,7 @@ export const sendDocument = async ({
formValues: document.formValues as Record<string, string | number | boolean>,
});
const newDocumentData = await putFile({
const newDocumentData = await putPdfFile({
name: document.title,
type: 'application/pdf',
arrayBuffer: async () => Promise.resolve(prefilled),
@@ -118,86 +122,128 @@ export const sendDocument = async ({
Object.assign(document, result);
}
await Promise.all(
document.Recipient.map(async (recipient) => {
if (recipient.sendStatus === SendStatus.SENT || recipient.role === RecipientRole.CC) {
return;
}
if (sendEmail) {
await Promise.all(
document.Recipient.map(async (recipient) => {
if (recipient.sendStatus === SendStatus.SENT || recipient.role === RecipientRole.CC) {
return;
}
const recipientEmailType = RECIPIENT_ROLE_TO_EMAIL_TYPE[recipient.role];
const recipientEmailType = RECIPIENT_ROLE_TO_EMAIL_TYPE[recipient.role];
const { email, name } = recipient;
const { email, name } = recipient;
const selfSigner = email === user.email;
const customEmailTemplate = {
'signer.name': name,
'signer.email': email,
'document.name': document.title,
};
const selfSignerCustomEmail = `You have initiated the document ${`"${document.title}"`} that requires you to ${RECIPIENT_ROLES_DESCRIPTION[
recipient.role
].actionVerb.toLowerCase()} it.`;
const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000';
const signDocumentLink = `${NEXT_PUBLIC_WEBAPP_URL()}/sign/${recipient.token}`;
const customEmailTemplate = {
'signer.name': name,
'signer.email': email,
'document.name': document.title,
};
const template = createElement(DocumentInviteEmailTemplate, {
documentName: document.title,
inviterName: user.name || undefined,
inviterEmail: user.email,
assetBaseUrl,
signDocumentLink,
customBody: renderCustomEmailTemplate(customEmail?.message || '', customEmailTemplate),
role: recipient.role,
});
const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000';
const signDocumentLink = `${NEXT_PUBLIC_WEBAPP_URL()}/sign/${recipient.token}`;
const { actionVerb } = RECIPIENT_ROLES_DESCRIPTION[recipient.role];
const template = createElement(DocumentInviteEmailTemplate, {
documentName: document.title,
inviterName: user.name || undefined,
inviterEmail: user.email,
assetBaseUrl,
signDocumentLink,
customBody: renderCustomEmailTemplate(
selfSigner && !customEmail?.message
? selfSignerCustomEmail
: customEmail?.message || '',
customEmailTemplate,
),
role: recipient.role,
selfSigner,
});
await prisma.$transaction(
async (tx) => {
await mailer.sendMail({
to: {
address: email,
name,
},
from: {
name: FROM_NAME,
address: FROM_ADDRESS,
},
subject: customEmail?.subject
? renderCustomEmailTemplate(customEmail.subject, customEmailTemplate)
: `Please ${actionVerb.toLowerCase()} this document`,
html: render(template),
text: render(template, { plainText: true }),
});
const { actionVerb } = RECIPIENT_ROLES_DESCRIPTION[recipient.role];
await tx.recipient.update({
where: {
id: recipient.id,
},
data: {
sendStatus: SendStatus.SENT,
},
});
const emailSubject = selfSigner
? `Please ${actionVerb.toLowerCase()} your document`
: `Please ${actionVerb.toLowerCase()} this document`;
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT,
documentId: document.id,
user,
requestMetadata,
data: {
emailType: recipientEmailType,
recipientEmail: recipient.email,
recipientName: recipient.name,
recipientRole: recipient.role,
recipientId: recipient.id,
isResending: false,
await prisma.$transaction(
async (tx) => {
await mailer.sendMail({
to: {
address: email,
name,
},
}),
});
},
{ timeout: 30_000 },
);
}),
from: {
name: FROM_NAME,
address: FROM_ADDRESS,
},
subject: customEmail?.subject
? renderCustomEmailTemplate(customEmail.subject, customEmailTemplate)
: emailSubject,
html: render(template),
text: render(template, { plainText: true }),
});
await tx.recipient.update({
where: {
id: recipient.id,
},
data: {
sendStatus: SendStatus.SENT,
},
});
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT,
documentId: document.id,
user,
requestMetadata,
data: {
emailType: recipientEmailType,
recipientEmail: recipient.email,
recipientName: recipient.name,
recipientRole: recipient.role,
recipientId: recipient.id,
isResending: false,
},
}),
});
},
{ timeout: 30_000 },
);
}),
);
}
const allRecipientsHaveNoActionToTake = document.Recipient.every(
(recipient) => recipient.role === RecipientRole.CC,
);
if (allRecipientsHaveNoActionToTake) {
const updatedDocument = await updateDocument({
documentId,
userId,
teamId,
data: { status: DocumentStatus.COMPLETED },
});
await sealDocument({ documentId: updatedDocument.id, requestMetadata });
// Keep the return type the same for the `sendDocument` method
return await prisma.document.findFirstOrThrow({
where: {
id: documentId,
},
include: {
Recipient: true,
},
});
}
const updatedDocument = await prisma.$transaction(async (tx) => {
if (document.status === DocumentStatus.DRAFT) {
await tx.documentAuditLog.create({
@@ -0,0 +1,29 @@
import { prisma } from '@documenso/prisma';
import { SigningStatus } from '@documenso/prisma/client';
export type GetCompletedFieldsForDocumentOptions = {
documentId: number;
};
export const getCompletedFieldsForDocument = async ({
documentId,
}: GetCompletedFieldsForDocumentOptions) => {
return await prisma.field.findMany({
where: {
documentId,
Recipient: {
signingStatus: SigningStatus.SIGNED,
},
inserted: true,
},
include: {
Signature: true,
Recipient: {
select: {
name: true,
email: true,
},
},
},
});
};
@@ -0,0 +1,33 @@
import { prisma } from '@documenso/prisma';
import { SigningStatus } from '@documenso/prisma/client';
export type GetCompletedFieldsForTokenOptions = {
token: string;
};
export const getCompletedFieldsForToken = async ({ token }: GetCompletedFieldsForTokenOptions) => {
return await prisma.field.findMany({
where: {
Document: {
Recipient: {
some: {
token,
},
},
},
Recipient: {
signingStatus: SigningStatus.SIGNED,
},
inserted: true,
},
include: {
Signature: true,
Recipient: {
select: {
name: true,
email: true,
},
},
},
});
};
@@ -36,8 +36,8 @@ export const removeSignedFieldWithToken = async ({
throw new Error(`Document not found for field ${field.id}`);
}
if (document.status === DocumentStatus.COMPLETED) {
throw new Error(`Document ${document.id} has already been completed`);
if (document.status !== DocumentStatus.PENDING) {
throw new Error(`Document ${document.id} must be pending`);
}
if (recipient?.signingStatus === SigningStatus.SIGNED) {
@@ -1,22 +1,19 @@
import { prisma } from '@documenso/prisma';
import type { FieldType } from '@documenso/prisma/client';
export type Field = {
id?: number | null;
type: FieldType;
signerEmail: string;
signerId?: number;
pageNumber: number;
pageX: number;
pageY: number;
pageWidth: number;
pageHeight: number;
};
export type SetFieldsForTemplateOptions = {
userId: number;
templateId: number;
fields: Field[];
fields: {
id?: number | null;
type: FieldType;
signerEmail: string;
pageNumber: number;
pageX: number;
pageY: number;
pageWidth: number;
pageHeight: number;
}[];
};
export const setFieldsForTemplate = async ({
@@ -58,11 +55,7 @@ export const setFieldsForTemplate = async ({
});
const removedFields = existingFields.filter(
(existingField) =>
!fields.find(
(field) =>
field.id === existingField.id || field.signerEmail === existingField.Recipient?.email,
),
(existingField) => !fields.find((field) => field.id === existingField.id),
);
const linkedFields = fields.map((field) => {
@@ -127,5 +120,13 @@ export const setFieldsForTemplate = async ({
});
}
return persistedFields;
// Filter out fields that have been removed or have been updated.
const filteredFields = existingFields.filter((field) => {
const isRemoved = removedFields.find((removedField) => removedField.id === field.id);
const isUpdated = persistedFields.find((persistedField) => persistedField.id === field.id);
return !isRemoved && !isUpdated;
});
return [...filteredFields, ...persistedFields];
};
@@ -58,14 +58,14 @@ export const signFieldWithToken = async ({
throw new Error(`Recipient not found for field ${field.id}`);
}
if (document.status === DocumentStatus.COMPLETED) {
throw new Error(`Document ${document.id} has already been completed`);
}
if (document.deletedAt) {
throw new Error(`Document ${document.id} has been deleted`);
}
if (document.status !== DocumentStatus.PENDING) {
throw new Error(`Document ${document.id} must be pending for signing`);
}
if (recipient?.signingStatus === SigningStatus.SIGNED) {
throw new Error(`Recipient ${recipient.id} has already signed`);
}
@@ -18,7 +18,9 @@ export const getCertificatePdf = async ({ documentId }: GetCertificatePdfOptions
let browser: Browser;
if (process.env.NEXT_PRIVATE_BROWSERLESS_URL) {
browser = await chromium.connect(process.env.NEXT_PRIVATE_BROWSERLESS_URL);
// !: Use CDP rather than the default `connect` method to avoid coupling to the playwright version.
// !: Previously we would have to keep the playwright version in sync with the browserless version to avoid errors.
browser = await chromium.connectOverCDP(process.env.NEXT_PRIVATE_BROWSERLESS_URL);
} else {
browser = await chromium.launch();
}
@@ -33,6 +35,7 @@ export const getCertificatePdf = async ({ documentId }: GetCertificatePdfOptions
await page.goto(`${NEXT_PUBLIC_WEBAPP_URL()}/__htmltopdf/certificate?d=${encryptedId}`, {
waitUntil: 'networkidle',
timeout: 10_000,
});
const result = await page.pdf({
@@ -0,0 +1,112 @@
import type { PDFField, PDFWidgetAnnotation } from 'pdf-lib';
import { PDFCheckBox, PDFRadioGroup, PDFRef } from 'pdf-lib';
import {
PDFDict,
type PDFDocument,
PDFName,
drawObject,
popGraphicsState,
pushGraphicsState,
rotateInPlace,
translate,
} from 'pdf-lib';
export const flattenForm = (document: PDFDocument) => {
const form = document.getForm();
form.updateFieldAppearances();
for (const field of form.getFields()) {
for (const widget of field.acroField.getWidgets()) {
flattenWidget(document, field, widget);
}
try {
form.removeField(field);
} catch (error) {
console.error(error);
}
}
};
const getPageForWidget = (document: PDFDocument, widget: PDFWidgetAnnotation) => {
const pageRef = widget.P();
let page = document.getPages().find((page) => page.ref === pageRef);
if (!page) {
const widgetRef = document.context.getObjectRef(widget.dict);
if (!widgetRef) {
return null;
}
page = document.findPageForAnnotationRef(widgetRef);
if (!page) {
return null;
}
}
return page;
};
const getAppearanceRefForWidget = (field: PDFField, widget: PDFWidgetAnnotation) => {
try {
const normalAppearance = widget.getNormalAppearance();
let normalAppearanceRef: PDFRef | null = null;
if (normalAppearance instanceof PDFRef) {
normalAppearanceRef = normalAppearance;
}
if (
normalAppearance instanceof PDFDict &&
(field instanceof PDFCheckBox || field instanceof PDFRadioGroup)
) {
const value = field.acroField.getValue();
const ref = normalAppearance.get(value) ?? normalAppearance.get(PDFName.of('Off'));
if (ref instanceof PDFRef) {
normalAppearanceRef = ref;
}
}
return normalAppearanceRef;
} catch (error) {
console.error(error);
return null;
}
};
const flattenWidget = (document: PDFDocument, field: PDFField, widget: PDFWidgetAnnotation) => {
try {
const page = getPageForWidget(document, widget);
if (!page) {
return;
}
const appearanceRef = getAppearanceRefForWidget(field, widget);
if (!appearanceRef) {
return;
}
const xObjectKey = page.node.newXObject('FlatWidget', appearanceRef);
const rectangle = widget.getRectangle();
const operators = [
pushGraphicsState(),
translate(rectangle.x, rectangle.y),
...rotateInPlace({ ...rectangle, rotation: 0 }),
drawObject(xObjectKey),
popGraphicsState(),
].filter((op) => !!op);
page.pushOperators(...operators);
} catch (error) {
console.error(error);
}
};
@@ -1,6 +1,6 @@
// https://github.com/Hopding/pdf-lib/issues/20#issuecomment-412852821
import fontkit from '@pdf-lib/fontkit';
import { PDFDocument, StandardFonts } from 'pdf-lib';
import { PDFDocument } from 'pdf-lib';
import {
DEFAULT_HANDWRITING_FONT_SIZE,
@@ -17,6 +17,10 @@ export const insertFieldInPDF = async (pdf: PDFDocument, field: FieldWithSignatu
res.arrayBuffer(),
);
const fontNoto = await fetch(process.env.FONT_NOTO_SANS_URI).then(async (res) =>
res.arrayBuffer(),
);
const isSignatureField = isSignatureFieldType(field.type);
pdf.registerFontkit(fontkit);
@@ -41,7 +45,7 @@ export const insertFieldInPDF = async (pdf: PDFDocument, field: FieldWithSignatu
const fieldX = pageWidth * (Number(field.positionX) / 100);
const fieldY = pageHeight * (Number(field.positionY) / 100);
const font = await pdf.embedFont(isSignatureField ? fontCaveat : StandardFonts.Helvetica);
const font = await pdf.embedFont(isSignatureField ? fontCaveat : fontNoto);
if (field.type === FieldType.SIGNATURE || field.type === FieldType.FREE_SIGNATURE) {
await pdf.embedFont(fontCaveat);
@@ -1,3 +1,4 @@
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { prisma } from '@documenso/prisma';
import { TeamMemberRole } from '@documenso/prisma/client';
@@ -6,6 +7,8 @@ export type GetUserTokensOptions = {
teamId: number;
};
export type GetTeamTokensResponse = Awaited<ReturnType<typeof getTeamTokens>>;
export const getTeamTokens = async ({ userId, teamId }: GetUserTokensOptions) => {
const teamMember = await prisma.teamMember.findFirst({
where: {
@@ -15,7 +18,10 @@ export const getTeamTokens = async ({ userId, teamId }: GetUserTokensOptions) =>
});
if (teamMember?.role !== TeamMemberRole.ADMIN) {
throw new Error('You do not have permission to view tokens for this team');
throw new AppError(
AppErrorCode.UNAUTHORIZED,
'You do not have the required permissions to view this page.',
);
}
return await prisma.apiToken.findMany({
@@ -1,21 +1,32 @@
import { isUserEnterprise } from '@documenso/ee/server-only/util/is-document-enterprise';
import { prisma } from '@documenso/prisma';
import type { RecipientRole } from '@documenso/prisma/client';
import type { Recipient } from '@documenso/prisma/client';
import { RecipientRole } from '@documenso/prisma/client';
import { AppError, AppErrorCode } from '../../errors/app-error';
import {
type TRecipientActionAuthTypes,
ZRecipientAuthOptionsSchema,
} from '../../types/document-auth';
import { nanoid } from '../../universal/id';
import { createRecipientAuthOptions } from '../../utils/document-auth';
export type SetRecipientsForTemplateOptions = {
userId: number;
teamId?: number;
templateId: number;
recipients: {
id?: number;
email: string;
name: string;
role: RecipientRole;
actionAuth?: TRecipientActionAuthTypes | null;
}[];
};
export const setRecipientsForTemplate = async ({
userId,
teamId,
templateId,
recipients,
}: SetRecipientsForTemplateOptions) => {
@@ -43,6 +54,23 @@ export const setRecipientsForTemplate = async ({
throw new Error('Template not found');
}
const recipientsHaveActionAuth = recipients.some((recipient) => recipient.actionAuth);
// Check if user has permission to set the global action auth.
if (recipientsHaveActionAuth) {
const isDocumentEnterprise = await isUserEnterprise({
userId,
teamId,
});
if (!isDocumentEnterprise) {
throw new AppError(
AppErrorCode.UNAUTHORIZED,
'You do not have permission to set the action auth',
);
}
}
const normalizedRecipients = recipients.map((recipient) => ({
...recipient,
email: recipient.email.toLowerCase(),
@@ -74,31 +102,59 @@ export const setRecipientsForTemplate = async ({
};
});
const persistedRecipients = await prisma.$transaction(
// Disabling as wrapping promises here causes type issues
// eslint-disable-next-line @typescript-eslint/promise-function-async
linkedRecipients.map((recipient) =>
prisma.recipient.upsert({
where: {
id: recipient._persisted?.id ?? -1,
templateId,
},
update: {
name: recipient.name,
email: recipient.email,
role: recipient.role,
templateId,
},
create: {
name: recipient.name,
email: recipient.email,
role: recipient.role,
token: nanoid(),
templateId,
},
const persistedRecipients = await prisma.$transaction(async (tx) => {
return await Promise.all(
linkedRecipients.map(async (recipient) => {
let authOptions = ZRecipientAuthOptionsSchema.parse(recipient._persisted?.authOptions);
if (recipient.actionAuth !== undefined) {
authOptions = createRecipientAuthOptions({
accessAuth: authOptions.accessAuth,
actionAuth: recipient.actionAuth,
});
}
const upsertedRecipient = await tx.recipient.upsert({
where: {
id: recipient._persisted?.id ?? -1,
templateId,
},
update: {
name: recipient.name,
email: recipient.email,
role: recipient.role,
templateId,
authOptions,
},
create: {
name: recipient.name,
email: recipient.email,
role: recipient.role,
token: nanoid(),
templateId,
authOptions,
},
});
const recipientId = upsertedRecipient.id;
// Clear all fields if the recipient role is changed to a type that cannot have fields.
if (
recipient._persisted &&
recipient._persisted.role !== recipient.role &&
(recipient.role === RecipientRole.CC || recipient.role === RecipientRole.VIEWER)
) {
await tx.field.deleteMany({
where: {
recipientId,
},
});
}
return upsertedRecipient;
}),
),
);
);
});
if (removedRecipients.length > 0) {
await prisma.recipient.deleteMany({
@@ -110,5 +166,17 @@ export const setRecipientsForTemplate = async ({
});
}
return persistedRecipients;
// Filter out recipients that have been removed or have been updated.
const filteredRecipients: Recipient[] = existingRecipients.filter((recipient) => {
const isRemoved = removedRecipients.find(
(removedRecipient) => removedRecipient.id === recipient.id,
);
const isUpdated = persistedRecipients.find(
(persistedRecipient) => persistedRecipient.id === recipient.id,
);
return !isRemoved && !isUpdated;
});
return [...filteredRecipients, ...persistedRecipients];
};
@@ -0,0 +1,144 @@
import { nanoid } from '@documenso/lib/universal/id';
import { prisma } from '@documenso/prisma';
import type { RecipientRole } from '@documenso/prisma/client';
export type CreateDocumentFromTemplateLegacyOptions = {
templateId: number;
userId: number;
teamId?: number;
recipients?: {
name?: string;
email: string;
role?: RecipientRole;
}[];
};
/**
* Legacy server function for /api/v1
*/
export const createDocumentFromTemplateLegacy = async ({
templateId,
userId,
teamId,
recipients,
}: CreateDocumentFromTemplateLegacyOptions) => {
const template = await prisma.template.findUnique({
where: {
id: templateId,
...(teamId
? {
team: {
id: teamId,
members: {
some: {
userId,
},
},
},
}
: {
userId,
teamId: null,
}),
},
include: {
Recipient: true,
Field: true,
templateDocumentData: true,
},
});
if (!template) {
throw new Error('Template not found.');
}
const documentData = await prisma.documentData.create({
data: {
type: template.templateDocumentData.type,
data: template.templateDocumentData.data,
initialData: template.templateDocumentData.initialData,
},
});
const document = await prisma.document.create({
data: {
userId,
teamId: template.teamId,
title: template.title,
documentDataId: documentData.id,
Recipient: {
create: template.Recipient.map((recipient) => ({
email: recipient.email,
name: recipient.name,
role: recipient.role,
token: nanoid(),
})),
},
},
include: {
Recipient: {
orderBy: {
id: 'asc',
},
},
documentData: true,
},
});
await prisma.field.createMany({
data: template.Field.map((field) => {
const recipient = template.Recipient.find((recipient) => recipient.id === field.recipientId);
const documentRecipient = document.Recipient.find((doc) => doc.email === recipient?.email);
if (!documentRecipient) {
throw new Error('Recipient not found.');
}
return {
type: field.type,
page: field.page,
positionX: field.positionX,
positionY: field.positionY,
width: field.width,
height: field.height,
customText: field.customText,
inserted: field.inserted,
documentId: document.id,
recipientId: documentRecipient.id,
};
}),
});
if (recipients && recipients.length > 0) {
document.Recipient = await Promise.all(
recipients.map(async (recipient, index) => {
const existingRecipient = document.Recipient.at(index);
return await prisma.recipient.upsert({
where: {
documentId_email: {
documentId: document.id,
email: existingRecipient?.email ?? recipient.email,
},
},
update: {
name: recipient.name,
email: recipient.email,
role: recipient.role,
},
create: {
documentId: document.id,
email: recipient.email,
name: recipient.name,
role: recipient.role,
token: nanoid(),
},
});
}),
);
}
return document;
};
@@ -1,16 +1,52 @@
import { nanoid } from '@documenso/lib/universal/id';
import { prisma } from '@documenso/prisma';
import type { RecipientRole } from '@documenso/prisma/client';
import type { Field } from '@documenso/prisma/client';
import { type Recipient, WebhookTriggerEvents } from '@documenso/prisma/client';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
import { ZRecipientAuthOptionsSchema } from '../../types/document-auth';
import type { RequestMetadata } from '../../universal/extract-request-metadata';
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
import {
createDocumentAuthOptions,
createRecipientAuthOptions,
extractDocumentAuthMethods,
} from '../../utils/document-auth';
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
type FinalRecipient = Pick<Recipient, 'name' | 'email' | 'role' | 'authOptions'> & {
templateRecipientId: number;
fields: Field[];
};
export type CreateDocumentFromTemplateResponse = Awaited<
ReturnType<typeof createDocumentFromTemplate>
>;
export type CreateDocumentFromTemplateOptions = {
templateId: number;
userId: number;
teamId?: number;
recipients?: {
recipients: {
id: number;
name?: string;
email: string;
role?: RecipientRole;
}[];
/**
* Values that will override the predefined values in the template.
*/
override?: {
title?: string;
subject?: string;
message?: string;
timezone?: string;
password?: string;
dateFormat?: string;
redirectUrl?: string;
};
requestMetadata?: RequestMetadata;
};
export const createDocumentFromTemplate = async ({
@@ -18,7 +54,15 @@ export const createDocumentFromTemplate = async ({
userId,
teamId,
recipients,
override,
requestMetadata,
}: CreateDocumentFromTemplateOptions) => {
const user = await prisma.user.findFirstOrThrow({
where: {
id: userId,
},
});
const template = await prisma.template.findUnique({
where: {
id: templateId,
@@ -39,16 +83,51 @@ export const createDocumentFromTemplate = async ({
}),
},
include: {
Recipient: true,
Field: true,
Recipient: {
include: {
Field: true,
},
},
templateDocumentData: true,
templateMeta: true,
},
});
if (!template) {
throw new Error('Template not found.');
throw new AppError(AppErrorCode.NOT_FOUND, 'Template not found');
}
// Check that all the passed in recipient IDs can be associated with a template recipient.
recipients.forEach((recipient) => {
const foundRecipient = template.Recipient.find(
(templateRecipient) => templateRecipient.id === recipient.id,
);
if (!foundRecipient) {
throw new AppError(
AppErrorCode.INVALID_BODY,
`Recipient with ID ${recipient.id} not found in the template.`,
);
}
});
const { documentAuthOption: templateAuthOptions } = extractDocumentAuthMethods({
documentAuth: template.authOptions,
});
const finalRecipients: FinalRecipient[] = template.Recipient.map((templateRecipient) => {
const foundRecipient = recipients.find((recipient) => recipient.id === templateRecipient.id);
return {
templateRecipientId: templateRecipient.id,
fields: templateRecipient.Field,
name: foundRecipient ? foundRecipient.name ?? '' : templateRecipient.name,
email: foundRecipient ? foundRecipient.email : templateRecipient.email,
role: templateRecipient.role,
authOptions: templateRecipient.authOptions,
};
});
const documentData = await prisma.documentData.create({
data: {
type: template.templateDocumentData.type,
@@ -57,81 +136,104 @@ export const createDocumentFromTemplate = async ({
},
});
const document = await prisma.document.create({
data: {
userId,
teamId: template.teamId,
title: template.title,
documentDataId: documentData.id,
Recipient: {
create: template.Recipient.map((recipient) => ({
email: recipient.email,
name: recipient.name,
role: recipient.role,
token: nanoid(),
})),
},
},
return await prisma.$transaction(async (tx) => {
const document = await tx.document.create({
data: {
userId,
teamId: template.teamId,
title: override?.title || template.title,
documentDataId: documentData.id,
authOptions: createDocumentAuthOptions({
globalAccessAuth: templateAuthOptions.globalAccessAuth,
globalActionAuth: templateAuthOptions.globalActionAuth,
}),
documentMeta: {
create: {
subject: override?.subject || template.templateMeta?.subject,
message: override?.message || template.templateMeta?.message,
timezone: override?.timezone || template.templateMeta?.timezone,
password: override?.password || template.templateMeta?.password,
dateFormat: override?.dateFormat || template.templateMeta?.dateFormat,
redirectUrl: override?.redirectUrl || template.templateMeta?.redirectUrl,
},
},
Recipient: {
createMany: {
data: finalRecipients.map((recipient) => {
const authOptions = ZRecipientAuthOptionsSchema.parse(recipient?.authOptions);
include: {
Recipient: {
orderBy: {
id: 'asc',
return {
email: recipient.email,
name: recipient.name,
role: recipient.role,
authOptions: createRecipientAuthOptions({
accessAuth: authOptions.accessAuth,
actionAuth: authOptions.actionAuth,
}),
token: nanoid(),
};
}),
},
},
},
documentData: true,
},
});
include: {
Recipient: {
orderBy: {
id: 'asc',
},
},
documentData: true,
},
});
await prisma.field.createMany({
data: template.Field.map((field) => {
const recipient = template.Recipient.find((recipient) => recipient.id === field.recipientId);
let fieldsToCreate: Omit<Field, 'id' | 'secondaryId' | 'templateId'>[] = [];
const documentRecipient = document.Recipient.find((doc) => doc.email === recipient?.email);
Object.values(finalRecipients).forEach(({ email, fields }) => {
const recipient = document.Recipient.find((recipient) => recipient.email === email);
return {
type: field.type,
page: field.page,
positionX: field.positionX,
positionY: field.positionY,
width: field.width,
height: field.height,
customText: field.customText,
inserted: field.inserted,
if (!recipient) {
throw new Error('Recipient not found.');
}
fieldsToCreate = fieldsToCreate.concat(
fields.map((field) => ({
documentId: document.id,
recipientId: recipient.id,
type: field.type,
page: field.page,
positionX: field.positionX,
positionY: field.positionY,
width: field.width,
height: field.height,
customText: '',
inserted: false,
})),
);
});
await tx.field.createMany({
data: fieldsToCreate,
});
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_CREATED,
documentId: document.id,
recipientId: documentRecipient?.id || null,
};
}),
});
if (recipients && recipients.length > 0) {
document.Recipient = await Promise.all(
recipients.map(async (recipient, index) => {
const existingRecipient = document.Recipient.at(index);
return await prisma.recipient.upsert({
where: {
documentId_email: {
documentId: document.id,
email: existingRecipient?.email ?? recipient.email,
},
},
update: {
name: recipient.name,
email: recipient.email,
role: recipient.role,
},
create: {
documentId: document.id,
email: recipient.email,
name: recipient.name,
role: recipient.role,
token: nanoid(),
},
});
user,
requestMetadata,
data: {
title: document.title,
},
}),
);
}
});
return document;
await triggerWebhook({
event: WebhookTriggerEvents.DOCUMENT_CREATED,
data: document,
userId,
teamId,
});
return document;
});
};
@@ -81,6 +81,10 @@ export const duplicateTemplate = async ({
(doc) => doc.email === recipient?.email,
);
if (!duplicatedTemplateRecipient) {
throw new Error('Recipient not found.');
}
return {
type: field.type,
page: field.page,
@@ -91,7 +95,7 @@ export const duplicateTemplate = async ({
customText: field.customText,
inserted: field.inserted,
templateId: duplicatedTemplate.id,
recipientId: duplicatedTemplateRecipient?.id || null,
recipientId: duplicatedTemplateRecipient.id,
};
}),
});
@@ -0,0 +1,38 @@
import { prisma } from '@documenso/prisma';
import type { TemplateWithDetails } from '@documenso/prisma/types/template';
export type GetTemplateWithDetailsByIdOptions = {
id: number;
userId: number;
};
export const getTemplateWithDetailsById = async ({
id,
userId,
}: GetTemplateWithDetailsByIdOptions): Promise<TemplateWithDetails> => {
return await prisma.template.findFirstOrThrow({
where: {
id,
OR: [
{
userId,
},
{
team: {
members: {
some: {
userId,
},
},
},
},
],
},
include: {
templateDocumentData: true,
templateMeta: true,
Recipient: true,
Field: true,
},
});
};
@@ -0,0 +1,139 @@
'use server';
import { isUserEnterprise } from '@documenso/ee/server-only/util/is-document-enterprise';
import type { RequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { prisma } from '@documenso/prisma';
import type { TemplateMeta } from '@documenso/prisma/client';
import { AppError, AppErrorCode } from '../../errors/app-error';
import type { TDocumentAccessAuthTypes, TDocumentActionAuthTypes } from '../../types/document-auth';
import { createDocumentAuthOptions, extractDocumentAuthMethods } from '../../utils/document-auth';
export type UpdateTemplateSettingsOptions = {
userId: number;
teamId?: number;
templateId: number;
data: {
title?: string;
globalAccessAuth?: TDocumentAccessAuthTypes | null;
globalActionAuth?: TDocumentActionAuthTypes | null;
};
meta?: Partial<Omit<TemplateMeta, 'id' | 'templateId'>>;
requestMetadata?: RequestMetadata;
};
export const updateTemplateSettings = async ({
userId,
teamId,
templateId,
meta,
data,
}: UpdateTemplateSettingsOptions) => {
if (!data.title && !data.globalAccessAuth && !data.globalActionAuth) {
throw new AppError(AppErrorCode.INVALID_BODY, 'Missing data to update');
}
const template = await prisma.template.findFirstOrThrow({
where: {
id: templateId,
...(teamId
? {
team: {
id: teamId,
members: {
some: {
userId,
},
},
},
}
: {
userId,
teamId: null,
}),
},
include: {
templateMeta: true,
},
});
const { documentAuthOption } = extractDocumentAuthMethods({
documentAuth: template.authOptions,
});
const { templateMeta } = template;
const isDateSame = (templateMeta?.dateFormat || null) === (meta?.dateFormat || null);
const isMessageSame = (templateMeta?.message || null) === (meta?.message || null);
const isPasswordSame = (templateMeta?.password || null) === (meta?.password || null);
const isSubjectSame = (templateMeta?.subject || null) === (meta?.subject || null);
const isRedirectUrlSame = (templateMeta?.redirectUrl || null) === (meta?.redirectUrl || null);
const isTimezoneSame = (templateMeta?.timezone || null) === (meta?.timezone || null);
// Early return to avoid unnecessary updates.
if (
template.title === data.title &&
data.globalAccessAuth === documentAuthOption.globalAccessAuth &&
data.globalActionAuth === documentAuthOption.globalActionAuth &&
isDateSame &&
isMessageSame &&
isPasswordSame &&
isSubjectSame &&
isRedirectUrlSame &&
isTimezoneSame
) {
return template;
}
const documentGlobalAccessAuth = documentAuthOption?.globalAccessAuth ?? null;
const documentGlobalActionAuth = documentAuthOption?.globalActionAuth ?? null;
// If the new global auth values aren't passed in, fallback to the current document values.
const newGlobalAccessAuth =
data?.globalAccessAuth === undefined ? documentGlobalAccessAuth : data.globalAccessAuth;
const newGlobalActionAuth =
data?.globalActionAuth === undefined ? documentGlobalActionAuth : data.globalActionAuth;
// Check if user has permission to set the global action auth.
if (newGlobalActionAuth) {
const isDocumentEnterprise = await isUserEnterprise({
userId,
teamId,
});
if (!isDocumentEnterprise) {
throw new AppError(
AppErrorCode.UNAUTHORIZED,
'You do not have permission to set the action auth',
);
}
}
const authOptions = createDocumentAuthOptions({
globalAccessAuth: newGlobalAccessAuth,
globalActionAuth: newGlobalActionAuth,
});
return await prisma.template.update({
where: {
id: templateId,
},
data: {
title: data.title,
authOptions,
templateMeta: {
upsert: {
where: {
templateId,
},
create: {
...meta,
},
update: {
...meta,
},
},
},
},
});
};
@@ -1,31 +1,27 @@
import { DateTime } from 'luxon';
import { prisma } from '@documenso/prisma';
export type GetCompletedDocumentsMonthlyResult = Array<{
month: string;
count: number;
cume_count: number;
}>;
type GetCompletedDocumentsMonthlyQueryResult = Array<{
month: Date;
count: bigint;
cume_count: bigint;
}>;
import { kyselyPrisma, sql } from '@documenso/prisma';
import { DocumentStatus } from '@documenso/prisma/client';
export const getCompletedDocumentsMonthly = async () => {
const result = await prisma.$queryRaw<GetCompletedDocumentsMonthlyQueryResult>`
SELECT
DATE_TRUNC('month', "updatedAt") AS "month",
COUNT("id") as "count",
SUM(COUNT("id")) OVER (ORDER BY DATE_TRUNC('month', "updatedAt")) as "cume_count"
FROM "Document"
WHERE "status" = 'COMPLETED'
GROUP BY "month"
ORDER BY "month" DESC
LIMIT 12
`;
const qb = kyselyPrisma.$kysely
.selectFrom('Document')
.select(({ fn }) => [
fn<Date>('DATE_TRUNC', [sql.lit('MONTH'), 'Document.updatedAt']).as('month'),
fn.count('id').as('count'),
fn
.sum(fn.count('id'))
// Feels like a bug in the Kysely extension but I just can not do this orderBy in a type-safe manner
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions, @typescript-eslint/no-explicit-any
.over((ob) => ob.orderBy(fn('DATE_TRUNC', [sql.lit('MONTH'), 'Document.updatedAt']) as any))
.as('cume_count'),
])
.where(() => sql`"Document"."status" = ${DocumentStatus.COMPLETED}::"DocumentStatus"`)
.groupBy('month')
.orderBy('month', 'desc')
.limit(12);
const result = await qb.execute();
return result.map((row) => ({
month: DateTime.fromJSDate(row.month).toFormat('yyyy-MM'),
@@ -33,3 +29,7 @@ export const getCompletedDocumentsMonthly = async () => {
cume_count: Number(row.cume_count),
}));
};
export type GetCompletedDocumentsMonthlyResult = Awaited<
ReturnType<typeof getCompletedDocumentsMonthly>
>;
@@ -1,30 +1,25 @@
import { DateTime } from 'luxon';
import { prisma } from '@documenso/prisma';
export type GetUserMonthlyGrowthResult = Array<{
month: string;
count: number;
cume_count: number;
}>;
type GetUserMonthlyGrowthQueryResult = Array<{
month: Date;
count: bigint;
cume_count: bigint;
}>;
import { kyselyPrisma, sql } from '@documenso/prisma';
export const getUserMonthlyGrowth = async () => {
const result = await prisma.$queryRaw<GetUserMonthlyGrowthQueryResult>`
SELECT
DATE_TRUNC('month', "createdAt") AS "month",
COUNT("id") as "count",
SUM(COUNT("id")) OVER (ORDER BY DATE_TRUNC('month', "createdAt")) as "cume_count"
FROM "User"
GROUP BY "month"
ORDER BY "month" DESC
LIMIT 12
`;
const qb = kyselyPrisma.$kysely
.selectFrom('User')
.select(({ fn }) => [
fn<Date>('DATE_TRUNC', [sql.lit('MONTH'), 'User.createdAt']).as('month'),
fn.count('id').as('count'),
fn
.sum(fn.count('id'))
// Feels like a bug in the Kysely extension but I just can not do this orderBy in a type-safe manner
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions, @typescript-eslint/no-explicit-any
.over((ob) => ob.orderBy(fn('DATE_TRUNC', [sql.lit('MONTH'), 'User.createdAt']) as any))
.as('cume_count'),
])
.groupBy('month')
.orderBy('month', 'desc')
.limit(12);
const result = await qb.execute();
return result.map((row) => ({
month: DateTime.fromJSDate(row.month).toFormat('yyyy-MM'),
@@ -32,3 +27,5 @@ export const getUserMonthlyGrowth = async () => {
cume_count: Number(row.cume_count),
}));
};
export type GetUserMonthlyGrowthResult = Awaited<ReturnType<typeof getUserMonthlyGrowth>>;
@@ -4,6 +4,7 @@ export const getWebhooksByUserId = async (userId: number) => {
return await prisma.webhook.findMany({
where: {
userId,
teamId: null,
},
orderBy: {
createdAt: 'desc',
+3
View File
@@ -0,0 +1,3 @@
import type { getCompletedFieldsForToken } from '../server-only/field/get-completed-fields-for-token';
export type CompletedField = Awaited<ReturnType<typeof getCompletedFieldsForToken>>[number];
+15 -6
View File
@@ -17,6 +17,7 @@ export const getFlag = async (
options?: GetFlagOptions,
): Promise<TFeatureFlagValue> => {
const requestHeaders = options?.requestHeaders ?? {};
delete requestHeaders['content-length'];
if (!isFeatureFlagEnabled()) {
return LOCAL_FEATURE_FLAGS[flag] ?? true;
@@ -25,7 +26,7 @@ export const getFlag = async (
const url = new URL(`${APP_BASE_URL()}/api/feature-flag/get`);
url.searchParams.set('flag', flag);
const response = await fetch(url, {
return await fetch(url, {
headers: {
...requestHeaders,
},
@@ -35,9 +36,10 @@ export const getFlag = async (
})
.then(async (res) => res.json())
.then((res) => ZFeatureFlagValueSchema.parse(res))
.catch(() => false);
return response;
.catch((err) => {
console.error(err);
return LOCAL_FEATURE_FLAGS[flag] ?? false;
});
};
/**
@@ -50,6 +52,7 @@ export const getAllFlags = async (
options?: GetFlagOptions,
): Promise<Record<string, TFeatureFlagValue>> => {
const requestHeaders = options?.requestHeaders ?? {};
delete requestHeaders['content-length'];
if (!isFeatureFlagEnabled()) {
return LOCAL_FEATURE_FLAGS;
@@ -67,7 +70,10 @@ export const getAllFlags = async (
})
.then(async (res) => res.json())
.then((res) => z.record(z.string(), ZFeatureFlagValueSchema).parse(res))
.catch(() => LOCAL_FEATURE_FLAGS);
.catch((err) => {
console.error(err);
return LOCAL_FEATURE_FLAGS;
});
};
/**
@@ -89,7 +95,10 @@ export const getAllAnonymousFlags = async (): Promise<Record<string, TFeatureFla
})
.then(async (res) => res.json())
.then((res) => z.record(z.string(), ZFeatureFlagValueSchema).parse(res))
.catch(() => LOCAL_FEATURE_FLAGS);
.catch((err) => {
console.error(err);
return LOCAL_FEATURE_FLAGS;
});
};
interface GetFlagOptions {
+30 -3
View File
@@ -1,9 +1,12 @@
import { base64 } from '@scure/base';
import { env } from 'next-runtime-env';
import { PDFDocument } from 'pdf-lib';
import { match } from 'ts-pattern';
import { getFlag } from '@documenso/lib/universal/get-feature-flag';
import { DocumentDataType } from '@documenso/prisma/client';
import { AppError } from '../../errors/app-error';
import { createDocumentData } from '../../server-only/document-data/create-document-data';
type File = {
@@ -12,14 +15,38 @@ type File = {
arrayBuffer: () => Promise<ArrayBuffer>;
};
/**
* Uploads a document file to the appropriate storage location and creates
* a document data record.
*/
export const putPdfFile = async (file: File) => {
const isEncryptedDocumentsAllowed = await getFlag('app_allow_encrypted_documents').catch(
() => false,
);
// This will prevent uploading encrypted PDFs or anything that can't be opened.
if (!isEncryptedDocumentsAllowed) {
await PDFDocument.load(await file.arrayBuffer()).catch((e) => {
console.error(`PDF upload parse error: ${e.message}`);
throw new AppError('INVALID_DOCUMENT_FILE');
});
}
const { type, data } = await putFile(file);
return await createDocumentData({ type, data });
};
/**
* Uploads a file to the appropriate storage location.
*/
export const putFile = async (file: File) => {
const NEXT_PUBLIC_UPLOAD_TRANSPORT = env('NEXT_PUBLIC_UPLOAD_TRANSPORT');
const { type, data } = await match(NEXT_PUBLIC_UPLOAD_TRANSPORT)
return await match(NEXT_PUBLIC_UPLOAD_TRANSPORT)
.with('s3', async () => putFileInS3(file))
.otherwise(async () => putFileInDatabase(file));
return await createDocumentData({ type, data });
};
const putFileInDatabase = async (file: File) => {
+1
View File
@@ -0,0 +1 @@
generated/
+27 -15
View File
@@ -1,21 +1,33 @@
import { PrismaClient } from '@prisma/client';
import { Kysely, PostgresAdapter, PostgresIntrospector, PostgresQueryCompiler } from 'kysely';
import kyselyExtension from 'prisma-extension-kysely';
import type { DB } from './generated/types';
import { getDatabaseUrl } from './helper';
import { remember } from './utils/remember';
declare global {
// We need `var` to declare a global variable in TypeScript
// eslint-disable-next-line no-var
var prisma: PrismaClient | undefined;
}
export const prisma = remember(
'prisma',
() =>
new PrismaClient({
datasourceUrl: getDatabaseUrl(),
}),
);
if (!globalThis.prisma) {
globalThis.prisma = new PrismaClient({ datasourceUrl: getDatabaseUrl() });
}
export const kyselyPrisma = remember('kyselyPrisma', () =>
prisma.$extends(
kyselyExtension({
kysely: (driver) =>
new Kysely<DB>({
dialect: {
createAdapter: () => new PostgresAdapter(),
createDriver: () => driver,
createIntrospector: (db) => new PostgresIntrospector(db),
createQueryCompiler: () => new PostgresQueryCompiler(),
},
}),
}),
),
);
export const prisma =
globalThis.prisma ||
new PrismaClient({
datasourceUrl: getDatabaseUrl(),
});
export const getPrismaClient = () => prisma;
export { sql } from 'kysely';
@@ -0,0 +1,13 @@
-- AlterTable
ALTER TABLE "Recipient" ADD COLUMN "documentDeletedAt" TIMESTAMP(3);
-- Hard delete all PENDING documents that have been soft deleted
DELETE FROM "Document" WHERE "deletedAt" IS NOT NULL AND "status" = 'PENDING';
-- Update all recipients who are the owner of the document and where the document has deletedAt set to not null
UPDATE "Recipient"
SET "documentDeletedAt" = "Document"."deletedAt"
FROM "Document", "User"
WHERE "Recipient"."documentId" = "Document"."id"
AND "Recipient"."email" = "User"."email"
AND "Document"."deletedAt" IS NOT NULL;
@@ -0,0 +1,11 @@
/*
Warnings:
- Made the column `recipientId` on table `Field` required. This step will fail if there are existing NULL values in that column.
*/
-- Drop all Fields where the recipientId is null
DELETE FROM "Field" WHERE "recipientId" IS NULL;
-- AlterTable
ALTER TABLE "Field" ALTER COLUMN "recipientId" SET NOT NULL;
@@ -0,0 +1,23 @@
-- DropForeignKey
ALTER TABLE "PasswordResetToken" DROP CONSTRAINT "PasswordResetToken_userId_fkey";
-- DropForeignKey
ALTER TABLE "Signature" DROP CONSTRAINT "Signature_fieldId_fkey";
-- DropForeignKey
ALTER TABLE "Team" DROP CONSTRAINT "Team_ownerUserId_fkey";
-- DropForeignKey
ALTER TABLE "TeamMember" DROP CONSTRAINT "TeamMember_userId_fkey";
-- AddForeignKey
ALTER TABLE "PasswordResetToken" ADD CONSTRAINT "PasswordResetToken_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Signature" ADD CONSTRAINT "Signature_fieldId_fkey" FOREIGN KEY ("fieldId") REFERENCES "Field"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Team" ADD CONSTRAINT "Team_ownerUserId_fkey" FOREIGN KEY ("ownerUserId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "TeamMember" ADD CONSTRAINT "TeamMember_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,22 @@
-- AlterTable
ALTER TABLE "Template" ADD COLUMN "authOptions" JSONB;
-- CreateTable
CREATE TABLE "TemplateMeta" (
"id" TEXT NOT NULL,
"subject" TEXT,
"message" TEXT,
"timezone" TEXT DEFAULT 'Etc/UTC',
"password" TEXT,
"dateFormat" TEXT DEFAULT 'yyyy-MM-dd hh:mm a',
"templateId" INTEGER NOT NULL,
"redirectUrl" TEXT,
CONSTRAINT "TemplateMeta_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "TemplateMeta_templateId_key" ON "TemplateMeta"("templateId");
-- AddForeignKey
ALTER TABLE "TemplateMeta" ADD CONSTRAINT "TemplateMeta_templateId_fkey" FOREIGN KEY ("templateId") REFERENCES "Template"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+6 -2
View File
@@ -12,21 +12,25 @@
"prisma:generate": "prisma generate",
"prisma:migrate-dev": "prisma migrate dev --skip-seed",
"prisma:migrate-deploy": "prisma migrate deploy",
"prisma:migrate-reset": "prisma migrate reset",
"prisma:seed": "prisma db seed",
"prisma:studio": "prisma studio"
},
"prisma": {
"seed": "ts-node --transpileOnly --project ./tsconfig.seed.json ./seed-database.ts"
"seed": "tsx ./seed-database.ts"
},
"dependencies": {
"@prisma/client": "5.4.2",
"kysely": "^0.27.3",
"prisma": "5.4.2",
"prisma-extension-kysely": "^2.1.0",
"ts-pattern": "^5.0.6"
},
"devDependencies": {
"dotenv": "^16.3.1",
"dotenv-cli": "^7.3.0",
"ts-node": "^10.9.1",
"prisma-kysely": "^1.8.0",
"tsx": "^4.11.0",
"typescript": "5.2.2"
}
}
+47 -28
View File
@@ -1,3 +1,7 @@
generator kysely {
provider = "prisma-kysely"
}
generator client {
provider = "prisma-client-js"
}
@@ -99,7 +103,7 @@ model PasswordResetToken {
createdAt DateTime @default(now())
expiry DateTime
userId Int
User User @relation(fields: [userId], references: [id])
User User @relation(fields: [userId], references: [id], onDelete: Cascade)
}
model Passkey {
@@ -348,23 +352,24 @@ enum RecipientRole {
}
model Recipient {
id Int @id @default(autoincrement())
documentId Int?
templateId Int?
email String @db.VarChar(255)
name String @default("") @db.VarChar(255)
token String
expired DateTime?
signedAt DateTime?
authOptions Json?
role RecipientRole @default(SIGNER)
readStatus ReadStatus @default(NOT_OPENED)
signingStatus SigningStatus @default(NOT_SIGNED)
sendStatus SendStatus @default(NOT_SENT)
Document Document? @relation(fields: [documentId], references: [id], onDelete: Cascade)
Template Template? @relation(fields: [templateId], references: [id], onDelete: Cascade)
Field Field[]
Signature Signature[]
id Int @id @default(autoincrement())
documentId Int?
templateId Int?
email String @db.VarChar(255)
name String @default("") @db.VarChar(255)
token String
documentDeletedAt DateTime?
expired DateTime?
signedAt DateTime?
authOptions Json?
role RecipientRole @default(SIGNER)
readStatus ReadStatus @default(NOT_OPENED)
signingStatus SigningStatus @default(NOT_SIGNED)
sendStatus SendStatus @default(NOT_SENT)
Document Document? @relation(fields: [documentId], references: [id], onDelete: Cascade)
Template Template? @relation(fields: [templateId], references: [id], onDelete: Cascade)
Field Field[]
Signature Signature[]
@@unique([documentId, email])
@@unique([templateId, email])
@@ -387,7 +392,7 @@ model Field {
secondaryId String @unique @default(cuid())
documentId Int?
templateId Int?
recipientId Int?
recipientId Int
type FieldType
page Int
positionX Decimal @default(0)
@@ -398,7 +403,7 @@ model Field {
inserted Boolean
Document Document? @relation(fields: [documentId], references: [id], onDelete: Cascade)
Template Template? @relation(fields: [templateId], references: [id], onDelete: Cascade)
Recipient Recipient? @relation(fields: [recipientId], references: [id], onDelete: Cascade)
Recipient Recipient @relation(fields: [recipientId], references: [id], onDelete: Cascade)
Signature Signature?
@@index([documentId])
@@ -415,7 +420,7 @@ model Signature {
typedSignature String?
Recipient Recipient @relation(fields: [recipientId], references: [id], onDelete: Cascade)
Field Field @relation(fields: [fieldId], references: [id], onDelete: Restrict)
Field Field @relation(fields: [fieldId], references: [id], onDelete: Cascade)
@@index([recipientId])
}
@@ -457,7 +462,7 @@ model Team {
emailVerification TeamEmailVerification?
transferVerification TeamTransferVerification?
owner User @relation(fields: [ownerUserId], references: [id])
owner User @relation(fields: [ownerUserId], references: [id], onDelete: Cascade)
subscription Subscription?
document Document[]
@@ -483,7 +488,7 @@ model TeamMember {
createdAt DateTime @default(now())
role TeamMemberRole
userId Int
user User @relation(fields: [userId], references: [id])
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
@@unique([userId, teamId])
@@ -539,15 +544,29 @@ enum TemplateType {
PRIVATE
}
model TemplateMeta {
id String @id @default(cuid())
subject String?
message String?
timezone String? @default("Etc/UTC") @db.Text
password String?
dateFormat String? @default("yyyy-MM-dd hh:mm a") @db.Text
templateId Int @unique
template Template @relation(fields: [templateId], references: [id], onDelete: Cascade)
redirectUrl String?
}
model Template {
id Int @id @default(autoincrement())
type TemplateType @default(PRIVATE)
id Int @id @default(autoincrement())
type TemplateType @default(PRIVATE)
title String
userId Int
teamId Int?
authOptions Json?
templateMeta TemplateMeta?
templateDocumentDataId String
createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt
createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt
team Team? @relation(fields: [teamId], references: [id], onDelete: Cascade)
templateDocumentData DocumentData @relation(fields: [templateDocumentDataId], references: [id], onDelete: Cascade)
@@ -564,5 +583,5 @@ model SiteSettings {
data Json
lastModifiedByUserId Int?
lastModifiedAt DateTime @default(now())
lastModifiedByUser User? @relation(fields: [lastModifiedByUserId], references: [id])
lastModifiedByUser User? @relation(fields: [lastModifiedByUserId], references: [id], onDelete: SetNull)
}
+9 -8
View File
@@ -342,14 +342,15 @@ export const seedPendingDocumentWithFullFields = async ({
},
});
const latestDocument = updateDocumentOptions
? await prisma.document.update({
where: {
id: document.id,
},
data: updateDocumentOptions,
})
: document;
const latestDocument = await prisma.document.update({
where: {
id: document.id,
},
data: {
...updateDocumentOptions,
status: DocumentStatus.PENDING,
},
});
return {
document: latestDocument,
+27
View File
@@ -2,6 +2,7 @@ import fs from 'node:fs';
import path from 'node:path';
import { prisma } from '..';
import type { Prisma, User } from '../client';
import { DocumentDataType, ReadStatus, RecipientRole, SendStatus, SigningStatus } from '../client';
const examplePdf = fs
@@ -14,6 +15,32 @@ type SeedTemplateOptions = {
teamId?: number;
};
type CreateTemplateOptions = {
key?: string | number;
createTemplateOptions?: Partial<Prisma.TemplateUncheckedCreateInput>;
};
export const seedBlankTemplate = async (owner: User, options: CreateTemplateOptions = {}) => {
const { key, createTemplateOptions = {} } = options;
const documentData = await prisma.documentData.create({
data: {
type: DocumentDataType.BYTES_64,
data: examplePdf,
initialData: examplePdf,
},
});
return await prisma.template.create({
data: {
title: `[TEST] Template ${key}`,
templateDocumentDataId: documentData.id,
userId: owner.id,
...createTemplateOptions,
},
});
};
export const seedTemplate = async (options: SeedTemplateOptions) => {
const { title = 'Untitled', userId, teamId } = options;
+19
View File
@@ -0,0 +1,19 @@
import type {
DocumentData,
Field,
Recipient,
Template,
TemplateMeta,
} from '@documenso/prisma/client';
export type TemplateWithData = Template & {
templateDocumentData?: DocumentData | null;
templateMeta?: TemplateMeta | null;
};
export type TemplateWithDetails = Template & {
templateDocumentData: DocumentData;
templateMeta: TemplateMeta | null;
Recipient: Recipient[];
Field: Field[];
};
+18
View File
@@ -0,0 +1,18 @@
declare global {
// eslint-disable-next-line no-var, @typescript-eslint/no-explicit-any
var __prisma_remember: Map<string, any>;
}
export function remember<T>(name: string, getValue: () => T): T {
const thusly = globalThis;
if (!thusly.__prisma_remember) {
thusly.__prisma_remember = new Map();
}
if (!thusly.__prisma_remember.has(name)) {
thusly.__prisma_remember.set(name, getValue());
}
return thusly.__prisma_remember.get(name);
}
+5
View File
@@ -124,10 +124,15 @@ module.exports = {
from: { height: 'var(--radix-accordion-content-height)' },
to: { height: 0 },
},
'caret-blink': {
'0%,70%,100%': { opacity: '1' },
'20%,50%': { opacity: '0' },
},
},
animation: {
'accordion-down': 'accordion-down 0.2s ease-out',
'accordion-up': 'accordion-up 0.2s ease-out',
'caret-blink': 'caret-blink 1.25s ease-out infinite',
},
screens: {
'3xl': '1920px',
+10 -4
View File
@@ -1,6 +1,7 @@
import { TRPCError } from '@trpc/server';
import { findDocuments } from '@documenso/lib/server-only/admin/get-all-documents';
import { getEntireDocument } from '@documenso/lib/server-only/admin/get-entire-document';
import { updateRecipient } from '@documenso/lib/server-only/admin/update-recipient';
import { updateUser } from '@documenso/lib/server-only/admin/update-user';
import { sealDocument } from '@documenso/lib/server-only/document/seal-document';
@@ -10,6 +11,7 @@ import { upsertSiteSetting } from '@documenso/lib/server-only/site-settings/upse
import { deleteUser } from '@documenso/lib/server-only/user/delete-user';
import { getUserById } from '@documenso/lib/server-only/user/get-user-by-id';
import { extractNextApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { DocumentStatus } from '@documenso/prisma/client';
import { adminProcedure, router } from '../trpc';
import {
@@ -100,9 +102,13 @@ export const adminRouter = router({
const { id } = input;
try {
return await sealDocument({ documentId: id, isResealing: true });
const document = await getEntireDocument({ id });
const isResealing = document.status === DocumentStatus.COMPLETED;
return await sealDocument({ documentId: id, isResealing });
} catch (err) {
console.log('resealDocument error', err);
console.error('resealDocument error', err);
throw new TRPCError({
code: 'BAD_REQUEST',
@@ -123,7 +129,7 @@ export const adminRouter = router({
return await deleteUser({ id });
} catch (err) {
console.log(err);
console.error(err);
throw new TRPCError({
code: 'BAD_REQUEST',
@@ -144,7 +150,7 @@ export const adminRouter = router({
requestMetadata: extractNextApiRequestMetadata(ctx.req),
});
} catch (err) {
console.log(err);
console.error(err);
throw new TRPCError({
code: 'BAD_REQUEST',
@@ -4,6 +4,7 @@ import { DateTime } from 'luxon';
import { getServerLimits } from '@documenso/ee/server-only/limits/server';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { DOCUMENSO_ENCRYPTION_KEY } from '@documenso/lib/constants/crypto';
import { AppError } from '@documenso/lib/errors/app-error';
import { encryptSecondaryData } from '@documenso/lib/server-only/crypto/encrypt';
import { upsertDocumentMeta } from '@documenso/lib/server-only/document-meta/upsert-document-meta';
import { createDocument } from '@documenso/lib/server-only/document/create-document';
@@ -20,6 +21,7 @@ import { updateDocumentSettings } from '@documenso/lib/server-only/document/upda
import { updateTitle } from '@documenso/lib/server-only/document/update-title';
import { symmetricEncrypt } from '@documenso/lib/universal/crypto';
import { extractNextApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { DocumentStatus } from '@documenso/prisma/client';
import { authenticatedProcedure, procedure, router } from '../trpc';
import {
@@ -358,6 +360,7 @@ export const documentRouter = router({
query,
userId: ctx.user.id,
});
return documents;
} catch (err) {
console.error(err);
@@ -412,6 +415,10 @@ export const documentRouter = router({
teamId,
});
if (document.status !== DocumentStatus.COMPLETED) {
throw new AppError('DOCUMENT_NOT_COMPLETE');
}
const encrypted = encryptSecondaryData({
data: document.id.toString(),
expiresAt: DateTime.now().plus({ minutes: 5 }).toJSDate().valueOf(),
+1 -1
View File
@@ -53,7 +53,7 @@ export const fieldRouter = router({
const { templateId, fields } = input;
try {
await setFieldsForTemplate({
return await setFieldsForTemplate({
userId: ctx.user.id,
templateId,
fields: fields.map((field) => ({
@@ -90,7 +90,7 @@ export const profileRouter = router({
try {
const { url } = input;
if (IS_BILLING_ENABLED() && url.length <= 6) {
if (IS_BILLING_ENABLED() && url.length < 6) {
const subscriptions = await getSubscriptionsByUserId({
userId: ctx.user.id,
}).then((subscriptions) =>
@@ -46,16 +46,18 @@ export const recipientRouter = router({
.input(ZAddTemplateSignersMutationSchema)
.mutation(async ({ input, ctx }) => {
try {
const { templateId, signers } = input;
const { templateId, signers, teamId } = input;
return await setRecipientsForTemplate({
userId: ctx.user.id,
teamId,
templateId,
recipients: signers.map((signer) => ({
id: signer.nativeId,
email: signer.email,
name: signer.name,
role: signer.role,
actionAuth: signer.actionAuth,
})),
});
} catch (err) {
@@ -34,6 +34,7 @@ export type TAddSignersMutationSchema = z.infer<typeof ZAddSignersMutationSchema
export const ZAddTemplateSignersMutationSchema = z
.object({
teamId: z.number().optional(),
templateId: z.number(),
signers: z.array(
z.object({
@@ -41,6 +42,7 @@ export const ZAddTemplateSignersMutationSchema = z
email: z.string().email().min(1),
name: z.string(),
role: z.nativeEnum(RecipientRole),
actionAuth: ZRecipientActionAuthTypesSchema.optional().nullable(),
}),
),
})
@@ -10,7 +10,7 @@ import { FROM_ADDRESS, FROM_NAME, SERVICE_USER_EMAIL } from '@documenso/lib/cons
import { insertFieldInPDF } from '@documenso/lib/server-only/pdf/insert-field-in-pdf';
import { alphaid } from '@documenso/lib/universal/id';
import { getFile } from '@documenso/lib/universal/upload/get-file';
import { putFile } from '@documenso/lib/universal/upload/put-file';
import { putPdfFile } from '@documenso/lib/universal/upload/put-file';
import { prisma } from '@documenso/prisma';
import {
DocumentStatus,
@@ -86,7 +86,7 @@ export const singleplayerRouter = router({
},
});
const { id: documentDataId } = await putFile({
const { id: documentDataId } = await putPdfFile({
name: `${documentName}.pdf`,
type: 'application/pdf',
arrayBuffer: async () => Promise.resolve(signedPdfBuffer),
+76 -5
View File
@@ -1,10 +1,16 @@
import { TRPCError } from '@trpc/server';
import { getServerLimits } from '@documenso/ee/server-only/limits/server';
import { AppError } from '@documenso/lib/errors/app-error';
import { sendDocument } from '@documenso/lib/server-only/document/send-document';
import { createDocumentFromTemplate } from '@documenso/lib/server-only/template/create-document-from-template';
import { createTemplate } from '@documenso/lib/server-only/template/create-template';
import { deleteTemplate } from '@documenso/lib/server-only/template/delete-template';
import { duplicateTemplate } from '@documenso/lib/server-only/template/duplicate-template';
import { getTemplateWithDetailsById } from '@documenso/lib/server-only/template/get-template-with-details-by-id';
import { updateTemplateSettings } from '@documenso/lib/server-only/template/update-template-settings';
import { extractNextApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import type { Document } from '@documenso/prisma/client';
import { authenticatedProcedure, router } from '../trpc';
import {
@@ -12,6 +18,8 @@ import {
ZCreateTemplateMutationSchema,
ZDeleteTemplateMutationSchema,
ZDuplicateTemplateMutationSchema,
ZGetTemplateWithDetailsByIdQuerySchema,
ZUpdateTemplateSettingsMutationSchema,
} from './schema';
export const templateRouter = router({
@@ -49,19 +57,34 @@ export const templateRouter = router({
throw new Error('You have reached your document limit.');
}
return await createDocumentFromTemplate({
const requestMetadata = extractNextApiRequestMetadata(ctx.req);
let document: Document = await createDocumentFromTemplate({
templateId,
teamId,
userId: ctx.user.id,
recipients: input.recipients,
requestMetadata,
});
if (input.sendDocument) {
document = await sendDocument({
documentId: document.id,
userId: ctx.user.id,
teamId,
requestMetadata,
}).catch((err) => {
console.error(err);
throw new AppError('DOCUMENT_SEND_FAILED');
});
}
return document;
} catch (err) {
console.error(err);
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'We were unable to create this document. Please try again later.',
});
throw AppError.parseErrorToTRPCError(err);
}
}),
@@ -104,4 +127,52 @@ export const templateRouter = router({
});
}
}),
getTemplateWithDetailsById: authenticatedProcedure
.input(ZGetTemplateWithDetailsByIdQuerySchema)
.query(async ({ input, ctx }) => {
try {
return await getTemplateWithDetailsById({
id: input.id,
userId: ctx.user.id,
});
} catch (err) {
console.error(err);
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'We were unable to find this template. Please try again later.',
});
}
}),
// Todo: Add API
updateTemplateSettings: authenticatedProcedure
.input(ZUpdateTemplateSettingsMutationSchema)
.mutation(async ({ input, ctx }) => {
try {
const { templateId, teamId, data, meta } = input;
const userId = ctx.user.id;
const requestMetadata = extractNextApiRequestMetadata(ctx.req);
return await updateTemplateSettings({
userId,
teamId,
templateId,
data,
meta,
requestMetadata,
});
} catch (err) {
console.error(err);
throw new TRPCError({
code: 'BAD_REQUEST',
message:
'We were unable to update the settings for this template. Please try again later.',
});
}
}),
});
+41 -5
View File
@@ -1,6 +1,10 @@
import { z } from 'zod';
import { RecipientRole } from '@documenso/prisma/client';
import { URL_REGEX } from '@documenso/lib/constants/url-regex';
import {
ZDocumentAccessAuthTypesSchema,
ZDocumentActionAuthTypesSchema,
} from '@documenso/lib/types/document-auth';
export const ZCreateTemplateMutationSchema = z.object({
title: z.string().min(1).trim(),
@@ -14,12 +18,16 @@ export const ZCreateDocumentFromTemplateMutationSchema = z.object({
recipients: z
.array(
z.object({
id: z.number(),
email: z.string().email(),
name: z.string(),
role: z.nativeEnum(RecipientRole),
name: z.string().optional(),
}),
)
.optional(),
.refine((recipients) => {
const emails = recipients.map((signer) => signer.email);
return new Set(emails).size === emails.length;
}, 'Recipients must have unique emails'),
sendDocument: z.boolean().optional(),
});
export const ZDuplicateTemplateMutationSchema = z.object({
@@ -31,10 +39,38 @@ export const ZDeleteTemplateMutationSchema = z.object({
id: z.number().min(1),
});
export const ZUpdateTemplateSettingsMutationSchema = z.object({
templateId: z.number(),
teamId: z.number().min(1).optional(),
data: z.object({
title: z.string().min(1).optional(),
globalAccessAuth: ZDocumentAccessAuthTypesSchema.nullable().optional(),
globalActionAuth: ZDocumentActionAuthTypesSchema.nullable().optional(),
}),
meta: z.object({
subject: z.string(),
message: z.string(),
timezone: z.string(),
dateFormat: z.string(),
redirectUrl: z
.string()
.optional()
.refine((value) => value === undefined || value === '' || URL_REGEX.test(value), {
message: 'Please enter a valid URL',
}),
}),
});
export const ZGetTemplateWithDetailsByIdQuerySchema = z.object({
id: z.number().min(1),
});
export type TCreateTemplateMutationSchema = z.infer<typeof ZCreateTemplateMutationSchema>;
export type TCreateDocumentFromTemplateMutationSchema = z.infer<
typeof ZCreateDocumentFromTemplateMutationSchema
>;
export type TDuplicateTemplateMutationSchema = z.infer<typeof ZDuplicateTemplateMutationSchema>;
export type TDeleteTemplateMutationSchema = z.infer<typeof ZDeleteTemplateMutationSchema>;
export type TGetTemplateWithDetailsByIdQuerySchema = z.infer<
typeof ZGetTemplateWithDetailsByIdQuerySchema
>;
+1
View File
@@ -77,6 +77,7 @@ declare namespace NodeJS {
DEPLOYMENT_TARGET?: 'webapp' | 'marketing';
FONT_CAVEAT_URI: string;
FONT_NOTO_SANS_URI: string;
POSTGRES_URL?: string;
DATABASE_URL?: string;
@@ -0,0 +1,66 @@
'use client';
import React, { forwardRef } from 'react';
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 { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
export const DocumentGlobalAuthAccessSelect = forwardRef<HTMLButtonElement, SelectProps>(
(props, ref) => (
<Select {...props}>
<SelectTrigger ref={ref} className="bg-background text-muted-foreground">
<SelectValue data-testid="documentAccessSelectValue" placeholder="None" />
</SelectTrigger>
<SelectContent position="popper">
{Object.values(DocumentAccessAuth).map((authType) => (
<SelectItem key={authType} value={authType}>
{DOCUMENT_AUTH_TYPES[authType].value}
</SelectItem>
))}
{/* Note: -1 is remapped in the Zod schema to the required value. */}
<SelectItem value={'-1'}>None</SelectItem>
</SelectContent>
</Select>
),
);
DocumentGlobalAuthAccessSelect.displayName = 'DocumentGlobalAuthAccessSelect';
export const DocumentGlobalAuthAccessTooltip = () => (
<Tooltip>
<TooltipTrigger>
<InfoIcon className="mx-2 h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="text-foreground max-w-md space-y-2 p-4">
<h2>
<strong>Document access</strong>
</h2>
<p>The authentication required for recipients to view the document.</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 to view the document
</li>
<li>
<strong>None</strong> - The document can be accessed directly by the URL sent to the
recipient
</li>
</ul>
</TooltipContent>
</Tooltip>
);
@@ -0,0 +1,80 @@
'use client';
import React, { forwardRef } from 'react';
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 { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
export const DocumentGlobalAuthActionSelect = forwardRef<HTMLButtonElement, SelectProps>(
(props, ref) => (
<Select {...props}>
<SelectTrigger className="bg-background text-muted-foreground">
<SelectValue ref={ref} data-testid="documentActionSelectValue" placeholder="None" />
</SelectTrigger>
<SelectContent position="popper">
{Object.values(DocumentActionAuth)
.filter((auth) => auth !== DocumentAuth.ACCOUNT)
.map((authType) => (
<SelectItem key={authType} value={authType}>
{DOCUMENT_AUTH_TYPES[authType].value}
</SelectItem>
))}
{/* Note: -1 is remapped in the Zod schema to the required value. */}
<SelectItem value={'-1'}>None</SelectItem>
</SelectContent>
</Select>
),
);
DocumentGlobalAuthActionSelect.displayName = 'DocumentGlobalAuthActionSelect';
export const DocumentGlobalAuthActionTooltip = () => (
<Tooltip>
<TooltipTrigger>
<InfoIcon className="mx-2 h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="text-foreground max-w-md space-y-2 p-4">
<h2>
<strong>Global recipient action authentication</strong>
</h2>
<p>The authentication required for recipients to sign the signature field.</p>
<p>
This can be overriden by setting the authentication requirements directly on each recipient
in the next step.
</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>
<strong>Require passkey</strong> - The recipient must have an account and passkey
configured via their settings
</li>
<li>
<strong>Require 2FA</strong> - The recipient must have an account and 2FA enabled via
their settings
</li>
<li>
<strong>None</strong> - No authentication required
</li>
</ul>
</TooltipContent>
</Tooltip>
);
@@ -0,0 +1,34 @@
'use client';
import React from 'react';
export const DocumentSendEmailMessageHelper = () => {
return (
<div>
<p className="text-muted-foreground text-sm">
You can use the following variables in your message:
</p>
<ul className="mt-2 flex list-inside list-disc flex-col gap-y-2 text-sm">
<li className="text-muted-foreground">
<code className="text-muted-foreground bg-muted-foreground/20 rounded p-1 text-sm">
{'{signer.name}'}
</code>{' '}
- The signer's name
</li>
<li className="text-muted-foreground">
<code className="text-muted-foreground bg-muted-foreground/20 rounded p-1 text-sm">
{'{signer.email}'}
</code>{' '}
- The signer's email
</li>
<li className="text-muted-foreground">
<code className="text-muted-foreground bg-muted-foreground/20 rounded p-1 text-sm">
{'{document.name}'}
</code>{' '}
- The document's name
</li>
</ul>
</div>
);
};
+3 -1
View File
@@ -19,6 +19,7 @@ export type FieldContainerPortalProps = {
field: Field;
className?: string;
children: React.ReactNode;
cardClassName?: string;
};
export function FieldContainerPortal({
@@ -44,7 +45,7 @@ export function FieldContainerPortal({
);
}
export function FieldRootContainer({ field, children }: FieldContainerPortalProps) {
export function FieldRootContainer({ field, children, cardClassName }: FieldContainerPortalProps) {
const [isValidating, setIsValidating] = useState(false);
const ref = React.useRef<HTMLDivElement>(null);
@@ -78,6 +79,7 @@ export function FieldRootContainer({ field, children }: FieldContainerPortalProp
{
'border-orange-300 ring-1 ring-orange-300': !field.inserted && isValidating,
},
cardClassName,
)}
ref={ref}
data-inserted={field.inserted ? 'true' : 'false'}
@@ -0,0 +1,80 @@
'use client';
import React from 'react';
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 { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
export type RecipientActionAuthSelectProps = SelectProps;
export const RecipientActionAuthSelect = (props: RecipientActionAuthSelectProps) => {
return (
<Select {...props}>
<SelectTrigger className="bg-background text-muted-foreground">
<SelectValue placeholder="Inherit authentication method" />
<Tooltip>
<TooltipTrigger className="-mr-1 ml-auto">
<InfoIcon className="mx-2 h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="text-foreground max-w-md p-4">
<h2>
<strong>Recipient action authentication</strong>
</h2>
<p>The authentication required for recipients to sign fields</p>
<p className="mt-2">This will override any global settings.</p>
<ul className="ml-3.5 list-outside list-disc space-y-0.5 py-2">
<li>
<strong>Inherit authentication method</strong> - Use the global action signing
authentication method configured in the "General Settings" step
</li>
{/* <li>
<strong>Require account</strong> - The recipient must be
signed in
</li> */}
<li>
<strong>Require passkey</strong> - The recipient must have an account and passkey
configured via their settings
</li>
<li>
<strong>Require 2FA</strong> - The recipient must have an account and 2FA enabled
via their settings
</li>
<li>
<strong>None</strong> - No authentication required
</li>
</ul>
</TooltipContent>
</Tooltip>
</SelectTrigger>
<SelectContent position="popper">
{/* Note: -1 is remapped in the Zod schema to the required value. */}
<SelectItem value="-1">Inherit authentication method</SelectItem>
{Object.values(RecipientActionAuth)
.filter((auth) => auth !== RecipientActionAuth.ACCOUNT)
.map((authType) => (
<SelectItem key={authType} value={authType}>
{DOCUMENT_AUTH_TYPES[authType].value}
</SelectItem>
))}
</SelectContent>
</Select>
);
};
@@ -0,0 +1,97 @@
'use client';
import React, { forwardRef } from 'react';
import type { SelectProps } from '@radix-ui/react-select';
import { InfoIcon } from 'lucide-react';
import { RecipientRole } from '@documenso/prisma/client';
import { ROLE_ICONS } from '@documenso/ui/primitives/recipient-role-icons';
import { Select, SelectContent, SelectItem, SelectTrigger } from '@documenso/ui/primitives/select';
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
export type RecipientRoleSelectProps = SelectProps;
export const RecipientRoleSelect = forwardRef<HTMLButtonElement, SelectProps>((props, ref) => (
<Select {...props}>
<SelectTrigger ref={ref} className="bg-background w-[60px]">
{/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions */}
{ROLE_ICONS[props.value as RecipientRole]}
</SelectTrigger>
<SelectContent align="end">
<SelectItem value={RecipientRole.SIGNER}>
<div className="flex items-center">
<div className="flex w-[150px] items-center">
<span className="mr-2">{ROLE_ICONS[RecipientRole.SIGNER]}</span>
Needs to sign
</div>
<Tooltip>
<TooltipTrigger>
<InfoIcon className="h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="text-foreground z-9999 max-w-md p-4">
<p>The recipient is required to sign the document for it to be completed.</p>
</TooltipContent>
</Tooltip>
</div>
</SelectItem>
<SelectItem value={RecipientRole.APPROVER}>
<div className="flex items-center">
<div className="flex w-[150px] items-center">
<span className="mr-2">{ROLE_ICONS[RecipientRole.APPROVER]}</span>
Needs to approve
</div>
<Tooltip>
<TooltipTrigger>
<InfoIcon className="h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="text-foreground z-9999 max-w-md p-4">
<p>The recipient is required to approve the document for it to be completed.</p>
</TooltipContent>
</Tooltip>
</div>
</SelectItem>
<SelectItem value={RecipientRole.VIEWER}>
<div className="flex items-center">
<div className="flex w-[150px] items-center">
<span className="mr-2">{ROLE_ICONS[RecipientRole.VIEWER]}</span>
Needs to view
</div>
<Tooltip>
<TooltipTrigger>
<InfoIcon className="h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="text-foreground z-9999 max-w-md p-4">
<p>The recipient is required to view the document for it to be completed.</p>
</TooltipContent>
</Tooltip>
</div>
</SelectItem>
<SelectItem value={RecipientRole.CC}>
<div className="flex items-center">
<div className="flex w-[150px] items-center">
<span className="mr-2">{ROLE_ICONS[RecipientRole.CC]}</span>
Receives copy
</div>
<Tooltip>
<TooltipTrigger>
<InfoIcon className="h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="text-foreground z-9999 max-w-md p-4">
<p>
The recipient is not required to take any action and receives a copy of the document
after it is completed.
</p>
</TooltipContent>
</Tooltip>
</div>
</SelectItem>
</SelectContent>
</Select>
));
RecipientRoleSelect.displayName = 'RecipientRoleSelect';
+5 -3
View File
@@ -63,15 +63,17 @@
"lucide-react": "^0.279.0",
"luxon": "^3.4.2",
"next": "14.0.3",
"pdfjs-dist": "3.6.172",
"pdfjs-dist": "3.11.174",
"react": "18.2.0",
"react-colorful": "^5.6.1",
"react-day-picker": "^8.7.1",
"react-dom": "18.2.0",
"react-hook-form": "^7.45.4",
"react-pdf": "7.3.3",
"react-pdf": "7.7.3",
"react-rnd": "^10.4.1",
"tailwind-merge": "^1.12.0",
"tailwindcss-animate": "^1.0.5",
"ts-pattern": "^5.0.5",
"zod": "^3.22.4"
}
}
}
+4 -1
View File
@@ -55,6 +55,8 @@ type AvatarWithTextProps = {
primaryText: React.ReactNode;
secondaryText?: React.ReactNode;
rightSideComponent?: React.ReactNode;
// Optional class to hide/show the text beside avatar
textSectionClassName?: string;
};
const AvatarWithText = ({
@@ -64,6 +66,7 @@ const AvatarWithText = ({
primaryText,
secondaryText,
rightSideComponent,
textSectionClassName,
}: AvatarWithTextProps) => (
<div className={cn('flex w-full max-w-xs items-center gap-2', className)}>
<Avatar
@@ -72,7 +75,7 @@ const AvatarWithText = ({
<AvatarFallback className="text-xs text-gray-400">{avatarFallback}</AvatarFallback>
</Avatar>
<div className="flex flex-col text-left text-sm font-normal">
<div className={cn('flex flex-col text-left text-sm font-normal', textSectionClassName)}>
<span className="text-foreground truncate">{primaryText}</span>
<span className="text-muted-foreground truncate text-xs">{secondaryText}</span>
</div>
+5 -1
View File
@@ -32,7 +32,11 @@ type CommandDialogProps = DialogProps & {
const CommandDialog = ({ children, commandProps, ...props }: CommandDialogProps) => {
return (
<Dialog {...props}>
<DialogContent className="overflow-hidden p-0 shadow-2xl">
<DialogContent
className="w-11/12 items-center overflow-hidden rounded-lg p-0 shadow-2xl lg:mt-0"
position="center"
overlayClassName="bg-background/60"
>
<Command
{...commandProps}
className="[&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group-heading]]:px-0 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-4 [&_[cmdk-item]_svg]:w-4"
+28 -21
View File
@@ -54,28 +54,35 @@ const DialogContent = React.forwardRef<
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & {
position?: 'start' | 'end' | 'center';
hideClose?: boolean;
/* Below prop is to add additional classes to the overlay */
overlayClassName?: string;
}
>(({ className, children, position = 'start', hideClose = false, ...props }, ref) => (
<DialogPortal position={position}>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
'bg-background animate-in data-[state=open]:fade-in-90 sm:zoom-in-90 data-[state=open]:slide-in-from-bottom-10 data-[state=open]:sm:slide-in-from-bottom-0 fixed z-50 grid w-full gap-4 rounded-b-lg border p-6 shadow-lg sm:max-w-lg sm:rounded-lg',
className,
)}
{...props}
>
{children}
{!hideClose && (
<DialogPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute right-4 top-4 rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:pointer-events-none">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
));
>(
(
{ className, children, overlayClassName, position = 'start', hideClose = false, ...props },
ref,
) => (
<DialogPortal position={position}>
<DialogOverlay className={cn(overlayClassName)} />
<DialogPrimitive.Content
ref={ref}
className={cn(
'bg-background animate-in data-[state=open]:fade-in-90 sm:zoom-in-90 data-[state=open]:slide-in-from-bottom-10 data-[state=open]:sm:slide-in-from-bottom-0 fixed z-50 grid w-full gap-4 rounded-b-lg border p-6 shadow-lg sm:max-w-lg sm:rounded-lg',
className,
)}
{...props}
>
{children}
{!hideClose && (
<DialogPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute right-4 top-4 rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:pointer-events-none">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
),
);
DialogContent.displayName = DialogPrimitive.Content.displayName;
@@ -53,6 +53,7 @@ export type AddFieldsFormProps = {
recipients: Recipient[];
fields: Field[];
onSubmit: (_data: TAddFieldsFormSchema) => void;
canGoBack?: boolean;
isDocumentPdfLoaded: boolean;
};
@@ -62,10 +63,13 @@ export const AddFieldsFormPartial = ({
recipients,
fields,
onSubmit,
canGoBack = false,
isDocumentPdfLoaded,
}: AddFieldsFormProps) => {
const { isWithinPageBounds, getFieldPosition, getPage } = useDocumentElement();
const { currentStep, totalSteps, previousStep } = useStep();
const canRenderBackButtonAsRemove =
currentStep === 1 && typeof documentFlow.onBackStep === 'function' && canGoBack;
const {
control,
@@ -595,7 +599,9 @@ export const AddFieldsFormPartial = ({
onGoBackClick={() => {
previousStep();
remove();
documentFlow.onBackStep?.();
}}
goBackLabel={canRenderBackButtonAsRemove ? 'Remove' : undefined}
onGoNextClick={() => void onFormSubmit()}
/>
</DocumentFlowFormContainerFooter>
@@ -7,12 +7,18 @@ import { InfoIcon } from 'lucide-react';
import { useForm } from 'react-hook-form';
import { DATE_FORMATS, DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats';
import { DOCUMENT_AUTH_TYPES } from '@documenso/lib/constants/document-auth';
import { DEFAULT_DOCUMENT_TIME_ZONE, TIME_ZONES } from '@documenso/lib/constants/time-zones';
import { DocumentAccessAuth, DocumentActionAuth } from '@documenso/lib/types/document-auth';
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
import { DocumentStatus, type Field, type Recipient, SendStatus } from '@documenso/prisma/client';
import type { DocumentWithData } from '@documenso/prisma/types/document-with-data';
import {
DocumentGlobalAuthAccessSelect,
DocumentGlobalAuthAccessTooltip,
} from '@documenso/ui/components/document/document-global-auth-access-select';
import {
DocumentGlobalAuthActionSelect,
DocumentGlobalAuthActionTooltip,
} from '@documenso/ui/components/document/document-global-auth-action-select';
import {
Accordion,
AccordionContent,
@@ -140,49 +146,11 @@ export const AddSettingsFormPartial = ({
<FormItem>
<FormLabel className="flex flex-row items-center">
Document access
<Tooltip>
<TooltipTrigger>
<InfoIcon className="mx-2 h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="text-foreground max-w-md space-y-2 p-4">
<h2>
<strong>Document access</strong>
</h2>
<p>The authentication required for recipients to view the document.</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 to
view the document
</li>
<li>
<strong>None</strong> - The document can be accessed directly by the URL
sent to the recipient
</li>
</ul>
</TooltipContent>
</Tooltip>
<DocumentGlobalAuthAccessTooltip />
</FormLabel>
<FormControl>
<Select {...field} onValueChange={field.onChange}>
<SelectTrigger className="bg-background text-muted-foreground">
<SelectValue data-testid="documentAccessSelectValue" placeholder="None" />
</SelectTrigger>
<SelectContent position="popper">
{Object.values(DocumentAccessAuth).map((authType) => (
<SelectItem key={authType} value={authType}>
{DOCUMENT_AUTH_TYPES[authType].value}
</SelectItem>
))}
{/* Note: -1 is remapped in the Zod schema to the required value. */}
<SelectItem value={'-1'}>None</SelectItem>
</SelectContent>
</Select>
<DocumentGlobalAuthAccessSelect {...field} onValueChange={field.onChange} />
</FormControl>
</FormItem>
)}
@@ -196,62 +164,11 @@ export const AddSettingsFormPartial = ({
<FormItem>
<FormLabel className="flex flex-row items-center">
Recipient action authentication
<Tooltip>
<TooltipTrigger>
<InfoIcon className="mx-2 h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="text-foreground max-w-md space-y-2 p-4">
<h2>
<strong>Global recipient action authentication</strong>
</h2>
<p>
The authentication required for recipients to sign the signature field.
</p>
<p>
This can be overriden by setting the authentication requirements
directly on each recipient in the next step.
</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>
<strong>Require passkey</strong> - The recipient must have an account
and passkey configured via their settings
</li>
<li>
<strong>Require 2FA</strong> - The recipient must have an account and
2FA enabled via their settings
</li>
<li>
<strong>None</strong> - No authentication required
</li>
</ul>
</TooltipContent>
</Tooltip>
<DocumentGlobalAuthActionTooltip />
</FormLabel>
<FormControl>
<Select {...field} onValueChange={field.onChange}>
<SelectTrigger className="bg-background text-muted-foreground">
<SelectValue data-testid="documentActionSelectValue" placeholder="None" />
</SelectTrigger>
<SelectContent position="popper">
{Object.values(DocumentActionAuth).map((authType) => (
<SelectItem key={authType} value={authType}>
{DOCUMENT_AUTH_TYPES[authType].value}
</SelectItem>
))}
{/* Note: -1 is remapped in the Zod schema to the required value. */}
<SelectItem value={'-1'}>None</SelectItem>
</SelectContent>
</Select>
<DocumentGlobalAuthActionSelect {...field} onValueChange={field.onChange} />
</FormControl>
</FormItem>
)}
@@ -4,19 +4,18 @@ import React, { useId, useMemo, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { motion } from 'framer-motion';
import { InfoIcon, Plus, Trash } from 'lucide-react';
import { Plus, Trash } from 'lucide-react';
import { useSession } from 'next-auth/react';
import { useFieldArray, useForm } from 'react-hook-form';
import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
import { DOCUMENT_AUTH_TYPES } from '@documenso/lib/constants/document-auth';
import {
RecipientActionAuth,
ZRecipientAuthOptionsSchema,
} from '@documenso/lib/types/document-auth';
import { ZRecipientAuthOptionsSchema } from '@documenso/lib/types/document-auth';
import { nanoid } from '@documenso/lib/universal/id';
import type { Field, Recipient } from '@documenso/prisma/client';
import { RecipientRole, SendStatus } from '@documenso/prisma/client';
import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animate-generic-fade-in-out';
import { RecipientActionAuthSelect } from '@documenso/ui/components/recipient/recipient-action-auth-select';
import { RecipientRoleSelect } from '@documenso/ui/components/recipient/recipient-role-select';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '../button';
@@ -24,10 +23,7 @@ import { Checkbox } from '../checkbox';
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '../form/form';
import { FormErrorMessage } from '../form/form-error-message';
import { Input } from '../input';
import { ROLE_ICONS } from '../recipient-role-icons';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../select';
import { useStep } from '../stepper';
import { Tooltip, TooltipContent, TooltipTrigger } from '../tooltip';
import { useToast } from '../use-toast';
import type { TAddSignersFormSchema } from './add-signers.types';
import { ZAddSignersFormSchema } from './add-signers.types';
@@ -60,6 +56,8 @@ export const AddSignersFormPartial = ({
}: AddSignersFormProps) => {
const { toast } = useToast();
const { remaining } = useLimits();
const { data: session } = useSession();
const user = session?.user;
const initialId = useId();
@@ -107,10 +105,14 @@ export const AddSignersFormPartial = ({
const [showAdvancedSettings, setShowAdvancedSettings] = useState(alwaysShowAdvancedSettings);
const {
setValue,
formState: { errors, isSubmitting },
control,
watch,
} = form;
const watchedSigners = watch('signers');
const onFormSubmit = form.handleSubmit(onSubmit);
const {
@@ -122,6 +124,11 @@ export const AddSignersFormPartial = ({
name: 'signers',
});
const emptySignerIndex = watchedSigners.findIndex((signer) => !signer.name && !signer.email);
const isUserAlreadyARecipient = watchedSigners.some(
(signer) => signer.email.toLowerCase() === user?.email?.toLowerCase(),
);
const hasBeenSentToRecipientId = (id?: number) => {
if (!id) {
return false;
@@ -161,6 +168,21 @@ export const AddSignersFormPartial = ({
removeSigner(index);
};
const onAddSelfSigner = () => {
if (emptySignerIndex !== -1) {
setValue(`signers.${emptySignerIndex}.name`, user?.name ?? '');
setValue(`signers.${emptySignerIndex}.email`, user?.email ?? '');
} else {
appendSigner({
formId: nanoid(12),
name: user?.name ?? '',
email: user?.email ?? '',
role: RecipientRole.SIGNER,
actionAuth: undefined,
});
}
};
const onKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
if (event.key === 'Enter' && event.target instanceof HTMLInputElement) {
onAddSigner();
@@ -209,8 +231,8 @@ export const AddSignersFormPartial = ({
<Input
type="email"
placeholder="Email"
disabled={isSubmitting || hasBeenSentToRecipientId(signer.nativeId)}
{...field}
disabled={isSubmitting || hasBeenSentToRecipientId(signer.nativeId)}
onKeyDown={onKeyDown}
/>
</FormControl>
@@ -230,15 +252,13 @@ export const AddSignersFormPartial = ({
'col-span-4': showAdvancedSettings,
})}
>
{!showAdvancedSettings && index === 0 && (
<FormLabel required>Name</FormLabel>
)}
{!showAdvancedSettings && index === 0 && <FormLabel>Name</FormLabel>}
<FormControl>
<Input
placeholder="Name"
disabled={isSubmitting || hasBeenSentToRecipientId(signer.nativeId)}
{...field}
disabled={isSubmitting || hasBeenSentToRecipientId(signer.nativeId)}
onKeyDown={onKeyDown}
/>
</FormControl>
@@ -255,65 +275,11 @@ export const AddSignersFormPartial = ({
render={({ field }) => (
<FormItem className="col-span-6">
<FormControl>
<Select
<RecipientActionAuthSelect
{...field}
onValueChange={field.onChange}
disabled={isSubmitting || hasBeenSentToRecipientId(signer.nativeId)}
>
<SelectTrigger className="bg-background text-muted-foreground">
<SelectValue placeholder="Inherit authentication method" />
<Tooltip>
<TooltipTrigger className="-mr-1 ml-auto">
<InfoIcon className="mx-2 h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="text-foreground max-w-md p-4">
<h2>
<strong>Recipient action authentication</strong>
</h2>
<p>The authentication required for recipients to sign fields</p>
<p className="mt-2">This will override any global settings.</p>
<ul className="ml-3.5 list-outside list-disc space-y-0.5 py-2">
<li>
<strong>Inherit authentication method</strong> - Use the
global action signing authentication method configured in
the "General Settings" step
</li>
<li>
<strong>Require account</strong> - The recipient must be
signed in
</li>
<li>
<strong>Require passkey</strong> - The recipient must have
an account and passkey configured via their settings
</li>
<li>
<strong>Require 2FA</strong> - The recipient must have an
account and 2FA enabled via their settings
</li>
<li>
<strong>None</strong> - No authentication required
</li>
</ul>
</TooltipContent>
</Tooltip>
</SelectTrigger>
<SelectContent position="popper">
{/* Note: -1 is remapped in the Zod schema to the required value. */}
<SelectItem value="-1">Inherit authentication method</SelectItem>
{Object.values(RecipientActionAuth).map((authType) => (
<SelectItem key={authType} value={authType}>
{DOCUMENT_AUTH_TYPES[authType].value}
</SelectItem>
))}
</SelectContent>
</Select>
/>
</FormControl>
<FormMessage />
@@ -327,46 +293,11 @@ export const AddSignersFormPartial = ({
render={({ field }) => (
<FormItem className="col-span-1 mt-auto">
<FormControl>
<Select
<RecipientRoleSelect
{...field}
onValueChange={field.onChange}
disabled={isSubmitting || hasBeenSentToRecipientId(signer.nativeId)}
>
<SelectTrigger className="bg-background w-[60px]">
{/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions */}
{ROLE_ICONS[field.value as RecipientRole]}
</SelectTrigger>
<SelectContent align="end">
<SelectItem value={RecipientRole.SIGNER}>
<div className="flex items-center">
<span className="mr-2">{ROLE_ICONS[RecipientRole.SIGNER]}</span>
Signer
</div>
</SelectItem>
<SelectItem value={RecipientRole.CC}>
<div className="flex items-center">
<span className="mr-2">{ROLE_ICONS[RecipientRole.CC]}</span>
Receives copy
</div>
</SelectItem>
<SelectItem value={RecipientRole.APPROVER}>
<div className="flex items-center">
<span className="mr-2">{ROLE_ICONS[RecipientRole.APPROVER]}</span>
Approver
</div>
</SelectItem>
<SelectItem value={RecipientRole.VIEWER}>
<div className="flex items-center">
<span className="mr-2">{ROLE_ICONS[RecipientRole.VIEWER]}</span>
Viewer
</div>
</SelectItem>
</SelectContent>
</Select>
/>
</FormControl>
<FormMessage />
@@ -403,6 +334,7 @@ export const AddSignersFormPartial = ({
>
<Button
type="button"
className="flex-1"
disabled={isSubmitting || signers.length >= remaining.recipients}
onClick={() => onAddSigner()}
>
@@ -410,25 +342,36 @@ export const AddSignersFormPartial = ({
Add Signer
</Button>
{!alwaysShowAdvancedSettings && isDocumentEnterprise && (
<div className="flex flex-row items-center">
<Checkbox
id="showAdvancedRecipientSettings"
className="h-5 w-5"
checkClassName="dark:text-white text-primary"
checked={showAdvancedSettings}
onCheckedChange={(value) => setShowAdvancedSettings(Boolean(value))}
/>
<label
className="text-muted-foreground ml-2 text-sm"
htmlFor="showAdvancedRecipientSettings"
>
Show advanced settings
</label>
</div>
)}
<Button
type="button"
variant="secondary"
className="dark:bg-muted dark:hover:bg-muted/80 bg-black/5 hover:bg-black/10"
disabled={isSubmitting || isUserAlreadyARecipient}
onClick={() => onAddSelfSigner()}
>
<Plus className="-ml-1 mr-2 h-5 w-5" />
Add myself
</Button>
</div>
{!alwaysShowAdvancedSettings && isDocumentEnterprise && (
<div className="mt-4 flex flex-row items-center">
<Checkbox
id="showAdvancedRecipientSettings"
className="h-5 w-5"
checkClassName="dark:text-white text-primary"
checked={showAdvancedSettings}
onCheckedChange={(value) => setShowAdvancedSettings(Boolean(value))}
/>
<label
className="text-muted-foreground ml-2 text-sm"
htmlFor="showAdvancedRecipientSettings"
>
Show advanced settings
</label>
</div>
)}
</Form>
</AnimateGenericFadeInOut>
</DocumentFlowFormContainerContent>
@@ -6,6 +6,7 @@ import { useForm } from 'react-hook-form';
import type { Field, Recipient } from '@documenso/prisma/client';
import { DocumentStatus } from '@documenso/prisma/client';
import type { DocumentWithData } from '@documenso/prisma/types/document-with-data';
import { DocumentSendEmailMessageHelper } from '@documenso/ui/components/document/document-send-email-message-helper';
import { FormErrorMessage } from '../form/form-error-message';
import { Input } from '../input';
@@ -104,32 +105,7 @@ export const AddSubjectFormPartial = ({
/>
</div>
<div>
<p className="text-muted-foreground text-sm">
You can use the following variables in your message:
</p>
<ul className="mt-2 flex list-inside list-disc flex-col gap-y-2 text-sm">
<li className="text-muted-foreground">
<code className="text-muted-foreground bg-muted-foreground/20 rounded p-1 text-sm">
{'{signer.name}'}
</code>{' '}
- The signer's name
</li>
<li className="text-muted-foreground">
<code className="text-muted-foreground bg-muted-foreground/20 rounded p-1 text-sm">
{'{signer.email}'}
</code>{' '}
- The signer's email
</li>
<li className="text-muted-foreground">
<code className="text-muted-foreground bg-muted-foreground/20 rounded p-1 text-sm">
{'{document.name}'}
</code>{' '}
- The document's name
</li>
</ul>
</div>
<DocumentSendEmailMessageHelper />
</div>
</div>
</DocumentFlowFormContainerContent>
+76
View File
@@ -0,0 +1,76 @@
'use client';
import * as React from 'react';
import { OTPInput, OTPInputContext } from 'input-otp';
import { Minus } from 'lucide-react';
import { cn } from '../lib/utils';
const PinInput = React.forwardRef<
React.ElementRef<typeof OTPInput>,
React.ComponentPropsWithoutRef<typeof OTPInput>
>(({ className, containerClassName, ...props }, ref) => (
<OTPInput
ref={ref}
containerClassName={cn(
'flex items-center gap-2 has-[:disabled]:opacity-50',
containerClassName,
)}
className={cn('disabled:cursor-not-allowed', className)}
{...props}
/>
));
PinInput.displayName = 'PinInput';
const PinInputGroup = React.forwardRef<
React.ElementRef<'div'>,
React.ComponentPropsWithoutRef<'div'>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn('flex items-center', className)} {...props} />
));
PinInputGroup.displayName = 'PinInputGroup';
const PinInputSlot = React.forwardRef<
React.ElementRef<'div'>,
React.ComponentPropsWithoutRef<'div'> & { index: number }
>(({ index, className, ...props }, ref) => {
const context = React.useContext(OTPInputContext);
const { char, hasFakeCaret, isActive } = context.slots[index];
return (
<div
ref={ref}
className={cn(
'border-input relative flex h-10 w-10 items-center justify-center border-y border-r font-mono shadow-sm transition-all first:rounded-l-md first:border-l last:rounded-r-md',
isActive && 'ring-ring z-10 ring-1',
className,
)}
{...props}
>
{char}
{hasFakeCaret && (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
<div className="animate-caret-blink bg-foreground h-4 w-px duration-1000" />
</div>
)}
</div>
);
});
PinInputSlot.displayName = 'PinInputSlot';
const PinInputSeparator = React.forwardRef<
React.ElementRef<'div'>,
React.ComponentPropsWithoutRef<'div'>
>(({ ...props }, ref) => (
<div ref={ref} role="separator" {...props}>
<Minus className="h-5 w-5" />
</div>
));
PinInputSeparator.displayName = 'PinInputSeparator';
export { PinInput, PinInputGroup, PinInputSlot, PinInputSeparator };
+63 -1
View File
@@ -30,4 +30,66 @@ const PopoverContent = React.forwardRef<
PopoverContent.displayName = PopoverPrimitive.Content.displayName;
export { Popover, PopoverTrigger, PopoverContent };
type PopoverHoverProps = {
trigger: React.ReactNode;
children: React.ReactNode;
contentProps?: React.ComponentPropsWithoutRef<typeof PopoverContent>;
};
const PopoverHover = ({ trigger, children, contentProps }: PopoverHoverProps) => {
const [open, setOpen] = React.useState(false);
const isControlled = React.useRef(false);
const isMouseOver = React.useRef<boolean>(false);
const onMouseEnter = () => {
isMouseOver.current = true;
if (isControlled.current) {
return;
}
setOpen(true);
};
const onMouseLeave = () => {
isMouseOver.current = false;
if (isControlled.current) {
return;
}
setTimeout(() => {
setOpen(isMouseOver.current);
}, 200);
};
const onOpenChange = (newOpen: boolean) => {
isControlled.current = newOpen;
setOpen(newOpen);
};
return (
<Popover open={open} onOpenChange={onOpenChange}>
<PopoverTrigger
className="flex cursor-pointer outline-none"
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
>
{trigger}
</PopoverTrigger>
<PopoverContent
side="top"
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
{...contentProps}
>
{children}
</PopoverContent>
</Popover>
);
};
export { Popover, PopoverTrigger, PopoverContent, PopoverHover };
@@ -1,28 +1,34 @@
'use client';
import React, { useId, useState } from 'react';
import React, { useId, useMemo, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { AnimatePresence, motion } from 'framer-motion';
import { motion } from 'framer-motion';
import { Plus, Trash } from 'lucide-react';
import { Controller, useFieldArray, useForm } from 'react-hook-form';
import { useSession } from 'next-auth/react';
import { useFieldArray, useForm } from 'react-hook-form';
import { ZRecipientAuthOptionsSchema } from '@documenso/lib/types/document-auth';
import { nanoid } from '@documenso/lib/universal/id';
import { type Field, type Recipient, RecipientRole } from '@documenso/prisma/client';
import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animate-generic-fade-in-out';
import { RecipientActionAuthSelect } from '@documenso/ui/components/recipient/recipient-action-auth-select';
import { RecipientRoleSelect } from '@documenso/ui/components/recipient/recipient-role-select';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import { FormErrorMessage } from '@documenso/ui/primitives/form/form-error-message';
import { Input } from '@documenso/ui/primitives/input';
import { Label } from '@documenso/ui/primitives/label';
import { Checkbox } from '../checkbox';
import {
DocumentFlowFormContainerActions,
DocumentFlowFormContainerContent,
DocumentFlowFormContainerFooter,
DocumentFlowFormContainerStep,
} from '../document-flow/document-flow-root';
import { ShowFieldItem } from '../document-flow/show-field-item';
import type { DocumentFlowStep } from '../document-flow/types';
import { ROLE_ICONS } from '../recipient-role-icons';
import { Select, SelectContent, SelectItem, SelectTrigger } from '../select';
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '../form/form';
import { useStep } from '../stepper';
import type { TAddTemplatePlacholderRecipientsFormSchema } from './add-template-placeholder-recipients.types';
import { ZAddTemplatePlacholderRecipientsFormSchema } from './add-template-placeholder-recipients.types';
@@ -31,27 +37,31 @@ export type AddTemplatePlaceholderRecipientsFormProps = {
documentFlow: DocumentFlowStep;
recipients: Recipient[];
fields: Field[];
isEnterprise: boolean;
isDocumentPdfLoaded: boolean;
onSubmit: (_data: TAddTemplatePlacholderRecipientsFormSchema) => void;
};
export const AddTemplatePlaceholderRecipientsFormPartial = ({
documentFlow,
isEnterprise,
recipients,
fields: _fields,
fields,
isDocumentPdfLoaded,
onSubmit,
}: AddTemplatePlaceholderRecipientsFormProps) => {
const initialId = useId();
const { data: session } = useSession();
const user = session?.user;
const [placeholderRecipientCount, setPlaceholderRecipientCount] = useState(() =>
recipients.length > 1 ? recipients.length + 1 : 2,
);
const { currentStep, totalSteps, previousStep } = useStep();
const {
control,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<TAddTemplatePlacholderRecipientsFormSchema>({
const form = useForm<TAddTemplatePlacholderRecipientsFormSchema>({
resolver: zodResolver(ZAddTemplatePlacholderRecipientsFormSchema),
defaultValues: {
signers:
@@ -62,6 +72,8 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
name: recipient.name,
email: recipient.email,
role: recipient.role,
actionAuth:
ZRecipientAuthOptionsSchema.parse(recipient.authOptions)?.actionAuth ?? undefined,
}))
: [
{
@@ -69,12 +81,33 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
name: `Recipient 1`,
email: `recipient.1@documenso.com`,
role: RecipientRole.SIGNER,
actionAuth: undefined,
},
],
},
});
const onFormSubmit = handleSubmit(onSubmit);
// Always show advanced settings if any recipient has auth options.
const alwaysShowAdvancedSettings = useMemo(() => {
const recipientHasAuthOptions = recipients.find((recipient) => {
const recipientAuthOptions = ZRecipientAuthOptionsSchema.parse(recipient.authOptions);
return recipientAuthOptions?.accessAuth || recipientAuthOptions?.actionAuth;
});
const formHasActionAuth = form.getValues('signers').find((signer) => signer.actionAuth);
return recipientHasAuthOptions !== undefined || formHasActionAuth !== undefined;
}, [recipients, form]);
const [showAdvancedSettings, setShowAdvancedSettings] = useState(alwaysShowAdvancedSettings);
const {
formState: { errors, isSubmitting },
control,
} = form;
const onFormSubmit = form.handleSubmit(onSubmit);
const {
append: appendSigner,
@@ -85,10 +118,21 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
name: 'signers',
});
const onAddPlaceholderSelfRecipient = () => {
appendSigner({
formId: nanoid(12),
name: user?.name ?? '',
email: user?.email ?? '',
role: RecipientRole.SIGNER,
});
};
const onAddPlaceholderRecipient = () => {
appendSigner({
formId: nanoid(12),
// Update TEMPLATE_RECIPIENT_NAME_PLACEHOLDER_REGEX if this is ever changed.
name: `Recipient ${placeholderRecipientCount}`,
// Update TEMPLATE_RECIPIENT_EMAIL_PLACEHOLDER_REGEX if this is ever changed.
email: `recipient.${placeholderRecipientCount}@documenso.com`,
role: RecipientRole.SIGNER,
});
@@ -103,112 +147,181 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
return (
<>
<DocumentFlowFormContainerContent>
<div className="flex w-full flex-col gap-y-4">
<AnimatePresence>
{signers.map((signer, index) => (
<motion.div
key={signer.id}
data-native-id={signer.nativeId}
className="flex flex-wrap items-end gap-x-4"
>
<div className="flex-1">
<Label htmlFor={`signer-${signer.id}-email`}>Email</Label>
{isDocumentPdfLoaded &&
fields.map((field, index) => (
<ShowFieldItem key={index} field={field} recipients={recipients} />
))}
<Input
id={`signer-${signer.id}-email`}
type="email"
value={signer.email}
disabled
className="bg-background mt-2"
/>
</div>
<AnimateGenericFadeInOut motionKey={showAdvancedSettings ? 'Show' : 'Hide'}>
<Form {...form}>
<div className="flex w-full flex-col gap-y-2">
{signers.map((signer, index) => (
<motion.fieldset
key={signer.id}
data-native-id={signer.nativeId}
disabled={isSubmitting}
className={cn('grid grid-cols-8 gap-4 pb-4', {
'border-b pt-2': showAdvancedSettings,
})}
>
<FormField
control={form.control}
name={`signers.${index}.email`}
render={({ field }) => (
<FormItem
className={cn('relative', {
'col-span-3': !showAdvancedSettings,
'col-span-4': showAdvancedSettings,
})}
>
{!showAdvancedSettings && index === 0 && (
<FormLabel required>Email</FormLabel>
)}
<div className="flex-1">
<Label htmlFor={`signer-${signer.id}-name`}>Name</Label>
<FormControl>
<Input
type="email"
placeholder="Email"
{...field}
disabled={isSubmitting || signers[index].email === user?.email}
/>
</FormControl>
<Input
id={`signer-${signer.id}-name`}
type="text"
value={signer.name}
disabled
className="bg-background mt-2"
/>
</div>
<div className="w-[60px]">
<Controller
control={control}
name={`signers.${index}.role`}
render={({ field: { value, onChange } }) => (
<Select value={value} onValueChange={(x) => onChange(x)}>
<SelectTrigger className="bg-background">{ROLE_ICONS[value]}</SelectTrigger>
<SelectContent className="" align="end">
<SelectItem value={RecipientRole.SIGNER}>
<div className="flex items-center">
<span className="mr-2">{ROLE_ICONS[RecipientRole.SIGNER]}</span>
Signer
</div>
</SelectItem>
<SelectItem value={RecipientRole.CC}>
<div className="flex items-center">
<span className="mr-2">{ROLE_ICONS[RecipientRole.CC]}</span>
Receives copy
</div>
</SelectItem>
<SelectItem value={RecipientRole.APPROVER}>
<div className="flex items-center">
<span className="mr-2">{ROLE_ICONS[RecipientRole.APPROVER]}</span>
Approver
</div>
</SelectItem>
<SelectItem value={RecipientRole.VIEWER}>
<div className="flex items-center">
<span className="mr-2">{ROLE_ICONS[RecipientRole.VIEWER]}</span>
Viewer
</div>
</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name={`signers.${index}.name`}
render={({ field }) => (
<FormItem
className={cn({
'col-span-3': !showAdvancedSettings,
'col-span-4': showAdvancedSettings,
})}
>
{!showAdvancedSettings && index === 0 && <FormLabel>Name</FormLabel>}
<FormControl>
<Input
placeholder="Name"
{...field}
disabled={isSubmitting || signers[index].email === user?.email}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{showAdvancedSettings && isEnterprise && (
<FormField
control={form.control}
name={`signers.${index}.actionAuth`}
render={({ field }) => (
<FormItem className="col-span-6">
<FormControl>
<RecipientActionAuthSelect
{...field}
onValueChange={field.onChange}
disabled={isSubmitting}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
<FormField
name={`signers.${index}.role`}
render={({ field }) => (
<FormItem className="col-span-1 mt-auto">
<FormControl>
<RecipientRoleSelect
{...field}
onValueChange={field.onChange}
disabled={isSubmitting}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<div>
<button
type="button"
className="inline-flex h-10 w-10 items-center justify-center text-slate-500 hover:opacity-80 disabled:cursor-not-allowed disabled:opacity-50"
className="col-span-1 mt-auto inline-flex h-10 w-10 items-center justify-center text-slate-500 hover:opacity-80 disabled:cursor-not-allowed disabled:opacity-50"
disabled={isSubmitting || signers.length === 1}
onClick={() => onRemoveSigner(index)}
>
<Trash className="h-5 w-5" />
</button>
</div>
</motion.fieldset>
))}
</div>
<div className="w-full">
<FormErrorMessage className="mt-2" error={errors.signers?.[index]?.email} />
<FormErrorMessage className="mt-2" error={errors.signers?.[index]?.name} />
</div>
</motion.div>
))}
</AnimatePresence>
</div>
<FormErrorMessage
className="mt-2"
// Dirty hack to handle errors when .root is populated for an array type
error={'signers__root' in errors && errors['signers__root']}
/>
<FormErrorMessage
className="mt-2"
// Dirty hack to handle errors when .root is populated for an array type
error={'signers__root' in errors && errors['signers__root']}
/>
<div
className={cn('mt-2 flex flex-row items-center space-x-4', {
'mt-4': showAdvancedSettings,
})}
>
<Button
type="button"
className="flex-1"
disabled={isSubmitting}
onClick={() => onAddPlaceholderRecipient()}
>
<Plus className="-ml-1 mr-2 h-5 w-5" />
Add Placeholder Recipient
</Button>
<div className="mt-4">
<Button type="button" disabled={isSubmitting} onClick={() => onAddPlaceholderRecipient()}>
<Plus className="-ml-1 mr-2 h-5 w-5" />
Add Placeholder Recipient
</Button>
</div>
<Button
type="button"
className="dark:bg-muted dark:hover:bg-muted/80 bg-black/5 hover:bg-black/10"
variant="secondary"
disabled={
isSubmitting ||
form.getValues('signers').some((signer) => signer.email === user?.email)
}
onClick={() => onAddPlaceholderSelfRecipient()}
>
<Plus className="-ml-1 mr-2 h-5 w-5" />
Add Myself
</Button>
</div>
{!alwaysShowAdvancedSettings && isEnterprise && (
<div className="mt-4 flex flex-row items-center">
<Checkbox
id="showAdvancedRecipientSettings"
className="h-5 w-5"
checkClassName="dark:text-white text-primary"
checked={showAdvancedSettings}
onCheckedChange={(value) => setShowAdvancedSettings(Boolean(value))}
/>
<label
className="text-muted-foreground ml-2 text-sm"
htmlFor="showAdvancedRecipientSettings"
>
Show advanced settings
</label>
</div>
)}
</Form>
</AnimateGenericFadeInOut>
</DocumentFlowFormContainerContent>
<DocumentFlowFormContainerFooter>
@@ -1,5 +1,8 @@
import { z } from 'zod';
import { ZRecipientActionAuthTypesSchema } from '@documenso/lib/types/document-auth';
import { ZMapNegativeOneToUndefinedSchema } from '../document-flow/add-settings.types';
import { RecipientRole } from '.prisma/client';
export const ZAddTemplatePlacholderRecipientsFormSchema = z
@@ -11,6 +14,9 @@ export const ZAddTemplatePlacholderRecipientsFormSchema = z
email: z.string().min(1).email(),
name: z.string(),
role: z.nativeEnum(RecipientRole),
actionAuth: ZMapNegativeOneToUndefinedSchema.pipe(
ZRecipientActionAuthTypesSchema.optional(),
),
}),
),
})
@@ -0,0 +1,326 @@
'use client';
import { useEffect } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { InfoIcon } from 'lucide-react';
import { useForm } from 'react-hook-form';
import { DATE_FORMATS, DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats';
import { DEFAULT_DOCUMENT_TIME_ZONE, TIME_ZONES } from '@documenso/lib/constants/time-zones';
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
import { type Field, type Recipient } from '@documenso/prisma/client';
import type { TemplateWithData } from '@documenso/prisma/types/template';
import {
DocumentGlobalAuthAccessSelect,
DocumentGlobalAuthAccessTooltip,
} from '@documenso/ui/components/document/document-global-auth-access-select';
import {
DocumentGlobalAuthActionSelect,
DocumentGlobalAuthActionTooltip,
} from '@documenso/ui/components/document/document-global-auth-action-select';
import { DocumentSendEmailMessageHelper } from '@documenso/ui/components/document/document-send-email-message-helper';
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from '@documenso/ui/primitives/accordion';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Combobox } from '../combobox';
import {
DocumentFlowFormContainerActions,
DocumentFlowFormContainerContent,
DocumentFlowFormContainerFooter,
DocumentFlowFormContainerStep,
} from '../document-flow/document-flow-root';
import { ShowFieldItem } from '../document-flow/show-field-item';
import type { DocumentFlowStep } from '../document-flow/types';
import { Input } from '../input';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../select';
import { useStep } from '../stepper';
import { Textarea } from '../textarea';
import { Tooltip, TooltipContent, TooltipTrigger } from '../tooltip';
import type { TAddTemplateSettingsFormSchema } from './add-template-settings.types';
import { ZAddTemplateSettingsFormSchema } from './add-template-settings.types';
export type AddTemplateSettingsFormProps = {
documentFlow: DocumentFlowStep;
recipients: Recipient[];
fields: Field[];
isEnterprise: boolean;
isDocumentPdfLoaded: boolean;
template: TemplateWithData;
onSubmit: (_data: TAddTemplateSettingsFormSchema) => void;
};
export const AddTemplateSettingsFormPartial = ({
documentFlow,
recipients,
fields,
isEnterprise,
isDocumentPdfLoaded,
template,
onSubmit,
}: AddTemplateSettingsFormProps) => {
const { documentAuthOption } = extractDocumentAuthMethods({
documentAuth: template.authOptions,
});
const form = useForm<TAddTemplateSettingsFormSchema>({
resolver: zodResolver(ZAddTemplateSettingsFormSchema),
defaultValues: {
title: template.title,
globalAccessAuth: documentAuthOption?.globalAccessAuth || undefined,
globalActionAuth: documentAuthOption?.globalActionAuth || undefined,
meta: {
subject: template.templateMeta?.subject ?? '',
message: template.templateMeta?.message ?? '',
timezone: template.templateMeta?.timezone ?? DEFAULT_DOCUMENT_TIME_ZONE,
dateFormat: template.templateMeta?.dateFormat ?? DEFAULT_DOCUMENT_DATE_FORMAT,
redirectUrl: template.templateMeta?.redirectUrl ?? '',
},
},
});
const { stepIndex, currentStep, totalSteps, previousStep } = useStep();
// We almost always want to set the timezone to the user's local timezone to avoid confusion
// when the document is signed.
useEffect(() => {
if (!form.formState.touchedFields.meta?.timezone) {
form.setValue('meta.timezone', Intl.DateTimeFormat().resolvedOptions().timeZone);
}
}, [form, form.setValue, form.formState.touchedFields.meta?.timezone]);
return (
<>
<DocumentFlowFormContainerContent>
{isDocumentPdfLoaded &&
fields.map((field, index) => (
<ShowFieldItem key={index} field={field} recipients={recipients} />
))}
<Form {...form}>
<fieldset
className="flex h-full flex-col space-y-6"
disabled={form.formState.isSubmitting}
>
<FormField
control={form.control}
name="title"
render={({ field }) => (
<FormItem>
<FormLabel required>Template title</FormLabel>
<FormControl>
<Input className="bg-background" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="globalAccessAuth"
render={({ field }) => (
<FormItem>
<FormLabel className="flex flex-row items-center">
Document access
<DocumentGlobalAuthAccessTooltip />
</FormLabel>
<FormControl>
<DocumentGlobalAuthAccessSelect {...field} onValueChange={field.onChange} />
</FormControl>
</FormItem>
)}
/>
{isEnterprise && (
<FormField
control={form.control}
name="globalActionAuth"
render={({ field }) => (
<FormItem>
<FormLabel className="flex flex-row items-center">
Recipient action authentication
<DocumentGlobalAuthActionTooltip />
</FormLabel>
<FormControl>
<DocumentGlobalAuthActionSelect {...field} onValueChange={field.onChange} />
</FormControl>
</FormItem>
)}
/>
)}
<Accordion type="multiple">
<AccordionItem value="email-options" className="border-none">
<AccordionTrigger className="text-foreground rounded border px-3 py-2 text-left hover:bg-neutral-200/30 hover:no-underline">
Email Options
</AccordionTrigger>
<AccordionContent className="text-muted-foreground -mx-1 px-1 pt-4 text-sm leading-relaxed [&>div]:pb-0">
<div className="flex flex-col space-y-6">
<FormField
control={form.control}
name="meta.subject"
render={({ field }) => (
<FormItem>
<FormLabel>
Subject <span className="text-muted-foreground">(Optional)</span>
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="meta.message"
render={({ field }) => (
<FormItem>
<FormLabel>
Message <span className="text-muted-foreground">(Optional)</span>
</FormLabel>
<FormControl>
<Textarea className="bg-background h-32 resize-none" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<DocumentSendEmailMessageHelper />
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
<Accordion type="multiple">
<AccordionItem value="advanced-options" className="border-none">
<AccordionTrigger className="text-foreground rounded border px-3 py-2 text-left hover:bg-neutral-200/30 hover:no-underline">
Advanced Options
</AccordionTrigger>
<AccordionContent className="text-muted-foreground -mx-1 px-1 pt-4 text-sm leading-relaxed">
<div className="flex flex-col space-y-6">
<FormField
control={form.control}
name="meta.dateFormat"
render={({ field }) => (
<FormItem>
<FormLabel>Date Format</FormLabel>
<FormControl>
<Select {...field} onValueChange={field.onChange}>
<SelectTrigger className="bg-background">
<SelectValue />
</SelectTrigger>
<SelectContent>
{DATE_FORMATS.map((format) => (
<SelectItem key={format.key} value={format.value}>
{format.label}
</SelectItem>
))}
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="meta.timezone"
render={({ field }) => (
<FormItem>
<FormLabel>Time Zone</FormLabel>
<FormControl>
<Combobox
className="bg-background time-zone-field"
options={TIME_ZONES}
{...field}
onChange={(value) => value && field.onChange(value)}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="meta.redirectUrl"
render={({ field }) => (
<FormItem>
<FormLabel className="flex flex-row items-center">
Redirect URL{' '}
<Tooltip>
<TooltipTrigger>
<InfoIcon className="mx-2 h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="text-muted-foreground max-w-xs">
Add a URL to redirect the user to once the document is signed
</TooltipContent>
</Tooltip>
</FormLabel>
<FormControl>
<Input className="bg-background" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
</fieldset>
</Form>
</DocumentFlowFormContainerContent>
<DocumentFlowFormContainerFooter>
<DocumentFlowFormContainerStep
title={documentFlow.title}
step={currentStep}
maxStep={totalSteps}
/>
<DocumentFlowFormContainerActions
loading={form.formState.isSubmitting}
disabled={form.formState.isSubmitting}
canGoBack={stepIndex !== 0}
onGoBackClick={previousStep}
onGoNextClick={form.handleSubmit(onSubmit)}
/>
</DocumentFlowFormContainerFooter>
</>
);
};
@@ -0,0 +1,35 @@
import { z } from 'zod';
import { DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats';
import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones';
import { URL_REGEX } from '@documenso/lib/constants/url-regex';
import {
ZDocumentAccessAuthTypesSchema,
ZDocumentActionAuthTypesSchema,
} from '@documenso/lib/types/document-auth';
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" }),
globalAccessAuth: ZMapNegativeOneToUndefinedSchema.pipe(
ZDocumentAccessAuthTypesSchema.optional(),
),
globalActionAuth: ZMapNegativeOneToUndefinedSchema.pipe(
ZDocumentActionAuthTypesSchema.optional(),
),
meta: z.object({
subject: z.string(),
message: z.string(),
timezone: z.string().optional().default(DEFAULT_DOCUMENT_TIME_ZONE),
dateFormat: z.string().optional().default(DEFAULT_DOCUMENT_DATE_FORMAT),
redirectUrl: z
.string()
.optional()
.refine((value) => value === undefined || value === '' || URL_REGEX.test(value), {
message: 'Please enter a valid URL',
}),
}),
});
export type TAddTemplateSettingsFormSchema = z.infer<typeof ZAddTemplateSettingsFormSchema>;