chore: merge main

This commit is contained in:
Catalin Pit
2025-09-11 14:58:42 +03:00
343 changed files with 14952 additions and 3564 deletions

View File

@ -34,6 +34,7 @@ import { createTemplate } from '@documenso/lib/server-only/template/create-templ
import { deleteTemplate } from '@documenso/lib/server-only/template/delete-template';
import { findTemplates } from '@documenso/lib/server-only/template/find-templates';
import { getTemplateById } from '@documenso/lib/server-only/template/get-template-by-id';
import { ZRecipientAuthOptionsSchema } from '@documenso/lib/types/document-auth';
import { extractDerivedDocumentEmailSettings } from '@documenso/lib/types/document-email';
import {
ZCheckboxFieldMeta,
@ -330,6 +331,7 @@ export const ApiContractV1Implementation = tsr.router(ApiContractV1, {
userId: user.id,
teamId: team?.id,
formValues: body.formValues,
folderId: body.folderId,
documentDataId: documentData.id,
requestMetadata: metadata,
});
@ -736,6 +738,7 @@ export const ApiContractV1Implementation = tsr.router(ApiContractV1, {
teamId: team?.id,
recipients: body.recipients,
prefillFields: body.prefillFields,
folderId: body.folderId,
override: {
title: body.title,
...body.meta,
@ -978,10 +981,12 @@ export const ApiContractV1Implementation = tsr.router(ApiContractV1, {
userId: user.id,
teamId: team?.id,
recipients: [
...recipients.map(({ email, name }) => ({
email,
name,
role,
...recipients.map((recipient) => ({
email: recipient.email,
name: recipient.name,
role: recipient.role,
signingOrder: recipient.signingOrder,
actionAuth: ZRecipientAuthOptionsSchema.parse(recipient.authOptions)?.actionAuth ?? [],
})),
{
email,

View File

@ -33,7 +33,7 @@ export const ZNoBodyMutationSchema = null;
*/
export const ZGetDocumentsQuerySchema = z.object({
page: z.coerce.number().min(1).optional().default(1),
perPage: z.coerce.number().min(1).optional().default(1),
perPage: z.coerce.number().min(1).optional().default(10),
});
export type TGetDocumentsQuerySchema = z.infer<typeof ZGetDocumentsQuerySchema>;
@ -136,6 +136,12 @@ export type TUploadDocumentSuccessfulSchema = z.infer<typeof ZUploadDocumentSucc
export const ZCreateDocumentMutationSchema = z.object({
title: z.string().min(1),
externalId: z.string().nullish(),
folderId: z
.string()
.describe(
'The ID of the folder to create the document in. If not provided, the document will be created in the root folder.',
)
.optional(),
recipients: z.array(
z.object({
name: z.string().min(1),
@ -287,6 +293,12 @@ export type TCreateDocumentFromTemplateMutationResponseSchema = z.infer<
export const ZGenerateDocumentFromTemplateMutationSchema = z.object({
title: z.string().optional(),
externalId: z.string().optional(),
folderId: z
.string()
.describe(
'The ID of the folder to create the document in. If not provided, the document will be created in the root folder.',
)
.optional(),
recipients: z
.array(
z.object({
@ -625,5 +637,5 @@ export const ZSuccessfulGetTemplatesResponseSchema = z.object({
export const ZGetTemplatesQuerySchema = z.object({
page: z.coerce.number().min(1).optional().default(1),
perPage: z.coerce.number().min(1).optional().default(1),
perPage: z.coerce.number().min(1).optional().default(10),
});

View File

@ -1178,13 +1178,12 @@ test.describe('Unauthorized Access - Document API V2', () => {
const { user: firstRecipientUser } = await seedUser();
const { user: secondRecipientUser } = await seedUser();
await prisma.template.update({
const updatedTemplate = await prisma.template.update({
where: { id: template.id },
data: {
recipients: {
create: [
{
id: firstRecipientUser.id,
name: firstRecipientUser.name || '',
email: firstRecipientUser.email,
token: nanoid(12),
@ -1193,7 +1192,6 @@ test.describe('Unauthorized Access - Document API V2', () => {
signingStatus: SigningStatus.NOT_SIGNED,
},
{
id: secondRecipientUser.id,
name: secondRecipientUser.name || '',
email: secondRecipientUser.email,
token: nanoid(12),
@ -1204,21 +1202,35 @@ test.describe('Unauthorized Access - Document API V2', () => {
],
},
},
include: {
recipients: true,
},
});
const recipientAId = updatedTemplate.recipients.find(
(recipient) => recipient.email === firstRecipientUser.email,
)?.id;
const recipientBId = updatedTemplate.recipients.find(
(recipient) => recipient.email === secondRecipientUser.email,
)?.id;
if (!recipientAId || !recipientBId) {
throw new Error('Recipient IDs not found');
}
const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/template/use`, {
headers: { Authorization: `Bearer ${tokenB}` },
data: {
templateId: template.id,
recipients: [
{
id: firstRecipientUser.id,
id: recipientAId,
name: firstRecipientUser.name,
email: firstRecipientUser.email,
role: RecipientRole.SIGNER,
},
{
id: secondRecipientUser.id,
id: recipientBId,
name: secondRecipientUser.name,
email: secondRecipientUser.email,
role: RecipientRole.SIGNER,

View File

@ -0,0 +1,293 @@
import type { Page } from '@playwright/test';
import { expect, test } from '@playwright/test';
import { getFieldsForDocument } from '@documenso/lib/server-only/field/get-fields-for-document';
import { seedBlankDocument } from '@documenso/prisma/seed/documents';
import { seedUser } from '@documenso/prisma/seed/users';
import { apiSignin } from '../fixtures/authentication';
const setupDocumentAndNavigateToFieldsStep = async (page: Page) => {
const { user, team } = await seedUser();
const document = await seedBlankDocument(user, team.id);
await apiSignin({
page,
email: user.email,
redirectPath: `/documents/${document.id}/edit`,
});
await page.getByRole('button', { name: 'Continue' }).click();
await page.getByPlaceholder('Email').fill('recipient1@documenso.com');
await page.getByPlaceholder('Name').fill('Recipient 1');
await page.getByRole('button', { name: 'Add signer' }).click();
await page.getByPlaceholder('Email').nth(1).fill('recipient2@documenso.com');
await page.getByPlaceholder('Name').nth(1).fill('Recipient 2');
await page.getByRole('button', { name: 'Continue' }).click();
return { user, team, document };
};
const triggerAutosave = async (page: Page) => {
await page.locator('#document-flow-form-container').click();
await page.locator('#document-flow-form-container').blur();
await page.waitForTimeout(5000);
};
test.describe('AutoSave Fields Step', () => {
test('should autosave the fields without advanced settings', async ({ page }) => {
const { user, document, team } = await setupDocumentAndNavigateToFieldsStep(page);
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({
position: {
x: 100,
y: 100,
},
});
await page.getByRole('button', { name: 'Text' }).click();
await page.locator('canvas').click({
position: {
x: 100,
y: 200,
},
});
await page.getByTestId('field-advanced-settings-footer').waitFor({ state: 'visible' });
await page
.getByTestId('field-advanced-settings-footer')
.getByRole('button', { name: 'Cancel' })
.click();
await triggerAutosave(page);
await page.getByRole('combobox').click();
await page.getByRole('option', { name: 'Recipient 2 (recipient2@documenso.com)' }).click();
await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({
position: {
x: 100,
y: 500,
},
});
await triggerAutosave(page);
await expect(async () => {
const retrievedFields = await getFieldsForDocument({
documentId: document.id,
userId: user.id,
teamId: team.id,
});
expect(retrievedFields.length).toBe(3);
expect(retrievedFields[0].type).toBe('SIGNATURE');
expect(retrievedFields[1].type).toBe('TEXT');
expect(retrievedFields[2].type).toBe('SIGNATURE');
}).toPass();
});
test('should autosave the field deletion', async ({ page }) => {
const { user, document, team } = await setupDocumentAndNavigateToFieldsStep(page);
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({
position: {
x: 100,
y: 100,
},
});
await page.getByRole('button', { name: 'Text' }).click();
await page.locator('canvas').click({
position: {
x: 100,
y: 200,
},
});
await page.getByTestId('field-advanced-settings-footer').waitFor({ state: 'visible' });
await page
.getByTestId('field-advanced-settings-footer')
.getByRole('button', { name: 'Cancel' })
.click();
await triggerAutosave(page);
await page.getByRole('combobox').click();
await page.getByRole('option', { name: 'Recipient 2 (recipient2@documenso.com)' }).click();
await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({
position: {
x: 100,
y: 500,
},
});
await triggerAutosave(page);
await page.getByRole('combobox').click();
await page.getByRole('option', { name: 'Recipient 1 (recipient1@documenso.com)' }).click();
await page.getByText('Text').nth(1).click();
await page.getByRole('button', { name: 'Remove' }).click();
await triggerAutosave(page);
await expect(async () => {
const retrievedFields = await getFieldsForDocument({
documentId: document.id,
userId: user.id,
teamId: team.id,
});
expect(retrievedFields.length).toBe(2);
expect(retrievedFields[0].type).toBe('SIGNATURE');
expect(retrievedFields[1].type).toBe('SIGNATURE');
}).toPass();
});
test('should autosave the field duplication', async ({ page }) => {
const { user, document, team } = await setupDocumentAndNavigateToFieldsStep(page);
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({
position: {
x: 100,
y: 100,
},
});
await page.getByRole('button', { name: 'Text' }).click();
await page.locator('canvas').click({
position: {
x: 100,
y: 200,
},
});
await page.getByTestId('field-advanced-settings-footer').waitFor({ state: 'visible' });
await page
.getByTestId('field-advanced-settings-footer')
.getByRole('button', { name: 'Cancel' })
.click();
await triggerAutosave(page);
await page.getByRole('combobox').click();
await page.getByRole('option', { name: 'Recipient 2 (recipient2@documenso.com)' }).click();
await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({
position: {
x: 100,
y: 500,
},
});
await triggerAutosave(page);
await page.getByRole('combobox').click();
await page.getByRole('option', { name: 'Recipient 1 (recipient1@documenso.com)' }).click();
await page.getByText('Signature').nth(1).click();
await page.getByRole('button', { name: 'Duplicate', exact: true }).click();
await triggerAutosave(page);
await expect(async () => {
const retrievedFields = await getFieldsForDocument({
documentId: document.id,
userId: user.id,
teamId: team.id,
});
expect(retrievedFields.length).toBe(4);
expect(retrievedFields[0].type).toBe('SIGNATURE');
expect(retrievedFields[1].type).toBe('TEXT');
expect(retrievedFields[2].type).toBe('SIGNATURE');
expect(retrievedFields[3].type).toBe('SIGNATURE');
}).toPass();
});
test('should autosave the fields with advanced settings', async ({ page }) => {
const { user, document, team } = await setupDocumentAndNavigateToFieldsStep(page);
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({
position: {
x: 100,
y: 100,
},
});
await page.getByRole('button', { name: 'Text' }).click();
await page.locator('canvas').click({
position: {
x: 100,
y: 200,
},
});
await page.getByRole('textbox', { name: 'Field label' }).fill('Test Field');
await page.getByRole('textbox', { name: 'Field placeholder' }).fill('Test Placeholder');
await page.getByRole('textbox', { name: 'Add text to the field' }).fill('Test Text');
await page
.getByTestId('field-advanced-settings-footer')
.getByRole('button', { name: 'Save' })
.click();
await triggerAutosave(page);
await expect(async () => {
const retrievedFields = await getFieldsForDocument({
documentId: document.id,
userId: user.id,
teamId: team.id,
});
expect(retrievedFields.length).toBe(2);
expect(retrievedFields[0].type).toBe('SIGNATURE');
expect(retrievedFields[1].type).toBe('TEXT');
const textField = retrievedFields[1];
expect(textField.fieldMeta).toBeDefined();
if (
textField.fieldMeta &&
typeof textField.fieldMeta === 'object' &&
'type' in textField.fieldMeta
) {
expect(textField.fieldMeta.type).toBe('text');
expect(textField.fieldMeta.label).toBe('Test Field');
expect(textField.fieldMeta.placeholder).toBe('Test Placeholder');
if (textField.fieldMeta.type === 'text') {
expect(textField.fieldMeta.text).toBe('Test Text');
}
} else {
throw new Error('fieldMeta should be defined and contain advanced settings');
}
}).toPass();
});
});

View File

@ -0,0 +1,243 @@
import type { Page } from '@playwright/test';
import { expect, test } from '@playwright/test';
import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id';
import { seedBlankDocument } from '@documenso/prisma/seed/documents';
import { seedUser } from '@documenso/prisma/seed/users';
import { apiSignin } from '../fixtures/authentication';
test.describe.configure({ mode: 'parallel', timeout: 60000 });
const setupDocument = async (page: Page) => {
const { user, team } = await seedUser();
const document = await seedBlankDocument(user, team.id);
await apiSignin({
page,
email: user.email,
redirectPath: `/documents/${document.id}/edit`,
});
return { user, team, document };
};
const triggerAutosave = async (page: Page) => {
await page.locator('#document-flow-form-container').click();
await page.locator('#document-flow-form-container').blur();
await page.waitForTimeout(5000);
};
test.describe('AutoSave Settings Step', () => {
test('should autosave the title change', async ({ page }) => {
const { user, document, team } = await setupDocument(page);
const newDocumentTitle = 'New Document Title';
await page.getByRole('textbox', { name: 'Title *' }).fill(newDocumentTitle);
await triggerAutosave(page);
await expect(async () => {
const retrieved = await getDocumentById({
documentId: document.id,
userId: user.id,
teamId: team.id,
});
await expect(page.getByRole('textbox', { name: 'Title *' })).toHaveValue(retrieved.title);
}).toPass();
});
test('should autosave the language change', async ({ page }) => {
const { user, document, team } = await setupDocument(page);
const newDocumentLanguage = 'French';
const expectedLanguageCode = 'fr';
await page.getByRole('combobox').first().click();
await page.getByRole('option', { name: newDocumentLanguage }).click();
await triggerAutosave(page);
await expect(async () => {
const retrieved = await getDocumentById({
documentId: document.id,
userId: user.id,
teamId: team.id,
});
expect(retrieved.documentMeta?.language).toBe(expectedLanguageCode);
}).toPass();
});
test('should autosave the document access change', async ({ page }) => {
const { user, document, team } = await setupDocument(page);
const access = 'Require account';
const accessValue = 'ACCOUNT';
await page.getByRole('combobox').nth(1).click();
await page.getByRole('option', { name: access }).click();
await triggerAutosave(page);
await expect(async () => {
const retrieved = await getDocumentById({
documentId: document.id,
userId: user.id,
teamId: team.id,
});
expect(retrieved.authOptions?.globalAccessAuth).toContain(accessValue);
}).toPass();
});
test('should autosave the external ID change', async ({ page }) => {
const { user, document, team } = await setupDocument(page);
const newExternalId = '1234567890';
await page.getByRole('button', { name: 'Advanced Options' }).click();
await page.getByRole('textbox', { name: 'External ID' }).fill(newExternalId);
await triggerAutosave(page);
await expect(async () => {
const retrieved = await getDocumentById({
documentId: document.id,
userId: user.id,
teamId: team.id,
});
expect(retrieved.externalId).toBe(newExternalId);
}).toPass();
});
test('should autosave the allowed signature types change', async ({ page }) => {
const { user, document, team } = await setupDocument(page);
await page.getByRole('button', { name: 'Advanced Options' }).click();
await page.getByRole('combobox').nth(3).click();
await page.getByRole('option', { name: 'Draw' }).click();
await page.getByRole('option', { name: 'Type' }).click();
await triggerAutosave(page);
await expect(async () => {
const retrieved = await getDocumentById({
documentId: document.id,
userId: user.id,
teamId: team.id,
});
expect(retrieved.documentMeta?.drawSignatureEnabled).toBe(false);
expect(retrieved.documentMeta?.typedSignatureEnabled).toBe(false);
expect(retrieved.documentMeta?.uploadSignatureEnabled).toBe(true);
}).toPass();
});
test('should autosave the date format change', async ({ page }) => {
const { user, document, team } = await setupDocument(page);
await page.getByRole('button', { name: 'Advanced Options' }).click();
await page.getByRole('combobox').nth(4).click();
await page.getByRole('option', { name: 'ISO 8601', exact: true }).click();
await triggerAutosave(page);
await expect(async () => {
const retrieved = await getDocumentById({
documentId: document.id,
userId: user.id,
teamId: team.id,
});
expect(retrieved.documentMeta?.dateFormat).toBe("yyyy-MM-dd'T'HH:mm:ss.SSSXXX");
}).toPass();
});
test('should autosave the timezone change', async ({ page }) => {
const { user, document, team } = await setupDocument(page);
await page.getByRole('button', { name: 'Advanced Options' }).click();
await page.getByRole('combobox').nth(5).click();
await page.getByRole('option', { name: 'Europe/London' }).click();
await triggerAutosave(page);
await expect(async () => {
const retrieved = await getDocumentById({
documentId: document.id,
userId: user.id,
teamId: team.id,
});
expect(retrieved.documentMeta?.timezone).toBe('Europe/London');
}).toPass();
});
test('should autosave the redirect URL change', async ({ page }) => {
const { user, document, team } = await setupDocument(page);
const newRedirectUrl = 'https://documenso.com/test/';
await page.getByRole('button', { name: 'Advanced Options' }).click();
await page.getByRole('textbox', { name: 'Redirect URL' }).fill(newRedirectUrl);
await triggerAutosave(page);
await expect(async () => {
const retrieved = await getDocumentById({
documentId: document.id,
userId: user.id,
teamId: team.id,
});
expect(retrieved.documentMeta?.redirectUrl).toBe(newRedirectUrl);
}).toPass();
});
test('should autosave multiple field changes together', async ({ page }) => {
const { user, document, team } = await setupDocument(page);
const newTitle = 'Updated Document Title';
await page.getByRole('textbox', { name: 'Title *' }).fill(newTitle);
await page.getByRole('combobox').first().click();
await page.getByRole('option', { name: 'German' }).click();
await page.getByRole('combobox').nth(1).click();
await page.getByRole('option', { name: 'Require account' }).click();
await page.getByRole('button', { name: 'Advanced Options' }).click();
const newExternalId = 'MULTI-TEST-123';
await page.getByRole('textbox', { name: 'External ID' }).fill(newExternalId);
await page.getByRole('combobox').nth(5).click();
await page.getByRole('option', { name: 'Europe/Berlin' }).click();
await triggerAutosave(page);
await expect(async () => {
const retrieved = await getDocumentById({
documentId: document.id,
userId: user.id,
teamId: team.id,
});
expect(retrieved.title).toBe(newTitle);
expect(retrieved.documentMeta?.language).toBe('de');
expect(retrieved.authOptions?.globalAccessAuth).toContain('ACCOUNT');
expect(retrieved.externalId).toBe(newExternalId);
expect(retrieved.documentMeta?.timezone).toBe('Europe/Berlin');
}).toPass();
});
});

View File

@ -0,0 +1,168 @@
import type { Page } from '@playwright/test';
import { expect, test } from '@playwright/test';
import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id';
import { getRecipientsForDocument } from '@documenso/lib/server-only/recipient/get-recipients-for-document';
import { seedBlankDocument } from '@documenso/prisma/seed/documents';
import { seedUser } from '@documenso/prisma/seed/users';
import { apiSignin } from '../fixtures/authentication';
test.describe.configure({ mode: 'parallel', timeout: 60000 });
const setupDocumentAndNavigateToSignersStep = async (page: Page) => {
const { user, team } = await seedUser();
const document = await seedBlankDocument(user, team.id);
await apiSignin({
page,
email: user.email,
redirectPath: `/documents/${document.id}/edit`,
});
await page.getByRole('button', { name: 'Continue' }).click();
return { user, team, document };
};
const triggerAutosave = async (page: Page) => {
await page.locator('#document-flow-form-container').click();
await page.locator('#document-flow-form-container').blur();
await page.waitForTimeout(5000);
};
const addSignerAndSave = async (page: Page) => {
await page.getByPlaceholder('Email').fill('recipient1@documenso.com');
await page.getByPlaceholder('Name').fill('Recipient 1');
await triggerAutosave(page);
};
test.describe('AutoSave Signers Step', () => {
test('should autosave the signers addition', async ({ page }) => {
const { user, document, team } = await setupDocumentAndNavigateToSignersStep(page);
await addSignerAndSave(page);
await expect(async () => {
const retrievedRecipients = await getRecipientsForDocument({
documentId: document.id,
userId: user.id,
teamId: team.id,
});
expect(retrievedRecipients.length).toBe(1);
expect(retrievedRecipients[0].email).toBe('recipient1@documenso.com');
expect(retrievedRecipients[0].name).toBe('Recipient 1');
}).toPass();
});
test('should autosave the signer deletion', async ({ page }) => {
const { user, document, team } = await setupDocumentAndNavigateToSignersStep(page);
await addSignerAndSave(page);
await page.getByRole('button', { name: 'Add myself' }).click();
await triggerAutosave(page);
await page.getByTestId('remove-signer-button').first().click();
await triggerAutosave(page);
await expect(async () => {
const retrievedRecipients = await getRecipientsForDocument({
documentId: document.id,
userId: user.id,
teamId: team.id,
});
expect(retrievedRecipients.length).toBe(1);
expect(retrievedRecipients[0].email).toBe(user.email);
expect(retrievedRecipients[0].name).toBe(user.name);
}).toPass();
});
test('should autosave the signer update', async ({ page }) => {
const { user, document, team } = await setupDocumentAndNavigateToSignersStep(page);
await addSignerAndSave(page);
await page.getByPlaceholder('Name').fill('Documenso Manager');
await page.getByPlaceholder('Email').fill('manager@documenso.com');
await triggerAutosave(page);
await page.getByRole('combobox').click();
await page.getByRole('option', { name: 'Receives copy' }).click();
await triggerAutosave(page);
await expect(async () => {
const retrievedRecipients = await getRecipientsForDocument({
documentId: document.id,
userId: user.id,
teamId: team.id,
});
expect(retrievedRecipients.length).toBe(1);
expect(retrievedRecipients[0].email).toBe('manager@documenso.com');
expect(retrievedRecipients[0].name).toBe('Documenso Manager');
expect(retrievedRecipients[0].role).toBe('CC');
}).toPass();
});
test('should autosave the signing order change', async ({ page }) => {
const { user, document, team } = await setupDocumentAndNavigateToSignersStep(page);
await addSignerAndSave(page);
await page.getByRole('button', { name: 'Add signer' }).click();
await page.getByTestId('signer-email-input').nth(1).fill('recipient2@documenso.com');
await page.getByLabel('Name').nth(1).fill('Recipient 2');
await page.getByRole('button', { name: 'Add Signer' }).click();
await page.getByTestId('signer-email-input').nth(2).fill('recipient3@documenso.com');
await page.getByLabel('Name').nth(2).fill('Recipient 3');
await triggerAutosave(page);
await page.getByLabel('Enable signing order').check();
await page.getByLabel('Allow signers to dictate next signer').check();
await triggerAutosave(page);
await page.getByTestId('signing-order-input').nth(0).fill('3');
await page.getByTestId('signing-order-input').nth(0).blur();
await triggerAutosave(page);
await page.getByTestId('signing-order-input').nth(1).fill('1');
await page.getByTestId('signing-order-input').nth(1).blur();
await triggerAutosave(page);
await page.getByTestId('signing-order-input').nth(2).fill('2');
await page.getByTestId('signing-order-input').nth(2).blur();
await triggerAutosave(page);
await expect(async () => {
const retrievedDocumentData = await getDocumentById({
documentId: document.id,
userId: user.id,
teamId: team.id,
});
const retrievedRecipients = await getRecipientsForDocument({
documentId: document.id,
userId: user.id,
teamId: team.id,
});
expect(retrievedDocumentData.documentMeta?.signingOrder).toBe('SEQUENTIAL');
expect(retrievedDocumentData.documentMeta?.allowDictateNextSigner).toBe(true);
expect(retrievedRecipients.length).toBe(3);
expect(retrievedRecipients[0].signingOrder).toBe(2);
expect(retrievedRecipients[1].signingOrder).toBe(3);
expect(retrievedRecipients[2].signingOrder).toBe(1);
}).toPass();
});
});

View File

@ -0,0 +1,200 @@
import type { Page } from '@playwright/test';
import { expect, test } from '@playwright/test';
import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id';
import { seedBlankDocument } from '@documenso/prisma/seed/documents';
import { seedUser } from '@documenso/prisma/seed/users';
import { apiSignin } from '../fixtures/authentication';
test.describe.configure({ mode: 'parallel', timeout: 60000 });
export const setupDocumentAndNavigateToSubjectStep = async (page: Page) => {
const { user, team } = await seedUser();
const document = await seedBlankDocument(user, team.id);
await apiSignin({
page,
email: user.email,
redirectPath: `/documents/${document.id}/edit`,
});
await page.getByRole('button', { name: 'Continue' }).click();
await page.getByPlaceholder('Email').fill('recipient1@documenso.com');
await page.getByPlaceholder('Name').fill('Recipient 1');
await page.getByRole('button', { name: 'Continue' }).click();
await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({
position: {
x: 100,
y: 100,
},
});
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('heading', { name: 'Distribute Document' })).toBeVisible();
return { user, team, document };
};
export const triggerAutosave = async (page: Page) => {
await page.locator('#document-flow-form-container').click();
await page.locator('#document-flow-form-container').blur();
await page.waitForTimeout(5000);
};
test.describe('AutoSave Subject Step', () => {
test('should autosave the subject field', async ({ page }) => {
const { user, document, team } = await setupDocumentAndNavigateToSubjectStep(page);
const subject = 'Hello world!';
await page.getByRole('textbox', { name: 'Subject (Optional)' }).fill(subject);
await triggerAutosave(page);
await expect(async () => {
const retrievedDocumentData = await getDocumentById({
documentId: document.id,
userId: user.id,
teamId: team.id,
});
await expect(page.getByRole('textbox', { name: 'Subject (Optional)' })).toHaveValue(
retrievedDocumentData.documentMeta?.subject ?? '',
);
}).toPass();
});
test('should autosave the message field', async ({ page }) => {
const { user, document, team } = await setupDocumentAndNavigateToSubjectStep(page);
const message = 'Please review and sign this important document. Thank you!';
await page.getByRole('textbox', { name: 'Message (Optional)' }).fill(message);
await triggerAutosave(page);
await expect(async () => {
const retrievedDocumentData = await getDocumentById({
documentId: document.id,
userId: user.id,
teamId: team.id,
});
await expect(page.getByRole('textbox', { name: 'Message (Optional)' })).toHaveValue(
retrievedDocumentData.documentMeta?.message ?? '',
);
}).toPass();
});
test('should autosave the email settings checkboxes', async ({ page }) => {
const { user, document, team } = await setupDocumentAndNavigateToSubjectStep(page);
// Toggle some email settings checkboxes (randomly - some checked, some unchecked)
await page.getByText('Send recipient signed email').click();
await page.getByText('Send recipient removed email').click();
await page.getByText('Send document completed email', { exact: true }).click();
await page.getByText('Send document deleted email').click();
await triggerAutosave(page);
await expect(async () => {
const retrievedDocumentData = await getDocumentById({
documentId: document.id,
userId: user.id,
teamId: team.id,
});
const emailSettings = retrievedDocumentData.documentMeta?.emailSettings;
await expect(page.getByText('Send recipient signed email')).toBeChecked({
checked: emailSettings?.recipientSigned,
});
await expect(page.getByText('Send recipient removed email')).toBeChecked({
checked: emailSettings?.recipientRemoved,
});
await expect(page.getByText('Send document completed email', { exact: true })).toBeChecked({
checked: emailSettings?.documentCompleted,
});
await expect(page.getByText('Send document deleted email')).toBeChecked({
checked: emailSettings?.documentDeleted,
});
await expect(page.getByText('Send recipient signing request email')).toBeChecked({
checked: emailSettings?.recipientSigningRequest,
});
await expect(page.getByText('Send document pending email')).toBeChecked({
checked: emailSettings?.documentPending,
});
await expect(page.getByText('Send document completed email to the owner')).toBeChecked({
checked: emailSettings?.ownerDocumentCompleted,
});
}).toPass();
});
test('should autosave all fields and settings together', async ({ page }) => {
const { user, document, team } = await setupDocumentAndNavigateToSubjectStep(page);
const subject = 'Combined Test Subject - Please Sign';
const message =
'This is a comprehensive test message for autosave functionality. Please review and sign at your earliest convenience.';
await page.getByRole('textbox', { name: 'Subject (Optional)' }).fill(subject);
await page.getByRole('textbox', { name: 'Message (Optional)' }).fill(message);
await page.getByText('Send recipient signed email').click();
await page.getByText('Send recipient removed email').click();
await page.getByText('Send document completed email', { exact: true }).click();
await page.getByText('Send document deleted email').click();
await triggerAutosave(page);
await expect(async () => {
const retrievedDocumentData = await getDocumentById({
documentId: document.id,
userId: user.id,
teamId: team.id,
});
expect(retrievedDocumentData.documentMeta?.subject).toBe(subject);
expect(retrievedDocumentData.documentMeta?.message).toBe(message);
expect(retrievedDocumentData.documentMeta?.emailSettings).toBeDefined();
await expect(page.getByRole('textbox', { name: 'Subject (Optional)' })).toHaveValue(
retrievedDocumentData.documentMeta?.subject ?? '',
);
await expect(page.getByRole('textbox', { name: 'Message (Optional)' })).toHaveValue(
retrievedDocumentData.documentMeta?.message ?? '',
);
await expect(page.getByText('Send recipient signed email')).toBeChecked({
checked: retrievedDocumentData.documentMeta?.emailSettings?.recipientSigned,
});
await expect(page.getByText('Send recipient removed email')).toBeChecked({
checked: retrievedDocumentData.documentMeta?.emailSettings?.recipientRemoved,
});
await expect(page.getByText('Send document completed email', { exact: true })).toBeChecked({
checked: retrievedDocumentData.documentMeta?.emailSettings?.documentCompleted,
});
await expect(page.getByText('Send document deleted email')).toBeChecked({
checked: retrievedDocumentData.documentMeta?.emailSettings?.documentDeleted,
});
await expect(page.getByText('Send recipient signing request email')).toBeChecked({
checked: retrievedDocumentData.documentMeta?.emailSettings?.recipientSigningRequest,
});
await expect(page.getByText('Send document pending email')).toBeChecked({
checked: retrievedDocumentData.documentMeta?.emailSettings?.documentPending,
});
await expect(page.getByText('Send document completed email to the owner')).toBeChecked({
checked: retrievedDocumentData.documentMeta?.emailSettings?.ownerDocumentCompleted,
});
}).toPass();
});
});

View File

@ -534,9 +534,6 @@ test('[DOCUMENT_FLOW]: should be able to create and sign a document with 3 recip
await page.getByLabel('Title').fill(documentTitle);
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('heading', { name: 'Add Signers' })).toBeVisible();
await page.getByLabel('Enable signing order').check();
for (let i = 1; i <= 3; i++) {
if (i > 1) {
await page.getByRole('button', { name: 'Add Signer' }).click();
@ -558,6 +555,9 @@ test('[DOCUMENT_FLOW]: should be able to create and sign a document with 3 recip
.fill(`User ${i}`);
}
await expect(page.getByRole('heading', { name: 'Add Signers' })).toBeVisible();
await page.getByLabel('Enable signing order').check();
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();

View File

@ -379,10 +379,11 @@ test('[TEAMS]: can create a template inside a template folder', async ({ page })
.filter({ hasText: /^Upload Template DocumentDrag & drop your PDF here\.$/ })
.nth(2)
.click();
await page.locator('input[type="file"]').waitFor({ state: 'attached' });
await page.locator('input[type="file"]').nth(0).waitFor({ state: 'attached' });
await page
.locator('input[type="file"]')
.nth(0)
.setInputFiles(path.join(__dirname, '../../../assets/documenso-supporter-pledge.pdf'));
await page.waitForTimeout(3000);

View File

@ -0,0 +1,304 @@
import type { Page } from '@playwright/test';
import { expect, test } from '@playwright/test';
import { getTemplateById } from '@documenso/lib/server-only/template/get-template-by-id';
import { seedBlankTemplate } from '@documenso/prisma/seed/templates';
import { seedUser } from '@documenso/prisma/seed/users';
import { apiSignin } from '../fixtures/authentication';
const setupTemplateAndNavigateToFieldsStep = async (page: Page) => {
const { user, team } = await seedUser();
const template = await seedBlankTemplate(user, team.id);
await apiSignin({
page,
email: user.email,
redirectPath: `/templates/${template.id}/edit`,
});
await page.getByRole('button', { name: 'Continue' }).click();
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.getByPlaceholder('Email').nth(1).fill('recipient2@documenso.com');
await page.getByPlaceholder('Name').nth(1).fill('Recipient 2');
await page.getByRole('button', { name: 'Continue' }).click();
return { user, team, template };
};
const triggerAutosave = async (page: Page) => {
await page.locator('#document-flow-form-container').click();
await page.locator('#document-flow-form-container').blur();
await page.waitForTimeout(5000);
};
test.describe('AutoSave Fields Step', () => {
test('should autosave the fields without advanced settings', async ({ page }) => {
const { user, template, team } = await setupTemplateAndNavigateToFieldsStep(page);
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({
position: {
x: 100,
y: 100,
},
});
await page.getByRole('button', { name: 'Text' }).click();
await page.locator('canvas').click({
position: {
x: 100,
y: 200,
},
});
await page.getByTestId('field-advanced-settings-footer').waitFor({ state: 'visible' });
await page
.getByTestId('field-advanced-settings-footer')
.getByRole('button', { name: 'Cancel' })
.click();
await triggerAutosave(page);
await page.getByRole('combobox').click();
await page.getByRole('option', { name: 'Recipient 2 (recipient2@documenso.com)' }).click();
await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({
position: {
x: 100,
y: 500,
},
});
await triggerAutosave(page);
await expect(async () => {
const retrievedFields = await getTemplateById({
id: template.id,
userId: user.id,
teamId: team.id,
});
const fields = retrievedFields.fields;
expect(fields.length).toBe(3);
expect(fields[0].type).toBe('SIGNATURE');
expect(fields[1].type).toBe('TEXT');
expect(fields[2].type).toBe('SIGNATURE');
}).toPass();
});
test('should autosave the field deletion', async ({ page }) => {
const { user, template, team } = await setupTemplateAndNavigateToFieldsStep(page);
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({
position: {
x: 100,
y: 100,
},
});
await page.getByRole('button', { name: 'Text' }).click();
await page.locator('canvas').click({
position: {
x: 100,
y: 200,
},
});
await page.getByTestId('field-advanced-settings-footer').waitFor({ state: 'visible' });
await page
.getByTestId('field-advanced-settings-footer')
.getByRole('button', { name: 'Cancel' })
.click();
await triggerAutosave(page);
await page.getByRole('combobox').click();
await page.getByRole('option', { name: 'Recipient 2 (recipient2@documenso.com)' }).click();
await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({
position: {
x: 100,
y: 500,
},
});
await triggerAutosave(page);
await page.getByRole('combobox').click();
await page.getByRole('option', { name: 'Recipient 1 (recipient1@documenso.com)' }).click();
await page.getByText('Text').nth(1).click();
await page.getByRole('button', { name: 'Remove' }).click();
await triggerAutosave(page);
await expect(async () => {
const retrievedFields = await getTemplateById({
id: template.id,
userId: user.id,
teamId: team.id,
});
const fields = retrievedFields.fields;
expect(fields.length).toBe(2);
expect(fields[0].type).toBe('SIGNATURE');
expect(fields[1].type).toBe('SIGNATURE');
}).toPass();
});
test('should autosave the field duplication', async ({ page }) => {
const { user, template, team } = await setupTemplateAndNavigateToFieldsStep(page);
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({
position: {
x: 100,
y: 100,
},
});
await page.getByRole('button', { name: 'Text' }).click();
await page.locator('canvas').click({
position: {
x: 100,
y: 200,
},
});
await page.getByTestId('field-advanced-settings-footer').waitFor({ state: 'visible' });
await page
.getByTestId('field-advanced-settings-footer')
.getByRole('button', { name: 'Cancel' })
.click();
await triggerAutosave(page);
await page.getByRole('combobox').click();
await page.getByRole('option', { name: 'Recipient 2 (recipient2@documenso.com)' }).click();
await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({
position: {
x: 100,
y: 500,
},
});
await triggerAutosave(page);
await page.getByRole('combobox').click();
await page.getByRole('option', { name: 'Recipient 1 (recipient1@documenso.com)' }).click();
await page.getByText('Signature').nth(1).click();
await page.getByRole('button', { name: 'Duplicate', exact: true }).click();
await triggerAutosave(page);
await expect(async () => {
const retrievedFields = await getTemplateById({
id: template.id,
userId: user.id,
teamId: team.id,
});
const fields = retrievedFields.fields;
expect(fields.length).toBe(4);
expect(fields[0].type).toBe('SIGNATURE');
expect(fields[1].type).toBe('TEXT');
expect(fields[2].type).toBe('SIGNATURE');
expect(fields[3].type).toBe('SIGNATURE');
}).toPass();
});
test('should autosave the fields with advanced settings', async ({ page }) => {
const { user, template, team } = await setupTemplateAndNavigateToFieldsStep(page);
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({
position: {
x: 100,
y: 100,
},
});
await page.getByRole('button', { name: 'Text' }).click();
await page.locator('canvas').click({
position: {
x: 100,
y: 200,
},
});
await page.getByRole('textbox', { name: 'Field label' }).fill('Test Field');
await page.getByRole('textbox', { name: 'Field placeholder' }).fill('Test Placeholder');
await page.getByRole('textbox', { name: 'Add text to the field' }).fill('Test Text');
await page.getByTestId('field-advanced-settings-footer').waitFor({ state: 'visible' });
await page
.getByTestId('field-advanced-settings-footer')
.getByRole('button', { name: 'Save' })
.click();
await page.waitForTimeout(2500);
await triggerAutosave(page);
await expect(async () => {
const retrievedTemplate = await getTemplateById({
id: template.id,
userId: user.id,
teamId: team.id,
});
const fields = retrievedTemplate.fields;
expect(fields.length).toBe(2);
expect(fields[0].type).toBe('SIGNATURE');
expect(fields[1].type).toBe('TEXT');
const textField = fields[1];
expect(textField.fieldMeta).toBeDefined();
if (
textField.fieldMeta &&
typeof textField.fieldMeta === 'object' &&
'type' in textField.fieldMeta
) {
expect(textField.fieldMeta.type).toBe('text');
expect(textField.fieldMeta.label).toBe('Test Field');
expect(textField.fieldMeta.placeholder).toBe('Test Placeholder');
if (textField.fieldMeta.type === 'text') {
expect(textField.fieldMeta.text).toBe('Test Text');
}
} else {
throw new Error('fieldMeta should be defined and contain advanced settings');
}
}).toPass();
});
});

View File

@ -0,0 +1,244 @@
import type { Page } from '@playwright/test';
import { expect, test } from '@playwright/test';
import { getTemplateById } from '@documenso/lib/server-only/template/get-template-by-id';
import { seedBlankTemplate } from '@documenso/prisma/seed/templates';
import { seedUser } from '@documenso/prisma/seed/users';
import { apiSignin } from '../fixtures/authentication';
test.describe.configure({ mode: 'parallel', timeout: 60000 });
const setupTemplate = async (page: Page) => {
const { user, team } = await seedUser();
const template = await seedBlankTemplate(user, team.id);
await apiSignin({
page,
email: user.email,
redirectPath: `/templates/${template.id}/edit`,
});
return { user, team, template };
};
const triggerAutosave = async (page: Page) => {
await page.locator('#document-flow-form-container').click();
await page.locator('#document-flow-form-container').blur();
await page.waitForTimeout(5000);
};
test.describe('AutoSave Settings Step - Templates', () => {
test('should autosave the title change', async ({ page }) => {
const { user, template, team } = await setupTemplate(page);
const newTemplateTitle = 'New Template Title';
await page.getByRole('textbox', { name: 'Title *' }).fill(newTemplateTitle);
await triggerAutosave(page);
await expect(async () => {
const retrievedTemplate = await getTemplateById({
id: template.id,
userId: user.id,
teamId: team.id,
});
await expect(page.getByRole('textbox', { name: 'Title *' })).toHaveValue(
retrievedTemplate.title,
);
}).toPass();
});
test('should autosave the language change', async ({ page }) => {
const { user, template, team } = await setupTemplate(page);
const newTemplateLanguage = 'French';
const expectedLanguageCode = 'fr';
await page.getByRole('combobox').first().click();
await page.getByRole('option', { name: newTemplateLanguage }).click();
await triggerAutosave(page);
await expect(async () => {
const retrievedTemplate = await getTemplateById({
id: template.id,
userId: user.id,
teamId: team.id,
});
expect(retrievedTemplate.templateMeta?.language).toBe(expectedLanguageCode);
}).toPass();
});
test('should autosave the template access change', async ({ page }) => {
const { user, template, team } = await setupTemplate(page);
const access = 'Require account';
const accessValue = 'ACCOUNT';
await page.getByRole('combobox').nth(1).click();
await page.getByRole('option', { name: access }).click();
await triggerAutosave(page);
await expect(async () => {
const retrievedTemplate = await getTemplateById({
id: template.id,
userId: user.id,
teamId: team.id,
});
expect(retrievedTemplate.authOptions?.globalAccessAuth).toContain(accessValue);
}).toPass();
});
test('should autosave the external ID change', async ({ page }) => {
const { user, template, team } = await setupTemplate(page);
const newExternalId = '1234567890';
await page.getByRole('button', { name: 'Advanced Options' }).click();
await page.getByRole('textbox', { name: 'External ID' }).fill(newExternalId);
await triggerAutosave(page);
await expect(async () => {
const retrievedTemplate = await getTemplateById({
id: template.id,
userId: user.id,
teamId: team.id,
});
expect(retrievedTemplate.externalId).toBe(newExternalId);
}).toPass();
});
test('should autosave the allowed signature types change', async ({ page }) => {
const { user, template, team } = await setupTemplate(page);
await page.getByRole('button', { name: 'Advanced Options' }).click();
await page.getByRole('combobox').nth(4).click();
await page.getByRole('option', { name: 'Draw' }).click();
await page.getByRole('option', { name: 'Type' }).click();
await triggerAutosave(page);
await expect(async () => {
const retrievedTemplate = await getTemplateById({
id: template.id,
userId: user.id,
teamId: team.id,
});
expect(retrievedTemplate.templateMeta?.drawSignatureEnabled).toBe(false);
expect(retrievedTemplate.templateMeta?.typedSignatureEnabled).toBe(false);
expect(retrievedTemplate.templateMeta?.uploadSignatureEnabled).toBe(true);
}).toPass();
});
test('should autosave the date format change', async ({ page }) => {
const { user, template, team } = await setupTemplate(page);
await page.getByRole('button', { name: 'Advanced Options' }).click();
await page.getByRole('combobox').nth(5).click();
await page.getByRole('option', { name: 'ISO 8601', exact: true }).click();
await triggerAutosave(page);
await expect(async () => {
const retrievedTemplate = await getTemplateById({
id: template.id,
userId: user.id,
teamId: team.id,
});
expect(retrievedTemplate.templateMeta?.dateFormat).toBe("yyyy-MM-dd'T'HH:mm:ss.SSSXXX");
}).toPass();
});
test('should autosave the timezone change', async ({ page }) => {
const { user, template, team } = await setupTemplate(page);
await page.getByRole('button', { name: 'Advanced Options' }).click();
await page.getByRole('combobox').nth(6).click();
await page.getByRole('option', { name: 'Europe/London' }).click();
await triggerAutosave(page);
await expect(async () => {
const retrievedTemplate = await getTemplateById({
id: template.id,
userId: user.id,
teamId: team.id,
});
expect(retrievedTemplate.templateMeta?.timezone).toBe('Europe/London');
}).toPass();
});
test('should autosave the redirect URL change', async ({ page }) => {
const { user, template, team } = await setupTemplate(page);
const newRedirectUrl = 'https://documenso.com/test/';
await page.getByRole('button', { name: 'Advanced Options' }).click();
await page.getByRole('textbox', { name: 'Redirect URL' }).fill(newRedirectUrl);
await triggerAutosave(page);
await expect(async () => {
const retrievedTemplate = await getTemplateById({
id: template.id,
userId: user.id,
teamId: team.id,
});
expect(retrievedTemplate.templateMeta?.redirectUrl).toBe(newRedirectUrl);
}).toPass();
});
test('should autosave multiple field changes together', async ({ page }) => {
const { user, template, team } = await setupTemplate(page);
const newTitle = 'Updated Template Title';
await page.getByRole('textbox', { name: 'Title *' }).fill(newTitle);
await page.getByRole('combobox').first().click();
await page.getByRole('option', { name: 'German' }).click();
await page.getByRole('combobox').nth(1).click();
await page.getByRole('option', { name: 'Require account' }).click();
await page.getByRole('button', { name: 'Advanced Options' }).click();
const newExternalId = 'MULTI-TEST-123';
await page.getByRole('textbox', { name: 'External ID' }).fill(newExternalId);
await page.getByRole('combobox').nth(6).click();
await page.getByRole('option', { name: 'Europe/Berlin' }).click();
await triggerAutosave(page);
await expect(async () => {
const retrievedTemplate = await getTemplateById({
id: template.id,
userId: user.id,
teamId: team.id,
});
expect(retrievedTemplate.title).toBe(newTitle);
expect(retrievedTemplate.templateMeta?.language).toBe('de');
expect(retrievedTemplate.authOptions?.globalAccessAuth).toContain('ACCOUNT');
expect(retrievedTemplate.externalId).toBe(newExternalId);
expect(retrievedTemplate.templateMeta?.timezone).toBe('Europe/Berlin');
}).toPass();
});
});

View File

@ -0,0 +1,174 @@
import type { Page } from '@playwright/test';
import { expect, test } from '@playwright/test';
import { getRecipientsForTemplate } from '@documenso/lib/server-only/recipient/get-recipients-for-template';
import { getTemplateById } from '@documenso/lib/server-only/template/get-template-by-id';
import { seedBlankTemplate } from '@documenso/prisma/seed/templates';
import { seedUser } from '@documenso/prisma/seed/users';
import { apiSignin } from '../fixtures/authentication';
test.describe.configure({ mode: 'parallel', timeout: 60000 });
const setupTemplateAndNavigateToSignersStep = async (page: Page) => {
const { user, team } = await seedUser();
const template = await seedBlankTemplate(user, team.id);
await apiSignin({
page,
email: user.email,
redirectPath: `/templates/${template.id}/edit`,
});
await page.getByRole('button', { name: 'Continue' }).click();
return { user, team, template };
};
const triggerAutosave = async (page: Page) => {
await page.locator('#document-flow-form-container').click();
await page.locator('#document-flow-form-container').blur();
await page.waitForTimeout(5000);
};
const addSignerAndSave = async (page: Page) => {
await page.getByPlaceholder('Email').fill('recipient1@documenso.com');
await page.getByPlaceholder('Name').fill('Recipient 1');
await triggerAutosave(page);
};
test.describe('AutoSave Signers Step - Templates', () => {
test('should autosave the signers addition', async ({ page }) => {
const { user, template, team } = await setupTemplateAndNavigateToSignersStep(page);
await addSignerAndSave(page);
await expect(async () => {
const retrievedRecipients = await getRecipientsForTemplate({
templateId: template.id,
userId: user.id,
teamId: team.id,
});
expect(retrievedRecipients.length).toBe(1);
expect(retrievedRecipients[0].email).toBe('recipient1@documenso.com');
expect(retrievedRecipients[0].name).toBe('Recipient 1');
}).toPass();
});
test('should autosave the signer deletion', async ({ page }) => {
const { user, template, team } = await setupTemplateAndNavigateToSignersStep(page);
await addSignerAndSave(page);
await page.getByRole('button', { name: 'Add myself' }).click();
await triggerAutosave(page);
await page.getByTestId('remove-placeholder-recipient-button').first().click();
await triggerAutosave(page);
await expect(async () => {
const retrievedRecipients = await getRecipientsForTemplate({
templateId: template.id,
userId: user.id,
teamId: team.id,
});
expect(retrievedRecipients.length).toBe(1);
expect(retrievedRecipients[0].email).toBe(user.email);
expect(retrievedRecipients[0].name).toBe(user.name);
}).toPass();
});
test('should autosave the signer update', async ({ page }) => {
const { user, template, team } = await setupTemplateAndNavigateToSignersStep(page);
await addSignerAndSave(page);
await page.getByPlaceholder('Name').fill('Documenso Manager');
await page.getByPlaceholder('Email').fill('manager@documenso.com');
await triggerAutosave(page);
await page.getByRole('combobox').click();
await page.getByRole('option', { name: 'Receives copy' }).click();
await triggerAutosave(page);
await expect(async () => {
const retrievedRecipients = await getRecipientsForTemplate({
templateId: template.id,
userId: user.id,
teamId: team.id,
});
expect(retrievedRecipients.length).toBe(1);
expect(retrievedRecipients[0].email).toBe('manager@documenso.com');
expect(retrievedRecipients[0].name).toBe('Documenso Manager');
expect(retrievedRecipients[0].role).toBe('CC');
}).toPass();
});
test('should autosave the signing order change', async ({ page }) => {
const { user, template, team } = await setupTemplateAndNavigateToSignersStep(page);
await addSignerAndSave(page);
await page.getByRole('button', { name: 'Add placeholder recipient' }).click();
await page
.getByTestId('placeholder-recipient-email-input')
.nth(1)
.fill('recipient2@documenso.com');
await page.getByTestId('placeholder-recipient-name-input').nth(1).fill('Recipient 2');
await page.getByRole('button', { name: 'Add placeholder recipient' }).click();
await page
.getByTestId('placeholder-recipient-email-input')
.nth(2)
.fill('recipient3@documenso.com');
await page.getByTestId('placeholder-recipient-name-input').nth(2).fill('Recipient 3');
await triggerAutosave(page);
await page.getByLabel('Enable signing order').check();
await page.getByLabel('Allow signers to dictate next signer').check();
await triggerAutosave(page);
await page.getByTestId('placeholder-recipient-signing-order-input').nth(0).fill('3');
await page.getByTestId('placeholder-recipient-signing-order-input').nth(0).blur();
await triggerAutosave(page);
await page.getByTestId('placeholder-recipient-signing-order-input').nth(1).fill('1');
await page.getByTestId('placeholder-recipient-signing-order-input').nth(1).blur();
await triggerAutosave(page);
await page.getByTestId('placeholder-recipient-signing-order-input').nth(2).fill('2');
await page.getByTestId('placeholder-recipient-signing-order-input').nth(2).blur();
await triggerAutosave(page);
await expect(async () => {
const retrievedTemplate = await getTemplateById({
id: template.id,
userId: user.id,
teamId: team.id,
});
const retrievedRecipients = await getRecipientsForTemplate({
templateId: template.id,
userId: user.id,
teamId: team.id,
});
expect(retrievedTemplate.templateMeta?.signingOrder).toBe('SEQUENTIAL');
expect(retrievedTemplate.templateMeta?.allowDictateNextSigner).toBe(true);
expect(retrievedRecipients.length).toBe(3);
expect(retrievedRecipients[0].signingOrder).toBe(2);
expect(retrievedRecipients[1].signingOrder).toBe(3);
expect(retrievedRecipients[2].signingOrder).toBe(1);
}).toPass();
});
});

View File

@ -268,7 +268,7 @@ test('[TEMPLATE]: should create a document from a template with custom document'
// Upload document.
const [fileChooser] = await Promise.all([
page.waitForEvent('filechooser'),
page.locator('input[type=file]').evaluate((e) => {
page.getByTestId('template-use-dialog-file-input').evaluate((e) => {
if (e instanceof HTMLInputElement) {
e.click();
}
@ -361,7 +361,7 @@ test('[TEMPLATE]: should create a team document from a template with custom docu
// Upload document.
const [fileChooser] = await Promise.all([
page.waitForEvent('filechooser'),
page.locator('input[type=file]').evaluate((e) => {
page.getByTestId('template-use-dialog-file-input').evaluate((e) => {
if (e instanceof HTMLInputElement) {
e.click();
}

View File

@ -144,10 +144,11 @@ test('[TEMPLATES]: use template', async ({ page }) => {
await page.getByRole('button', { name: 'Use Template' }).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');
// Get input with Email label placeholder.
await page.getByLabel('Email').click();
await page.getByLabel('Email').fill(teamMemberUser.email);
await page.getByLabel('Name').click();
await page.getByLabel('Name').fill('name');
await page.getByRole('button', { name: 'Create as draft' }).click();
await page.waitForURL(/\/t\/.+\/documents/);

View File

@ -17,7 +17,7 @@ export default defineConfig({
testDir: './e2e',
/* Run tests in files in parallel */
fullyParallel: false,
workers: 1,
workers: 4,
maxFailures: process.env.CI ? 1 : undefined,
/* Fail the build on CI if you accidentally left test.only in the source code. */
forbidOnly: !!process.env.CI,

View File

@ -7,6 +7,7 @@ import { AppError } from '@documenso/lib/errors/app-error';
import type { AuthAppType } from '../server';
import type { SessionValidationResult } from '../server/lib/session/session';
import type { PartialAccount } from '../server/lib/utils/get-accounts';
import type { ActiveSession } from '../server/lib/utils/get-session';
import { handleSignInRedirect } from '../server/lib/utils/redirect';
import type {
@ -96,6 +97,25 @@ export class AuthClient {
}
}
public account = {
getMany: async () => {
const response = await this.client['accounts'].$get();
await this.handleError(response);
const result = await response.json();
return superjson.deserialize<{ accounts: PartialAccount[] }>(result);
},
delete: async (accountId: string) => {
const response = await this.client['account'][':accountId'].$delete({
param: { accountId },
});
await this.handleError(response);
},
};
public emailPassword = {
signIn: async (data: Omit<TEmailPasswordSignin, 'csrfToken'> & { csrfToken?: string }) => {
let csrfToken = data.csrfToken;
@ -214,6 +234,22 @@ export class AuthClient {
window.location.href = data.redirectUrl;
}
},
org: {
signIn: async ({ orgUrl }: { orgUrl: string }) => {
const response = await this.client['oauth'].authorize.oidc.org[':orgUrl'].$post({
param: { orgUrl },
});
await this.handleError(response);
const data = await response.json();
// Redirect to external OIDC provider URL.
if (data.redirectUrl) {
window.location.href = data.redirectUrl;
}
},
},
};
}

View File

@ -7,6 +7,7 @@ import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { extractRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { setCsrfCookie } from './lib/session/session-cookies';
import { accountRoute } from './routes/account';
import { callbackRoute } from './routes/callback';
import { emailPasswordRoute } from './routes/email-password';
import { oauthRoute } from './routes/oauth';
@ -43,6 +44,7 @@ export const auth = new Hono<HonoAuthContext>()
})
.route('/', sessionRoute)
.route('/', signOutRoute)
.route('/', accountRoute)
.route('/callback', callbackRoute)
.route('/oauth', oauthRoute)
.route('/email-password', emailPasswordRoute)

View File

@ -0,0 +1,37 @@
import { UserSecurityAuditLogType } from '@prisma/client';
import type { Context } from 'hono';
import { ORGANISATION_USER_ACCOUNT_TYPE } from '@documenso/lib/constants/organisations';
import { prisma } from '@documenso/prisma';
import { getSession } from './get-session';
export const deleteAccountProvider = async (c: Context, accountId: string): Promise<void> => {
const { user } = await getSession(c);
const requestMeta = c.get('requestMetadata');
await prisma.$transaction(async (tx) => {
const deletedAccountProvider = await tx.account.delete({
where: {
id: accountId,
userId: user.id,
},
select: {
type: true,
},
});
await tx.userSecurityAuditLog.create({
data: {
userId: user.id,
ipAddress: requestMeta.ipAddress,
userAgent: requestMeta.userAgent,
type:
deletedAccountProvider.type === ORGANISATION_USER_ACCOUNT_TYPE
? UserSecurityAuditLogType.ORGANISATION_SSO_UNLINK
: UserSecurityAuditLogType.ACCOUNT_SSO_UNLINK,
},
});
});
};

View File

@ -0,0 +1,32 @@
import type { Context } from 'hono';
import { prisma } from '@documenso/prisma';
import { getSession } from './get-session';
export type PartialAccount = {
id: string;
userId: number;
type: string;
provider: string;
providerAccountId: string;
createdAt: Date;
};
export const getAccounts = async (c: Context | Request): Promise<PartialAccount[]> => {
const { user } = await getSession(c);
return await prisma.account.findMany({
where: {
userId: user.id,
},
select: {
id: true,
userId: true,
type: true,
provider: true,
providerAccountId: true,
createdAt: true,
},
});
};

View File

@ -20,70 +20,10 @@ type HandleOAuthCallbackUrlOptions = {
export const handleOAuthCallbackUrl = async (options: HandleOAuthCallbackUrlOptions) => {
const { c, clientOptions } = options;
if (!clientOptions.clientId || !clientOptions.clientSecret) {
throw new AppError(AppErrorCode.NOT_SETUP);
}
const { token_endpoint } = await getOpenIdConfiguration(clientOptions.wellKnownUrl, {
requiredScopes: clientOptions.scope,
});
const oAuthClient = new OAuth2Client(
clientOptions.clientId,
clientOptions.clientSecret,
clientOptions.redirectUrl,
);
const requestMeta = c.get('requestMetadata');
const code = c.req.query('code');
const state = c.req.query('state');
const storedState = deleteCookie(c, `${clientOptions.id}_oauth_state`);
const storedCodeVerifier = deleteCookie(c, `${clientOptions.id}_code_verifier`);
const storedRedirectPath = deleteCookie(c, `${clientOptions.id}_redirect_path`) ?? '';
if (!code || !storedState || state !== storedState || !storedCodeVerifier) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Invalid or missing state',
});
}
// eslint-disable-next-line prefer-const
let [redirectState, redirectPath] = storedRedirectPath.split(' ');
if (redirectState !== storedState || !redirectPath) {
redirectPath = '/';
}
const tokens = await oAuthClient.validateAuthorizationCode(
token_endpoint,
code,
storedCodeVerifier,
);
const accessToken = tokens.accessToken();
const accessTokenExpiresAt = tokens.accessTokenExpiresAt();
const idToken = tokens.idToken();
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const claims = decodeIdToken(tokens.idToken()) as Record<string, unknown>;
const email = claims.email;
const name = claims.name;
const sub = claims.sub;
if (typeof email !== 'string' || typeof name !== 'string' || typeof sub !== 'string') {
throw new AppError(AuthenticationErrorCode.InvalidRequest, {
message: 'Invalid claims',
});
}
if (claims.email_verified !== true && !clientOptions.bypassEmailVerification) {
throw new AppError(AuthenticationErrorCode.UnverifiedEmail, {
message: 'Account email is not verified',
});
}
const { email, name, sub, accessToken, accessTokenExpiresAt, idToken, redirectPath } =
await validateOauth({ c, clientOptions });
// Find the account if possible.
const existingAccount = await prisma.account.findFirst({
@ -92,7 +32,11 @@ export const handleOAuthCallbackUrl = async (options: HandleOAuthCallbackUrlOpti
providerAccountId: sub,
},
include: {
user: true,
user: {
select: {
id: true,
},
},
},
});
@ -107,6 +51,10 @@ export const handleOAuthCallbackUrl = async (options: HandleOAuthCallbackUrlOpti
where: {
email: email,
},
select: {
id: true,
emailVerified: true,
},
});
// Handle existing user but no account.
@ -191,3 +139,92 @@ export const handleOAuthCallbackUrl = async (options: HandleOAuthCallbackUrlOpti
return c.redirect(redirectPath, 302);
};
export const validateOauth = async (options: HandleOAuthCallbackUrlOptions) => {
const { c, clientOptions } = options;
if (!clientOptions.clientId || !clientOptions.clientSecret) {
throw new AppError(AppErrorCode.NOT_SETUP);
}
const { token_endpoint } = await getOpenIdConfiguration(clientOptions.wellKnownUrl, {
requiredScopes: clientOptions.scope,
});
const oAuthClient = new OAuth2Client(
clientOptions.clientId,
clientOptions.clientSecret,
clientOptions.redirectUrl,
);
const code = c.req.query('code');
const state = c.req.query('state');
const storedState = deleteCookie(c, `${clientOptions.id}_oauth_state`);
const storedCodeVerifier = deleteCookie(c, `${clientOptions.id}_code_verifier`);
const storedRedirectPath = deleteCookie(c, `${clientOptions.id}_redirect_path`) ?? '';
if (!code || !storedState || state !== storedState || !storedCodeVerifier) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Invalid or missing state',
});
}
// eslint-disable-next-line prefer-const
let [redirectState, redirectPath] = storedRedirectPath.split(' ');
if (redirectState !== storedState || !redirectPath) {
redirectPath = '/';
}
const tokens = await oAuthClient.validateAuthorizationCode(
token_endpoint,
code,
storedCodeVerifier,
);
const accessToken = tokens.accessToken();
const accessTokenExpiresAt = tokens.accessTokenExpiresAt();
const idToken = tokens.idToken();
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const claims = decodeIdToken(tokens.idToken()) as Record<string, unknown>;
const email = claims.email;
const name = claims.name;
const sub = claims.sub;
if (typeof email !== 'string') {
throw new AppError(AuthenticationErrorCode.InvalidRequest, {
message: 'Missing email',
});
}
if (typeof name !== 'string') {
throw new AppError(AuthenticationErrorCode.InvalidRequest, {
message: 'Missing name',
});
}
if (typeof sub !== 'string') {
throw new AppError(AuthenticationErrorCode.InvalidRequest, {
message: 'Missing sub claim',
});
}
if (claims.email_verified !== true && !clientOptions.bypassEmailVerification) {
throw new AppError(AuthenticationErrorCode.UnverifiedEmail, {
message: 'Account email is not verified',
});
}
return {
email,
name,
sub,
accessToken,
accessTokenExpiresAt,
idToken,
redirectPath,
};
};

View File

@ -0,0 +1,99 @@
import type { Context } from 'hono';
import { sendOrganisationAccountLinkConfirmationEmail } from '@documenso/ee/server-only/lib/send-organisation-account-link-confirmation-email';
import { AppError } from '@documenso/lib/errors/app-error';
import { onCreateUserHook } from '@documenso/lib/server-only/user/create-user';
import { formatOrganisationLoginUrl } from '@documenso/lib/utils/organisation-authentication-portal';
import { prisma } from '@documenso/prisma';
import { AuthenticationErrorCode } from '../errors/error-codes';
import { onAuthorize } from './authorizer';
import { validateOauth } from './handle-oauth-callback-url';
import { getOrganisationAuthenticationPortalOptions } from './organisation-portal';
type HandleOAuthOrganisationCallbackUrlOptions = {
c: Context;
orgUrl: string;
};
export const handleOAuthOrganisationCallbackUrl = async (
options: HandleOAuthOrganisationCallbackUrlOptions,
) => {
const { c, orgUrl } = options;
const { organisation, clientOptions } = await getOrganisationAuthenticationPortalOptions({
type: 'url',
organisationUrl: orgUrl,
});
const { email, name, sub, accessToken, accessTokenExpiresAt, idToken } = await validateOauth({
c,
clientOptions: {
...clientOptions,
bypassEmailVerification: true, // Bypass for organisation OIDC because we manually verify the email.
},
});
const allowedDomains = organisation.organisationAuthenticationPortal.allowedDomains;
if (allowedDomains.length > 0 && !allowedDomains.some((domain) => email.endsWith(`@${domain}`))) {
throw new AppError(AuthenticationErrorCode.InvalidRequest, {
message: 'Email domain not allowed',
});
}
// Find the account if possible.
const existingAccount = await prisma.account.findFirst({
where: {
provider: clientOptions.id,
providerAccountId: sub,
},
include: {
user: true,
},
});
// Directly log in user if account already exists.
if (existingAccount) {
await onAuthorize({ userId: existingAccount.user.id }, c);
return c.redirect(`/o/${orgUrl}`, 302);
}
let userToLink = await prisma.user.findFirst({
where: {
email,
},
});
// Handle new user.
if (!userToLink) {
userToLink = await prisma.user.create({
data: {
email: email,
name: name,
emailVerified: null, // Do not verify email.
},
});
await onCreateUserHook(userToLink).catch((err) => {
// Todo: (RR7) Add logging.
console.error(err);
});
}
await sendOrganisationAccountLinkConfirmationEmail({
type: userToLink.emailVerified ? 'link' : 'create',
userId: userToLink.id,
organisationId: organisation.id,
organisationName: organisation.name,
oauthConfig: {
accessToken,
idToken,
providerAccountId: sub,
expiresAt: Math.floor(accessTokenExpiresAt.getTime() / 1000),
},
});
return c.redirect(`${formatOrganisationLoginUrl(orgUrl)}?action=verification-required`, 302);
};

View File

@ -0,0 +1,94 @@
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
import { DOCUMENSO_ENCRYPTION_KEY } from '@documenso/lib/constants/crypto';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { symmetricDecrypt } from '@documenso/lib/universal/crypto';
import { formatOrganisationCallbackUrl } from '@documenso/lib/utils/organisation-authentication-portal';
import { prisma } from '@documenso/prisma';
type GetOrganisationAuthenticationPortalOptions =
| {
type: 'url';
organisationUrl: string;
}
| {
type: 'id';
organisationId: string;
};
export const getOrganisationAuthenticationPortalOptions = async (
options: GetOrganisationAuthenticationPortalOptions,
) => {
const organisation = await prisma.organisation.findFirst({
where:
options.type === 'url'
? {
url: options.organisationUrl,
}
: {
id: options.organisationId,
},
include: {
organisationClaim: true,
organisationAuthenticationPortal: true,
groups: true,
},
});
if (!organisation) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Organisation not found',
});
}
if (!IS_BILLING_ENABLED()) {
throw new AppError(AppErrorCode.NOT_SETUP, {
message: 'Billing is not enabled',
});
}
if (
!organisation.organisationClaim.flags.authenticationPortal ||
!organisation.organisationAuthenticationPortal.enabled
) {
throw new AppError(AppErrorCode.NOT_SETUP, {
message: 'Authentication portal is not enabled for this organisation',
});
}
const {
clientId,
clientSecret: encryptedClientSecret,
wellKnownUrl,
} = organisation.organisationAuthenticationPortal;
if (!clientId || !encryptedClientSecret || !wellKnownUrl) {
throw new AppError(AppErrorCode.NOT_SETUP, {
message: 'Authentication portal is not configured for this organisation',
});
}
if (!DOCUMENSO_ENCRYPTION_KEY) {
throw new AppError(AppErrorCode.NOT_SETUP, {
message: 'Encryption key is not set',
});
}
const clientSecret = Buffer.from(
symmetricDecrypt({ key: DOCUMENSO_ENCRYPTION_KEY, data: encryptedClientSecret }),
).toString('utf-8');
return {
organisation,
clientId,
clientSecret,
wellKnownUrl,
clientOptions: {
id: organisation.id,
scope: ['openid', 'email', 'profile'],
clientId,
clientSecret,
redirectUrl: formatOrganisationCallbackUrl(organisation.url),
wellKnownUrl,
},
};
};

View File

@ -0,0 +1,25 @@
import { Hono } from 'hono';
import superjson from 'superjson';
import { deleteAccountProvider } from '../lib/utils/delete-account-provider';
import { getAccounts } from '../lib/utils/get-accounts';
export const accountRoute = new Hono()
/**
* Get all linked accounts.
*/
.get('/accounts', async (c) => {
const accounts = await getAccounts(c);
return c.json(superjson.serialize({ accounts }));
})
/**
* Delete an account linking method.
*/
.delete('/account/:accountId', async (c) => {
const accountId = c.req.param('accountId');
await deleteAccountProvider(c, accountId);
return c.json({ success: true });
});

View File

@ -1,7 +1,10 @@
import { Hono } from 'hono';
import { AppError } from '@documenso/lib/errors/app-error';
import { GoogleAuthOptions, OidcAuthOptions } from '../config';
import { handleOAuthCallbackUrl } from '../lib/utils/handle-oauth-callback-url';
import { handleOAuthOrganisationCallbackUrl } from '../lib/utils/handle-oauth-organisation-callback-url';
import type { HonoAuthContext } from '../types/context';
/**
@ -14,6 +17,31 @@ export const callbackRoute = new Hono<HonoAuthContext>()
*/
.get('/oidc', async (c) => handleOAuthCallbackUrl({ c, clientOptions: OidcAuthOptions }))
/**
* Organisation OIDC callback verification.
*/
.get('/oidc/org/:orgUrl', async (c) => {
const orgUrl = c.req.param('orgUrl');
try {
return await handleOAuthOrganisationCallbackUrl({
c,
orgUrl,
});
} catch (err) {
console.error(err);
if (err instanceof Error) {
throw new AppError(err.name, {
message: err.message,
statusCode: 500,
});
}
throw err;
}
})
/**
* Google callback verification.
*/

View File

@ -16,7 +16,7 @@ import { validateTwoFactorAuthentication } from '@documenso/lib/server-only/2fa/
import { viewBackupCodes } from '@documenso/lib/server-only/2fa/view-backup-codes';
import { createUser } from '@documenso/lib/server-only/user/create-user';
import { forgotPassword } from '@documenso/lib/server-only/user/forgot-password';
import { getMostRecentVerificationTokenByUserId } from '@documenso/lib/server-only/user/get-most-recent-verification-token-by-user-id';
import { getMostRecentEmailVerificationToken } from '@documenso/lib/server-only/user/get-most-recent-email-verification-token';
import { resetPassword } from '@documenso/lib/server-only/user/reset-password';
import { updatePassword } from '@documenso/lib/server-only/user/update-password';
import { verifyEmail } from '@documenso/lib/server-only/user/verify-email';
@ -105,7 +105,7 @@ export const emailPasswordRoute = new Hono<HonoAuthContext>()
}
if (!user.emailVerified) {
const mostRecentToken = await getMostRecentVerificationTokenByUserId({
const mostRecentToken = await getMostRecentEmailVerificationToken({
userId: user.id,
});

View File

@ -4,6 +4,7 @@ import { z } from 'zod';
import { GoogleAuthOptions, OidcAuthOptions } from '../config';
import { handleOAuthAuthorizeUrl } from '../lib/utils/handle-oauth-authorize-url';
import { getOrganisationAuthenticationPortalOptions } from '../lib/utils/organisation-portal';
import type { HonoAuthContext } from '../types/context';
const ZOAuthAuthorizeSchema = z.object({
@ -34,4 +35,20 @@ export const oauthRoute = new Hono<HonoAuthContext>()
clientOptions: OidcAuthOptions,
redirectPath,
});
})
/**
* Organisation OIDC authorize endpoint.
*/
.post('/authorize/oidc/org/:orgUrl', async (c) => {
const orgUrl = c.req.param('orgUrl');
const { clientOptions } = await getOrganisationAuthenticationPortalOptions({
type: 'url',
organisationUrl: orgUrl,
});
return await handleOAuthAuthorizeUrl({
c,
clientOptions,
});
});

View File

@ -0,0 +1,163 @@
import { UserSecurityAuditLogType } from '@prisma/client';
import { getOrganisationAuthenticationPortalOptions } from '@documenso/auth/server/lib/utils/organisation-portal';
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
import {
ORGANISATION_ACCOUNT_LINK_VERIFICATION_TOKEN_IDENTIFIER,
ORGANISATION_USER_ACCOUNT_TYPE,
} from '@documenso/lib/constants/organisations';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { addUserToOrganisation } from '@documenso/lib/server-only/organisation/accept-organisation-invitation';
import { ZOrganisationAccountLinkMetadataSchema } from '@documenso/lib/types/organisation';
import type { RequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { prisma } from '@documenso/prisma';
export interface LinkOrganisationAccountOptions {
token: string;
requestMeta: RequestMetadata;
}
export const linkOrganisationAccount = async ({
token,
requestMeta,
}: LinkOrganisationAccountOptions) => {
if (!IS_BILLING_ENABLED()) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Billing is not enabled',
});
}
// Delete the token since it contains unnecessary sensitive data.
const verificationToken = await prisma.verificationToken.delete({
where: {
token,
identifier: ORGANISATION_ACCOUNT_LINK_VERIFICATION_TOKEN_IDENTIFIER,
},
include: {
user: {
select: {
id: true,
emailVerified: true,
accounts: {
select: {
provider: true,
providerAccountId: true,
},
},
},
},
},
});
if (!verificationToken) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Verification token not found, used or expired',
});
}
if (verificationToken.completed) {
throw new AppError('ALREADY_USED');
}
if (verificationToken.expires < new Date()) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Verification token not found, used or expired',
});
}
const tokenMetadata = ZOrganisationAccountLinkMetadataSchema.safeParse(
verificationToken.metadata,
);
if (!tokenMetadata.success) {
console.error('Invalid token metadata', tokenMetadata.error);
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Verification token not found, used or expired',
});
}
const user = verificationToken.user;
const { clientOptions, organisation } = await getOrganisationAuthenticationPortalOptions({
type: 'id',
organisationId: tokenMetadata.data.organisationId,
});
const organisationMember = await prisma.organisationMember.findFirst({
where: {
userId: user.id,
organisationId: tokenMetadata.data.organisationId,
},
});
const oauthConfig = tokenMetadata.data.oauthConfig;
const userAlreadyLinked = user.accounts.find(
(account) =>
account.provider === clientOptions.id &&
account.providerAccountId === oauthConfig.providerAccountId,
);
if (organisationMember && userAlreadyLinked) {
return;
}
await prisma.$transaction(
async (tx) => {
// Link the user if not linked yet.
if (!userAlreadyLinked) {
await tx.account.create({
data: {
type: ORGANISATION_USER_ACCOUNT_TYPE,
provider: clientOptions.id,
providerAccountId: oauthConfig.providerAccountId,
access_token: oauthConfig.accessToken,
expires_at: oauthConfig.expiresAt,
token_type: 'Bearer',
id_token: oauthConfig.idToken,
userId: user.id,
},
});
// Log link event.
await tx.userSecurityAuditLog.create({
data: {
userId: user.id,
ipAddress: requestMeta.ipAddress,
userAgent: requestMeta.userAgent,
type: UserSecurityAuditLogType.ORGANISATION_SSO_LINK,
},
});
// If account already exists in an unverified state, remove the password to ensure
// they cannot sign in using that method since we cannot confirm the password
// was set by the user.
if (!user.emailVerified) {
await tx.user.update({
where: {
id: user.id,
},
data: {
emailVerified: new Date(),
password: null,
// Todo: (RR7) Will need to update the "password" account after the migration.
},
});
}
}
// Only add the user to the organisation if they are not already a member.
if (!organisationMember) {
await addUserToOrganisation({
userId: user.id,
organisationId: tokenMetadata.data.organisationId,
organisationGroups: organisation.groups,
organisationMemberRole:
organisation.organisationAuthenticationPortal.defaultOrganisationRole,
});
}
},
{ timeout: 30_000 },
);
};

View File

@ -0,0 +1,119 @@
import { createElement } from 'react';
import { msg } from '@lingui/core/macro';
import crypto from 'crypto';
import { DateTime } from 'luxon';
import { mailer } from '@documenso/email/mailer';
import { OrganisationAccountLinkConfirmationTemplate } from '@documenso/email/templates/organisation-account-link-confirmation';
import { getI18nInstance } from '@documenso/lib/client-only/providers/i18n-server';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { DOCUMENSO_INTERNAL_EMAIL } from '@documenso/lib/constants/email';
import { ORGANISATION_ACCOUNT_LINK_VERIFICATION_TOKEN_IDENTIFIER } from '@documenso/lib/constants/organisations';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { getEmailContext } from '@documenso/lib/server-only/email/get-email-context';
import type { TOrganisationAccountLinkMetadata } from '@documenso/lib/types/organisation';
import { renderEmailWithI18N } from '@documenso/lib/utils/render-email-with-i18n';
import { prisma } from '@documenso/prisma';
export type SendOrganisationAccountLinkConfirmationEmailProps = TOrganisationAccountLinkMetadata & {
organisationName: string;
};
export const sendOrganisationAccountLinkConfirmationEmail = async ({
type,
userId,
organisationId,
organisationName,
oauthConfig,
}: SendOrganisationAccountLinkConfirmationEmailProps) => {
const user = await prisma.user.findFirst({
where: {
id: userId,
},
include: {
verificationTokens: {
where: {
identifier: ORGANISATION_ACCOUNT_LINK_VERIFICATION_TOKEN_IDENTIFIER,
},
orderBy: {
createdAt: 'desc',
},
take: 1,
},
},
});
if (!user) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'User not found',
});
}
const [previousVerificationToken] = user.verificationTokens;
// If we've sent a token in the last 5 minutes, don't send another one
if (
previousVerificationToken?.createdAt &&
DateTime.fromJSDate(previousVerificationToken.createdAt).diffNow('minutes').minutes > -5
) {
return;
}
const token = crypto.randomBytes(20).toString('hex');
const createdToken = await prisma.verificationToken.create({
data: {
identifier: ORGANISATION_ACCOUNT_LINK_VERIFICATION_TOKEN_IDENTIFIER,
token,
expires: DateTime.now().plus({ minutes: 30 }).toJSDate(),
metadata: {
type,
userId,
organisationId,
oauthConfig,
} satisfies TOrganisationAccountLinkMetadata,
userId,
},
});
const { emailLanguage } = await getEmailContext({
emailType: 'INTERNAL',
source: {
type: 'organisation',
organisationId,
},
meta: null,
});
const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000';
const confirmationLink = `${assetBaseUrl}/organisation/sso/confirmation/${createdToken.token}`;
const confirmationTemplate = createElement(OrganisationAccountLinkConfirmationTemplate, {
type,
assetBaseUrl,
confirmationLink,
organisationName,
});
const [html, text] = await Promise.all([
renderEmailWithI18N(confirmationTemplate, { lang: emailLanguage }),
renderEmailWithI18N(confirmationTemplate, { lang: emailLanguage, plainText: true }),
]);
const i18n = await getI18nInstance(emailLanguage);
return mailer.sendMail({
to: {
address: user.email,
name: user.name || '',
},
from: DOCUMENSO_INTERNAL_EMAIL,
subject:
type === 'create'
? i18n._(msg`Account creation request`)
: i18n._(msg`Account linking request`),
html,
text,
});
};

Binary file not shown.

After

Width:  |  Height:  |  Size: 297 B

View File

@ -0,0 +1,145 @@
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import {
Body,
Button,
Container,
Head,
Hr,
Html,
Img,
Preview,
Section,
Text,
} from '../components';
import { useBranding } from '../providers/branding';
import { TemplateFooter } from '../template-components/template-footer';
import TemplateImage from '../template-components/template-image';
type OrganisationAccountLinkConfirmationTemplateProps = {
type: 'create' | 'link';
confirmationLink: string;
organisationName: string;
assetBaseUrl: string;
};
export const OrganisationAccountLinkConfirmationTemplate = ({
type = 'link',
confirmationLink = '<CONFIRMATION_LINK>',
organisationName = '<ORGANISATION_NAME>',
assetBaseUrl = 'http://localhost:3002',
}: OrganisationAccountLinkConfirmationTemplateProps) => {
const { _ } = useLingui();
const branding = useBranding();
const previewText =
type === 'create'
? msg`A request has been made to create an account for you`
: msg`A request has been made to link your Documenso account`;
return (
<Html>
<Head />
<Preview>{_(previewText)}</Preview>
<Body className="mx-auto my-auto font-sans">
<Section className="bg-white">
<Container className="mx-auto mb-2 mt-8 max-w-xl rounded-lg border border-solid border-slate-200 px-2 pt-2 backdrop-blur-sm">
{branding.brandingEnabled && branding.brandingLogo ? (
<Img src={branding.brandingLogo} alt="Branding Logo" className="mb-4 h-6 p-2" />
) : (
<TemplateImage
assetBaseUrl={assetBaseUrl}
className="mb-4 h-6 p-2"
staticAsset="logo.png"
/>
)}
<Section>
<TemplateImage
className="mx-auto h-12 w-12"
assetBaseUrl={assetBaseUrl}
staticAsset="building-2.png"
/>
</Section>
<Section className="p-2 text-slate-500">
<Text className="text-center text-lg font-medium text-black">
{type === 'create' ? (
<Trans>Account creation request</Trans>
) : (
<Trans>Link your Documenso account</Trans>
)}
</Text>
<Text className="text-center text-base">
{type === 'create' ? (
<Trans>
<span className="font-bold">{organisationName}</span> has requested to create an
account on your behalf.
</Trans>
) : (
<Trans>
<span className="font-bold">{organisationName}</span> has requested to link your
current Documenso account to their organisation.
</Trans>
)}
</Text>
{/* Placeholder text if we want to have the warning in the email as well. */}
{/* <Section className="mt-6">
<Text className="my-0 text-sm">
<Trans>
By accepting this request, you will be granting{' '}
<strong>{organisationName}</strong> full access to:
</Trans>
</Text>
<ul className="mb-0 mt-2">
<li className="text-sm">
<Trans>Your account, and everything associated with it</Trans>
</li>
<li className="mt-1 text-sm">
<Trans>Something something something</Trans>
</li>
<li className="mt-1 text-sm">
<Trans>Something something something</Trans>
</li>
</ul>
<Text className="mt-2 text-sm">
<Trans>
You can unlink your account at any time in your security settings on Documenso{' '}
<Link href={`${assetBaseUrl}/settings/security/linked-accounts`}>here.</Link>
</Trans>
</Text>
</Section> */}
<Section className="mb-6 mt-8 text-center">
<Button
className="bg-documenso-500 inline-flex items-center justify-center rounded-lg px-6 py-3 text-center text-sm font-medium text-black no-underline"
href={confirmationLink}
>
<Trans>Review request</Trans>
</Button>
</Section>
</Section>
<Text className="text-center text-xs text-slate-500">
<Trans>Link expires in 30 minutes.</Trans>
</Text>
</Container>
<Hr className="mx-auto mt-12 max-w-xl" />
<Container className="mx-auto max-w-xl">
<TemplateFooter isDocument={false} />
</Container>
</Section>
</Body>
</Html>
);
};
export default OrganisationAccountLinkConfirmationTemplate;

View File

@ -0,0 +1,31 @@
import { useCallback, useEffect, useRef } from 'react';
export const useAutoSave = <T>(onSave: (data: T) => Promise<void>) => {
const saveTimeoutRef = useRef<NodeJS.Timeout>();
const saveFormData = async (data: T) => {
try {
await onSave(data);
} catch (error) {
console.error('Auto-save failed:', error);
}
};
const scheduleSave = useCallback((data: T) => {
if (saveTimeoutRef.current) {
clearTimeout(saveTimeoutRef.current);
}
saveTimeoutRef.current = setTimeout(() => void saveFormData(data), 2000);
}, []);
useEffect(() => {
return () => {
if (saveTimeoutRef.current) {
clearTimeout(saveTimeoutRef.current);
}
};
}, []);
return { scheduleSave };
};

View File

@ -13,4 +13,4 @@ export const IS_BILLING_ENABLED = () => env('NEXT_PUBLIC_FEATURE_BILLING_ENABLED
export const API_V2_BETA_URL = '/api/v2-beta';
export const SUPPORT_EMAIL = 'support@documenso.com';
export const SUPPORT_EMAIL = env('NEXT_PUBLIC_SUPPORT_EMAIL') ?? 'support@documenso.com';

View File

@ -23,6 +23,9 @@ export const OIDC_PROVIDER_LABEL = env('NEXT_PRIVATE_OIDC_PROVIDER_LABEL');
export const USER_SECURITY_AUDIT_LOG_MAP: Record<string, string> = {
ACCOUNT_SSO_LINK: 'Linked account to SSO',
ACCOUNT_SSO_UNLINK: 'Unlinked account from SSO',
ORGANISATION_SSO_LINK: 'Linked account to organisation',
ORGANISATION_SSO_UNLINK: 'Unlinked account from organisation',
ACCOUNT_PROFILE_UPDATE: 'Profile updated',
AUTH_2FA_DISABLE: '2FA Disabled',
AUTH_2FA_ENABLE: '2FA Enabled',

View File

@ -9,6 +9,7 @@ export const VALID_DATE_FORMAT_VALUES = [
'yyyy-MM-dd',
'dd/MM/yyyy hh:mm a',
'MM/dd/yyyy hh:mm a',
'dd.MM.yyyy HH:mm',
'yyyy-MM-dd HH:mm',
'yy-MM-dd hh:mm a',
'yyyy-MM-dd HH:mm:ss',
@ -40,6 +41,11 @@ export const DATE_FORMATS = [
label: 'MM/DD/YYYY',
value: 'MM/dd/yyyy hh:mm a',
},
{
key: 'DDMMYYYYHHMM',
label: 'DD.MM.YYYY HH:mm',
value: 'dd.MM.yyyy HH:mm',
},
{
key: 'YYYYMMDDHHmm',
label: 'YYYY-MM-DD HH:mm',

View File

@ -49,15 +49,24 @@ type DocumentSignatureTypeData = {
export const DOCUMENT_SIGNATURE_TYPES = {
[DocumentSignatureType.DRAW]: {
label: msg`Draw`,
label: msg({
message: `Draw`,
context: `Draw signatute type`,
}),
value: DocumentSignatureType.DRAW,
},
[DocumentSignatureType.TYPE]: {
label: msg`Type`,
label: msg({
message: `Type`,
context: `Type signatute type`,
}),
value: DocumentSignatureType.TYPE,
},
[DocumentSignatureType.UPLOAD]: {
label: msg`Upload`,
label: msg({
message: `Upload`,
context: `Upload signatute type`,
}),
value: DocumentSignatureType.UPLOAD,
},
} satisfies Record<DocumentSignatureType, DocumentSignatureTypeData>;

View File

@ -16,3 +16,5 @@ export const EMAIL_VERIFICATION_STATE = {
EXPIRED: 'EXPIRED',
ALREADY_VERIFIED: 'ALREADY_VERIFIED',
} as const;
export const USER_SIGNUP_VERIFICATION_TOKEN_IDENTIFIER = 'confirmation-email';

View File

@ -126,3 +126,7 @@ export const PROTECTED_ORGANISATION_URLS = [
export const isOrganisationUrlProtected = (url: string) => {
return PROTECTED_ORGANISATION_URLS.some((protectedUrl) => url.startsWith(`/${protectedUrl}`));
};
export const ORGANISATION_ACCOUNT_LINK_VERIFICATION_TOKEN_IDENTIFIER = 'organisation-account-link';
export const ORGANISATION_USER_ACCOUNT_TYPE = 'org-oidc';

View File

@ -4,39 +4,114 @@ import { RecipientRole } from '@prisma/client';
export const RECIPIENT_ROLES_DESCRIPTION = {
[RecipientRole.APPROVER]: {
actionVerb: msg`Approve`,
actioned: msg`Approved`,
progressiveVerb: msg`Approving`,
roleName: msg`Approver`,
roleNamePlural: msg`Approvers`,
actionVerb: msg({
message: `Approve`,
context: `Recipient role action verb`,
}),
actioned: msg({
message: `Approved`,
context: `Recipient role actioned`,
}),
progressiveVerb: msg({
message: `Approving`,
context: `Recipient role progressive verb`,
}),
roleName: msg({
message: `Approver`,
context: `Recipient role name`,
}),
roleNamePlural: msg({
message: `Approvers`,
context: `Recipient role plural name`,
}),
},
[RecipientRole.CC]: {
actionVerb: msg`CC`,
actioned: msg`CC'd`,
progressiveVerb: msg`CC`,
roleName: msg`Cc`,
roleNamePlural: msg`Ccers`,
actionVerb: msg({
message: `CC`,
context: `Recipient role action verb`,
}),
actioned: msg({
message: `CC'd`,
context: `Recipient role actioned`,
}),
progressiveVerb: msg({
message: `CC`,
context: `Recipient role progressive verb`,
}),
roleName: msg({
message: `Cc`,
context: `Recipient role name`,
}),
roleNamePlural: msg({
message: `Ccers`,
context: `Recipient role plural name`,
}),
},
[RecipientRole.SIGNER]: {
actionVerb: msg`Sign`,
actioned: msg`Signed`,
progressiveVerb: msg`Signing`,
roleName: msg`Signer`,
roleNamePlural: msg`Signers`,
actionVerb: msg({
message: `Sign`,
context: `Recipient role action verb`,
}),
actioned: msg({
message: `Signed`,
context: `Recipient role actioned`,
}),
progressiveVerb: msg({
message: `Signing`,
context: `Recipient role progressive verb`,
}),
roleName: msg({
message: `Signer`,
context: `Recipient role name`,
}),
roleNamePlural: msg({
message: `Signers`,
context: `Recipient role plural name`,
}),
},
[RecipientRole.VIEWER]: {
actionVerb: msg`View`,
actioned: msg`Viewed`,
progressiveVerb: msg`Viewing`,
roleName: msg`Viewer`,
roleNamePlural: msg`Viewers`,
actionVerb: msg({
message: `View`,
context: `Recipient role action verb`,
}),
actioned: msg({
message: `Viewed`,
context: `Recipient role actioned`,
}),
progressiveVerb: msg({
message: `Viewing`,
context: `Recipient role progressive verb`,
}),
roleName: msg({
message: `Viewer`,
context: `Recipient role name`,
}),
roleNamePlural: msg({
message: `Viewers`,
context: `Recipient role plural name`,
}),
},
[RecipientRole.ASSISTANT]: {
actionVerb: msg`Assist`,
actioned: msg`Assisted`,
progressiveVerb: msg`Assisting`,
roleName: msg`Assistant`,
roleNamePlural: msg`Assistants`,
actionVerb: msg({
message: `Assist`,
context: `Recipient role action verb`,
}),
actioned: msg({
message: `Assisted`,
context: `Recipient role actioned`,
}),
progressiveVerb: msg({
message: `Assisting`,
context: `Recipient role progressive verb`,
}),
roleName: msg({
message: `Assistant`,
context: `Recipient role name`,
}),
roleNamePlural: msg({
message: `Assistants`,
context: `Recipient role plural name`,
}),
},
} satisfies Record<keyof typeof RecipientRole, unknown>;

View File

@ -3,6 +3,10 @@ import { msg } from '@lingui/core/macro';
export const TEMPLATE_RECIPIENT_EMAIL_PLACEHOLDER_REGEX = /recipient\.\d+@documenso\.com/i;
export const TEMPLATE_RECIPIENT_NAME_PLACEHOLDER_REGEX = /Recipient \d+/i;
export const isTemplateRecipientEmailPlaceholder = (email: string) => {
return TEMPLATE_RECIPIENT_EMAIL_PLACEHOLDER_REGEX.test(email);
};
export const DIRECT_TEMPLATE_DOCUMENTATION = [
{
title: msg`Enable Direct Link Signing`,

View File

@ -29,7 +29,13 @@ export const run = async ({
id: documentId,
},
include: {
user: true,
user: {
select: {
id: true,
email: true,
name: true,
},
},
documentMeta: true,
recipients: true,
team: {
@ -48,7 +54,7 @@ export const run = async ({
type: 'team',
teamId: document.teamId,
},
meta: document.documentMeta || null,
meta: document.documentMeta,
});
const { documentMeta, user: documentOwner } = document;

View File

@ -39,7 +39,13 @@ export const run = async ({
},
},
include: {
user: true,
user: {
select: {
id: true,
email: true,
name: true,
},
},
},
},
},
@ -51,7 +57,13 @@ export const run = async ({
organisationId: payload.organisationId,
},
include: {
user: true,
user: {
select: {
id: true,
email: true,
name: true,
},
},
},
});

View File

@ -39,7 +39,13 @@ export const run = async ({
},
},
include: {
user: true,
user: {
select: {
id: true,
email: true,
name: true,
},
},
},
},
},
@ -49,6 +55,11 @@ export const run = async ({
where: {
id: payload.memberUserId,
},
select: {
id: true,
email: true,
name: true,
},
});
const { branding, emailLanguage, senderEmail } = await getEmailContext({

View File

@ -38,7 +38,13 @@ export const run = async ({
id: recipientId,
},
},
user: true,
user: {
select: {
id: true,
email: true,
name: true,
},
},
documentMeta: true,
},
});
@ -76,7 +82,7 @@ export const run = async ({
type: 'team',
teamId: document.teamId,
},
meta: document.documentMeta || null,
meta: document.documentMeta,
});
const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000';

View File

@ -33,7 +33,13 @@ export const run = async ({
id: documentId,
},
include: {
user: true,
user: {
select: {
id: true,
email: true,
name: true,
},
},
documentMeta: true,
team: {
select: {
@ -68,7 +74,7 @@ export const run = async ({
type: 'team',
teamId: document.teamId,
},
meta: document.documentMeta || null,
meta: document.documentMeta,
});
const i18n = await getI18nInstance(emailLanguage);

View File

@ -42,6 +42,11 @@ export const run = async ({
where: {
id: userId,
},
select: {
id: true,
email: true,
name: true,
},
}),
prisma.document.findFirstOrThrow({
where: {
@ -86,7 +91,7 @@ export const run = async ({
type: 'team',
teamId: document.teamId,
},
meta: document.documentMeta || null,
meta: document.documentMeta,
});
const customEmail = document?.documentMeta;

View File

@ -9,6 +9,7 @@ import { signPdf } from '@documenso/signing';
import { AppError, AppErrorCode } from '../../../errors/app-error';
import { sendCompletedEmail } from '../../../server-only/document/send-completed-email';
import PostHogServerClient from '../../../server-only/feature-flags/get-post-hog-server-client';
import { getAuditLogsPdf } from '../../../server-only/htmltopdf/get-audit-logs-pdf';
import { getCertificatePdf } from '../../../server-only/htmltopdf/get-certificate-pdf';
import { addRejectionStampToPdf } from '../../../server-only/pdf/add-rejection-stamp-to-pdf';
import { flattenAnnotations } from '../../../server-only/pdf/flatten-annotations';
@ -145,7 +146,24 @@ export const run = async ({
? await getCertificatePdf({
documentId,
language: document.documentMeta?.language,
}).catch(() => null)
}).catch((e) => {
console.log('Failed to get certificate PDF');
console.error(e);
return null;
})
: null;
const auditLogData = settings.includeAuditLog
? await getAuditLogsPdf({
documentId,
language: document.documentMeta?.language,
}).catch((e) => {
console.log('Failed to get audit logs PDF');
console.error(e);
return null;
})
: null;
const newDataId = await io.runTask('decorate-and-sign-pdf', async () => {
@ -174,6 +192,16 @@ export const run = async ({
});
}
if (auditLogData) {
const auditLogDoc = await PDFDocument.load(auditLogData);
const auditLogPages = await pdfDoc.copyPages(auditLogDoc, auditLogDoc.getPageIndices());
auditLogPages.forEach((page) => {
pdfDoc.addPage(page);
});
}
for (const field of fields) {
if (field.inserted) {
document.useLegacyFieldInsertion

View File

@ -33,6 +33,7 @@
"@pdf-lib/fontkit": "^1.1.1",
"@scure/base": "^1.1.3",
"@sindresorhus/slugify": "^2.2.1",
"@team-plain/typescript-sdk": "^5.9.0",
"@vvo/tzdb": "^6.117.0",
"csv-parse": "^5.6.0",
"inngest": "^3.19.13",

View File

@ -0,0 +1,7 @@
import { PlainClient } from '@team-plain/typescript-sdk';
import { env } from '@documenso/lib/utils/env';
export const plainClient = new PlainClient({
apiKey: env('NEXT_PRIVATE_PLAIN_API_KEY') ?? '',
});

View File

@ -10,13 +10,7 @@ export type UpdateUserOptions = {
};
export const updateUser = async ({ id, name, email, roles }: UpdateUserOptions) => {
await prisma.user.findFirstOrThrow({
where: {
id,
},
});
return await prisma.user.update({
await prisma.user.update({
where: {
id,
},

View File

@ -8,7 +8,10 @@ import { prisma } from '@documenso/prisma';
import { getI18nInstance } from '../../client-only/providers/i18n-server';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
import { env } from '../../utils/env';
import {
DOCUMENSO_INTERNAL_EMAIL,
USER_SIGNUP_VERIFICATION_TOKEN_IDENTIFIER,
} from '../../constants/email';
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
export interface SendConfirmationEmailProps {
@ -16,15 +19,15 @@ export interface SendConfirmationEmailProps {
}
export const sendConfirmationEmail = async ({ userId }: SendConfirmationEmailProps) => {
const NEXT_PRIVATE_SMTP_FROM_NAME = env('NEXT_PRIVATE_SMTP_FROM_NAME');
const NEXT_PRIVATE_SMTP_FROM_ADDRESS = env('NEXT_PRIVATE_SMTP_FROM_ADDRESS');
const user = await prisma.user.findFirstOrThrow({
where: {
id: userId,
},
include: {
verificationTokens: {
where: {
identifier: USER_SIGNUP_VERIFICATION_TOKEN_IDENTIFIER,
},
orderBy: {
createdAt: 'desc',
},
@ -41,8 +44,6 @@ export const sendConfirmationEmail = async ({ userId }: SendConfirmationEmailPro
const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000';
const confirmationLink = `${assetBaseUrl}/verify-email/${verificationToken.token}`;
const senderName = NEXT_PRIVATE_SMTP_FROM_NAME || 'Documenso';
const senderAddress = NEXT_PRIVATE_SMTP_FROM_ADDRESS || 'noreply@documenso.com';
const confirmationTemplate = createElement(ConfirmEmailTemplate, {
assetBaseUrl,
@ -61,10 +62,7 @@ export const sendConfirmationEmail = async ({ userId }: SendConfirmationEmailPro
address: user.email,
name: user.name || '',
},
from: {
name: senderName,
address: senderAddress,
},
from: DOCUMENSO_INTERNAL_EMAIL,
subject: i18n._(msg`Please confirm your email`),
html,
text,

View File

@ -0,0 +1,21 @@
import * as fs from 'node:fs';
import { env } from '@documenso/lib/utils/env';
export type CertificateStatus = {
isAvailable: boolean;
};
export const getCertificateStatus = (): CertificateStatus => {
const defaultPath =
env('NODE_ENV') === 'production' ? '/opt/documenso/cert.p12' : './example/cert.p12';
const filePath = env('NEXT_PRIVATE_SIGNING_LOCAL_FILE_PATH') || defaultPath;
try {
fs.accessSync(filePath, fs.constants.F_OK | fs.constants.R_OK);
const stats = fs.statSync(filePath);
return { isAvailable: stats.size > 0 };
} catch {
return { isAvailable: false };
}
};

View File

@ -1,6 +1,7 @@
import type { DocumentVisibility, TemplateMeta } from '@prisma/client';
import {
DocumentSource,
FolderType,
RecipientRole,
SendStatus,
SigningStatus,
@ -14,7 +15,7 @@ import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-reques
import { nanoid, prefixedId } from '@documenso/lib/universal/id';
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
import { prisma } from '@documenso/prisma';
import type { TCreateDocumentV2Request } from '@documenso/trpc/server/document-router/schema';
import type { TCreateDocumentTemporaryRequest } from '@documenso/trpc/server/document-router/create-document-temporary.types';
import type { TDocumentAccessAuthTypes, TDocumentActionAuthTypes } from '../../types/document-auth';
import type { TDocumentFormValues } from '../../types/document-form-values';
@ -44,7 +45,8 @@ export type CreateDocumentOptions = {
globalAccessAuth?: TDocumentAccessAuthTypes[];
globalActionAuth?: TDocumentActionAuthTypes[];
formValues?: TDocumentFormValues;
recipients: TCreateDocumentV2Request['recipients'];
recipients: TCreateDocumentTemporaryRequest['recipients'];
folderId?: string;
};
meta?: Partial<Omit<TemplateMeta, 'id' | 'templateId'>>;
requestMetadata: ApiRequestMetadata;
@ -59,7 +61,7 @@ export const createDocumentV2 = async ({
meta,
requestMetadata,
}: CreateDocumentOptions) => {
const { title, formValues } = data;
const { title, formValues, folderId } = data;
const team = await prisma.team.findFirst({
where: buildTeamWhereQuery({ teamId, userId }),
@ -78,6 +80,22 @@ export const createDocumentV2 = async ({
});
}
if (folderId) {
const folder = await prisma.folder.findUnique({
where: {
id: folderId,
type: FolderType.DOCUMENT,
team: buildTeamWhereQuery({ teamId, userId }),
},
});
if (!folder) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Folder not found',
});
}
}
const settings = await getTeamSettings({
userId,
teamId,
@ -164,6 +182,7 @@ export const createDocumentV2 = async ({
teamId,
authOptions,
visibility,
folderId,
formValues,
source: DocumentSource.DOCUMENT,
documentMeta: {
@ -212,7 +231,7 @@ export const createDocumentV2 = async ({
}),
);
// Todo: Is it necessary to create a full audit log with all fields and recipients audit logs?
// Todo: Is it necessary to create a full audit logs with all fields and recipients audit logs?
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({

View File

@ -156,7 +156,7 @@ const handleDocumentOwnerDelete = async ({
type: 'team',
teamId: document.teamId,
},
meta: document.documentMeta || null,
meta: document.documentMeta,
});
// Soft delete completed documents.

View File

@ -49,6 +49,11 @@ export const findDocuments = async ({
where: {
id: userId,
},
select: {
id: true,
email: true,
name: true,
},
});
let team = null;
@ -267,7 +272,7 @@ export const findDocuments = async ({
const findDocumentsFilter = (
status: ExtendedDocumentStatus,
user: User,
user: Pick<User, 'id' | 'email' | 'name'>,
folderId?: string | null,
) => {
return match<ExtendedDocumentStatus, Prisma.DocumentWhereInput>(status)

View File

@ -111,7 +111,7 @@ export const getDocumentWhereInput = async ({
visibility: {
in: teamVisibilityFilters,
},
teamId,
teamId: team.id,
},
// Or, if they are a recipient of the document.
{

View File

@ -73,7 +73,13 @@ export const getDocumentAndSenderByToken = async ({
},
},
include: {
user: true,
user: {
select: {
id: true,
email: true,
name: true,
},
},
documentData: true,
documentMeta: true,
recipients: {
@ -91,9 +97,6 @@ export const getDocumentAndSenderByToken = async ({
},
});
// eslint-disable-next-line no-unused-vars, @typescript-eslint/no-unused-vars
const { password: _password, ...user } = result.user;
const recipient = result.recipients[0];
// Sanity check, should not be possible.
@ -121,7 +124,11 @@ export const getDocumentAndSenderByToken = async ({
return {
...result,
user,
user: {
id: result.user.id,
email: result.user.email,
name: result.user.name,
},
};
};

View File

@ -7,14 +7,12 @@ export type GetDocumentWithDetailsByIdOptions = {
documentId: number;
userId: number;
teamId: number;
folderId?: string;
};
export const getDocumentWithDetailsById = async ({
documentId,
userId,
teamId,
folderId,
}: GetDocumentWithDetailsByIdOptions) => {
const { documentWhereInput } = await getDocumentWhereInput({
documentId,
@ -25,7 +23,6 @@ export const getDocumentWithDetailsById = async ({
const document = await prisma.document.findFirst({
where: {
...documentWhereInput,
folderId,
},
include: {
documentData: true,

View File

@ -28,13 +28,7 @@ export async function rejectDocumentWithToken({
documentId,
},
include: {
document: {
include: {
user: true,
recipients: true,
documentMeta: true,
},
},
document: true,
},
});

View File

@ -102,7 +102,7 @@ export const resendDocument = async ({
type: 'team',
teamId: document.teamId,
},
meta: document.documentMeta || null,
meta: document.documentMeta,
});
await Promise.all(

View File

@ -17,6 +17,7 @@ import type { RequestMetadata } from '../../universal/extract-request-metadata';
import { getFileServerSide } from '../../universal/upload/get-file.server';
import { putPdfFileServerSide } from '../../universal/upload/put-file.server';
import { fieldsContainUnsignedRequiredField } from '../../utils/advanced-fields-helpers';
import { getAuditLogsPdf } from '../htmltopdf/get-audit-logs-pdf';
import { getCertificatePdf } from '../htmltopdf/get-certificate-pdf';
import { addRejectionStampToPdf } from '../pdf/add-rejection-stamp-to-pdf';
import { flattenAnnotations } from '../pdf/flatten-annotations';
@ -125,6 +126,18 @@ export const sealDocument = async ({
})
: null;
const auditLogData = settings.includeAuditLog
? await getAuditLogsPdf({
documentId,
language: document.documentMeta?.language,
}).catch((e) => {
console.log('Failed to get audit logs PDF');
console.error(e);
return null;
})
: null;
const doc = await PDFDocument.load(pdfData);
// Normalize and flatten layers that could cause issues with the signature
@ -147,6 +160,16 @@ export const sealDocument = async ({
});
}
if (auditLogData) {
const auditLog = await PDFDocument.load(auditLogData);
const auditLogPages = await doc.copyPages(auditLog, auditLog.getPageIndices());
auditLogPages.forEach((page) => {
doc.addPage(page);
});
}
for (const field of fields) {
document.useLegacyFieldInsertion
? await legacy_insertFieldInPDF(doc, field)

View File

@ -33,7 +33,13 @@ export const sendCompletedEmail = async ({ documentId, requestMetadata }: SendDo
documentData: true,
documentMeta: true,
recipients: true,
user: true,
user: {
select: {
id: true,
email: true,
name: true,
},
},
team: {
select: {
id: true,
@ -59,7 +65,7 @@ export const sendCompletedEmail = async ({ documentId, requestMetadata }: SendDo
type: 'team',
teamId: document.teamId,
},
meta: document.documentMeta || null,
meta: document.documentMeta,
});
const { user: owner } = document;

View File

@ -24,7 +24,13 @@ export const sendDeleteEmail = async ({ documentId, reason }: SendDeleteEmailOpt
id: documentId,
},
include: {
user: true,
user: {
select: {
id: true,
email: true,
name: true,
},
},
documentMeta: true,
},
});
@ -49,7 +55,7 @@ export const sendDeleteEmail = async ({ documentId, reason }: SendDeleteEmailOpt
type: 'team',
teamId: document.teamId,
},
meta: document.documentMeta || null,
meta: document.documentMeta,
});
const { email, name } = document.user;

View File

@ -148,33 +148,6 @@ export const sendDocument = async ({
// throw new Error('Some signers have not been assigned a signature field.');
// }
const isRecipientSigningRequestEmailEnabled = extractDerivedDocumentEmailSettings(
document.documentMeta,
).recipientSigningRequest;
// Only send email if one of the following is true:
// - It is explicitly set
// - The email is enabled for signing requests AND sendEmail is undefined
if (sendEmail || (isRecipientSigningRequestEmailEnabled && sendEmail === undefined)) {
await Promise.all(
recipientsToNotify.map(async (recipient) => {
if (recipient.sendStatus === SendStatus.SENT || recipient.role === RecipientRole.CC) {
return;
}
await jobs.triggerJob({
name: 'send.signing.requested.email',
payload: {
userId,
documentId,
recipientId: recipient.id,
requestMetadata: requestMetadata?.requestMetadata,
},
});
}),
);
}
const allRecipientsHaveNoActionToTake = document.recipients.every(
(recipient) =>
recipient.role === RecipientRole.CC || recipient.signingStatus === SigningStatus.SIGNED,
@ -227,6 +200,33 @@ export const sendDocument = async ({
});
});
const isRecipientSigningRequestEmailEnabled = extractDerivedDocumentEmailSettings(
document.documentMeta,
).recipientSigningRequest;
// Only send email if one of the following is true:
// - It is explicitly set
// - The email is enabled for signing requests AND sendEmail is undefined
if (sendEmail || (isRecipientSigningRequestEmailEnabled && sendEmail === undefined)) {
await Promise.all(
recipientsToNotify.map(async (recipient) => {
if (recipient.sendStatus === SendStatus.SENT || recipient.role === RecipientRole.CC) {
return;
}
await jobs.triggerJob({
name: 'send.signing.requested.email',
payload: {
userId,
documentId,
recipientId: recipient.id,
requestMetadata: requestMetadata?.requestMetadata,
},
});
}),
);
}
await triggerWebhook({
event: WebhookTriggerEvents.DOCUMENT_SENT,
data: ZWebhookDocumentSchema.parse(mapDocumentToWebhookDocumentPayload(updatedDocument)),

View File

@ -51,7 +51,7 @@ export const sendPendingEmail = async ({ documentId, recipientId }: SendPendingE
type: 'team',
teamId: document.teamId,
},
meta: document.documentMeta || null,
meta: document.documentMeta,
});
const isDocumentPendingEmailEnabled = extractDerivedDocumentEmailSettings(

View File

@ -30,7 +30,13 @@ export const superDeleteDocument = async ({ id, requestMetadata }: SuperDeleteDo
include: {
recipients: true,
documentMeta: true,
user: true,
user: {
select: {
id: true,
email: true,
name: true,
},
},
},
});
@ -46,7 +52,7 @@ export const superDeleteDocument = async ({ id, requestMetadata }: SuperDeleteDo
type: 'team',
teamId: document.teamId,
},
meta: document.documentMeta || null,
meta: document.documentMeta,
});
const { status, user } = document;

View File

@ -1,3 +1,5 @@
import { P, match } from 'ts-pattern';
import type { BrandingSettings } from '@documenso/email/providers/branding';
import { prisma } from '@documenso/prisma';
import type {
@ -59,7 +61,7 @@ type RecipientGetEmailContextOptions = BaseGetEmailContextOptions & {
* Force meta options as a typesafe way to ensure developers don't forget to
* pass it in if it is available.
*/
meta: EmailMetaOption | null;
meta: EmailMetaOption | null | undefined;
};
type GetEmailContextOptions = InternalGetEmailContextOptions | RecipientGetEmailContextOptions;
@ -104,7 +106,12 @@ export const getEmailContext = async (
}
const replyToEmail = meta?.emailReplyTo || emailContext.settings.emailReplyTo || undefined;
const senderEmailId = meta?.emailId || emailContext.settings.emailId;
const senderEmailId = match(meta?.emailId)
.with(P.string, (emailId) => emailId) // Explicit string means to use the provided email ID.
.with(undefined, () => emailContext.settings.emailId) // Undefined means to use the inherited email ID.
.with(null, () => null) // Explicit null means to use the Documenso email.
.exhaustive();
const foundSenderEmail = emailContext.allowedEmails.find((email) => email.id === senderEmailId);

View File

@ -0,0 +1,83 @@
import { DateTime } from 'luxon';
import type { Browser } from 'playwright';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
import { type SupportedLanguageCodes, isValidLanguageCode } from '../../constants/i18n';
import { env } from '../../utils/env';
import { encryptSecondaryData } from '../crypto/encrypt';
export type GetAuditLogsPdfOptions = {
documentId: number;
// eslint-disable-next-line @typescript-eslint/ban-types
language?: SupportedLanguageCodes | (string & {});
};
export const getAuditLogsPdf = async ({ documentId, language }: GetAuditLogsPdfOptions) => {
const { chromium } = await import('playwright');
const encryptedId = encryptSecondaryData({
data: documentId.toString(),
expiresAt: DateTime.now().plus({ minutes: 5 }).toJSDate().valueOf(),
});
let browser: Browser;
const browserlessUrl = env('NEXT_PRIVATE_BROWSERLESS_URL');
if (browserlessUrl) {
// !: 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(browserlessUrl);
} else {
browser = await chromium.launch();
}
if (!browser) {
throw new Error(
'Failed to establish a browser, please ensure you have either a Browserless.io url or chromium browser installed',
);
}
const browserContext = await browser.newContext();
const page = await browserContext.newPage();
const lang = isValidLanguageCode(language) ? language : 'en';
await page.context().addCookies([
{
name: 'language',
value: lang,
url: NEXT_PUBLIC_WEBAPP_URL(),
},
]);
await page.goto(`${NEXT_PUBLIC_WEBAPP_URL()}/__htmltopdf/audit-log?d=${encryptedId}`, {
waitUntil: 'networkidle',
timeout: 10_000,
});
// !: This is a workaround to ensure the page is loaded correctly.
// !: It's not clear why but suddenly browserless cdp connections would
// !: cause the page to render blank until a reload is performed.
await page.reload({
waitUntil: 'networkidle',
timeout: 10_000,
});
await page.waitForSelector('h1', {
state: 'visible',
timeout: 10_000,
});
const result = await page.pdf({
format: 'A4',
printBackground: true,
});
await browserContext.close();
void browser.close();
return result;
};

View File

@ -46,7 +46,7 @@ export const getCertificatePdf = async ({ documentId, language }: GetCertificate
await page.context().addCookies([
{
name: 'language',
name: 'lang',
value: lang,
url: NEXT_PUBLIC_WEBAPP_URL(),
},
@ -57,8 +57,22 @@ export const getCertificatePdf = async ({ documentId, language }: GetCertificate
timeout: 10_000,
});
// !: This is a workaround to ensure the page is loaded correctly.
// !: It's not clear why but suddenly browserless cdp connections would
// !: cause the page to render blank until a reload is performed.
await page.reload({
waitUntil: 'networkidle',
timeout: 10_000,
});
await page.waitForSelector('h1', {
state: 'visible',
timeout: 10_000,
});
const result = await page.pdf({
format: 'A4',
printBackground: true,
});
await browserContext.close();

View File

@ -1,3 +1,4 @@
import type { OrganisationGroup, OrganisationMemberRole } from '@prisma/client';
import { OrganisationGroupType, OrganisationMemberInviteStatus } from '@prisma/client';
import { prisma } from '@documenso/prisma';
@ -23,11 +24,7 @@ export const acceptOrganisationInvitation = async ({
include: {
organisation: {
include: {
groups: {
include: {
teamGroups: true,
},
},
groups: true,
},
},
},
@ -45,6 +42,9 @@ export const acceptOrganisationInvitation = async ({
where: {
email: organisationMemberInvite.email,
},
select: {
id: true,
},
});
if (!user) {
@ -55,10 +55,49 @@ export const acceptOrganisationInvitation = async ({
const { organisation } = organisationMemberInvite;
const organisationGroupToUse = organisation.groups.find(
const isUserPartOfOrganisation = await prisma.organisationMember.findFirst({
where: {
userId: user.id,
organisationId: organisation.id,
},
});
if (isUserPartOfOrganisation) {
return;
}
await addUserToOrganisation({
userId: user.id,
organisationId: organisation.id,
organisationGroups: organisation.groups,
organisationMemberRole: organisationMemberInvite.organisationRole,
});
await prisma.organisationMemberInvite.update({
where: {
id: organisationMemberInvite.id,
},
data: {
status: OrganisationMemberInviteStatus.ACCEPTED,
},
});
};
export const addUserToOrganisation = async ({
userId,
organisationId,
organisationGroups,
organisationMemberRole,
}: {
userId: number;
organisationId: string;
organisationGroups: OrganisationGroup[];
organisationMemberRole: OrganisationMemberRole;
}) => {
const organisationGroupToUse = organisationGroups.find(
(group) =>
group.type === OrganisationGroupType.INTERNAL_ORGANISATION &&
group.organisationRole === organisationMemberInvite.organisationRole,
group.organisationRole === organisationMemberRole,
);
if (!organisationGroupToUse) {
@ -72,8 +111,8 @@ export const acceptOrganisationInvitation = async ({
await tx.organisationMember.create({
data: {
id: generateDatabaseId('member'),
userId: user.id,
organisationId: organisation.id,
userId,
organisationId,
organisationGroupMembers: {
create: {
id: generateDatabaseId('group_member'),
@ -83,20 +122,11 @@ export const acceptOrganisationInvitation = async ({
},
});
await tx.organisationMemberInvite.update({
where: {
id: organisationMemberInvite.id,
},
data: {
status: OrganisationMemberInviteStatus.ACCEPTED,
},
});
await jobs.triggerJob({
name: 'send.organisation-member-joined.email',
payload: {
organisationId: organisation.id,
memberUserId: user.id,
organisationId,
memberUserId: userId,
},
});
},

View File

@ -2,8 +2,10 @@ import type { Prisma } from '@prisma/client';
import { OrganisationType } from '@prisma/client';
import { OrganisationMemberRole } from '@prisma/client';
import { createCustomer } from '@documenso/ee/server-only/stripe/create-customer';
import { prisma } from '@documenso/prisma';
import { IS_BILLING_ENABLED } from '../../constants/app';
import { ORGANISATION_INTERNAL_GROUPS } from '../../constants/organisations';
import { AppErrorCode } from '../../errors/app-error';
import { AppError } from '../../errors/app-error';
@ -30,6 +32,33 @@ export const createOrganisation = async ({
customerId,
claim,
}: CreateOrganisationOptions) => {
let customerIdToUse = customerId;
if (!customerId && IS_BILLING_ENABLED()) {
const user = await prisma.user.findUnique({
where: {
id: userId,
},
});
if (!user) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'User not found',
});
}
customerIdToUse = await createCustomer({
name: user.name || user.email,
email: user.email,
})
.then((customer) => customer.id)
.catch((err) => {
console.error(err);
return undefined;
});
}
return await prisma.$transaction(async (tx) => {
const organisationSetting = await tx.organisationGlobalSettings.create({
data: {
@ -46,6 +75,16 @@ export const createOrganisation = async ({
},
});
const organisationAuthenticationPortal = await tx.organisationAuthenticationPortal.create({
data: {
id: generateDatabaseId('org_sso'),
enabled: false,
clientId: '',
clientSecret: '',
wellKnownUrl: '',
},
});
const orgIdAndUrl = prefixedId('org');
const organisation = await tx.organisation
@ -58,13 +97,14 @@ export const createOrganisation = async ({
ownerUserId: userId,
organisationGlobalSettingsId: organisationSetting.id,
organisationClaimId: organisationClaim.id,
organisationAuthenticationPortalId: organisationAuthenticationPortal.id,
groups: {
create: ORGANISATION_INTERNAL_GROUPS.map((group) => ({
...group,
id: generateDatabaseId('org_group'),
})),
},
customerId,
customerId: customerIdToUse,
},
include: {
groups: true,

View File

@ -3,6 +3,7 @@ import type { PDFDocument } from 'pdf-lib';
import { TextAlignment, rgb, setFontAndSize } from 'pdf-lib';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
import { getPageSize } from './get-page-size';
/**
* Adds a rejection stamp to each page of a PDF document.
@ -27,7 +28,7 @@ export async function addRejectionStampToPdf(
for (let i = 0; i < pages.length; i++) {
const page = pages[i];
const { width, height } = page.getSize();
const { width, height } = getPageSize(page);
// Draw the "REJECTED" text
const rejectedTitleText = 'DOCUMENT REJECTED';

View File

@ -0,0 +1,18 @@
import type { PDFPage } from 'pdf-lib';
/**
* Gets the effective page size for PDF operations.
*
* Uses CropBox by default to handle rare cases where MediaBox is larger than CropBox.
* Falls back to MediaBox when it's smaller than CropBox, following typical PDF reader behavior.
*/
export const getPageSize = (page: PDFPage) => {
const cropBox = page.getCropBox();
const mediaBox = page.getMediaBox();
if (mediaBox.width < cropBox.width || mediaBox.height < cropBox.height) {
return mediaBox;
}
return cropBox;
};

View File

@ -33,6 +33,7 @@ import {
ZRadioFieldMeta,
ZTextFieldMeta,
} from '../../types/field-meta';
import { getPageSize } from './get-page-size';
export const insertFieldInPDF = async (pdf: PDFDocument, field: FieldWithSignature) => {
const [fontCaveat, fontNoto] = await Promise.all([
@ -77,7 +78,7 @@ export const insertFieldInPDF = async (pdf: PDFDocument, field: FieldWithSignatu
const isPageRotatedToLandscape = pageRotationInDegrees === 90 || pageRotationInDegrees === 270;
let { width: pageWidth, height: pageHeight } = page.getSize();
let { width: pageWidth, height: pageHeight } = getPageSize(page);
// PDFs can have pages that are rotated, which are correctly rendered in the frontend.
// However when we load the PDF in the backend, the rotation is applied.

View File

@ -26,6 +26,7 @@ import {
ZRadioFieldMeta,
ZTextFieldMeta,
} from '../../types/field-meta';
import { getPageSize } from './get-page-size';
export const legacy_insertFieldInPDF = async (pdf: PDFDocument, field: FieldWithSignature) => {
const [fontCaveat, fontNoto] = await Promise.all([
@ -63,7 +64,7 @@ export const legacy_insertFieldInPDF = async (pdf: PDFDocument, field: FieldWith
const isPageRotatedToLandscape = pageRotationInDegrees === 90 || pageRotationInDegrees === 270;
let { width: pageWidth, height: pageHeight } = page.getSize();
let { width: pageWidth, height: pageHeight } = getPageSize(page);
// PDFs can have pages that are rotated, which are correctly rendered in the frontend.
// However when we load the PDF in the backend, the rotation is applied.

View File

@ -25,7 +25,7 @@ export const deleteTokenById = async ({ id, userId, teamId }: DeleteTokenByIdOpt
});
}
return await prisma.apiToken.delete({
await prisma.apiToken.delete({
where: {
id,
teamId,

View File

@ -130,7 +130,7 @@ export const deleteDocumentRecipient = async ({
type: 'team',
teamId: document.teamId,
},
meta: document.documentMeta || null,
meta: document.documentMeta,
});
const [html, text] = await Promise.all([

View File

@ -0,0 +1,108 @@
import { Prisma } from '@prisma/client';
import { buildTeamWhereQuery } from '@documenso/lib/utils/teams';
import { prisma } from '@documenso/prisma';
export type GetRecipientSuggestionsOptions = {
userId: number;
teamId?: number;
query: string;
};
export const getRecipientSuggestions = async ({
userId,
teamId,
query,
}: GetRecipientSuggestionsOptions) => {
const trimmedQuery = query.trim();
const nameEmailFilter = trimmedQuery
? {
OR: [
{
name: {
contains: trimmedQuery,
mode: Prisma.QueryMode.insensitive,
},
},
{
email: {
contains: trimmedQuery,
mode: Prisma.QueryMode.insensitive,
},
},
],
}
: {};
const recipients = await prisma.recipient.findMany({
where: {
document: {
team: buildTeamWhereQuery({ teamId, userId }),
},
...nameEmailFilter,
},
select: {
name: true,
email: true,
document: {
select: {
createdAt: true,
},
},
},
distinct: ['email'],
orderBy: {
document: {
createdAt: 'desc',
},
},
take: 5,
});
if (teamId) {
const teamMembers = await prisma.organisationMember.findMany({
where: {
user: {
...nameEmailFilter,
NOT: { id: userId },
},
organisationGroupMembers: {
some: {
group: {
teamGroups: {
some: { teamId },
},
},
},
},
},
include: {
user: {
select: {
email: true,
name: true,
},
},
},
take: 5,
});
const uniqueTeamMember = teamMembers.find(
(member) => !recipients.some((r) => r.email === member.user.email),
);
if (uniqueTeamMember) {
const teamMemberSuggestion = {
email: uniqueTeamMember.user.email,
name: uniqueTeamMember.user.name,
};
const allSuggestions = [...recipients.slice(0, 4), teamMemberSuggestion];
return allSuggestions;
}
}
return recipients;
};

View File

@ -1,5 +1,7 @@
import { prisma } from '@documenso/prisma';
import { buildTeamWhereQuery } from '../../utils/teams';
export interface GetRecipientsForTemplateOptions {
templateId: number;
userId: number;
@ -14,21 +16,12 @@ export const getRecipientsForTemplate = async ({
const recipients = await prisma.recipient.findMany({
where: {
templateId,
template: teamId
? {
team: {
id: teamId,
members: {
some: {
userId,
},
},
},
}
: {
userId,
teamId: null,
},
template: {
team: buildTeamWhereQuery({
teamId,
userId,
}),
},
},
orderBy: {
id: 'asc',

View File

@ -95,7 +95,7 @@ export const setDocumentRecipients = async ({
type: 'team',
teamId,
},
meta: document.documentMeta || null,
meta: document.documentMeta,
});
const recipientsHaveActionAuth = recipients.some(
@ -134,6 +134,9 @@ export const setDocumentRecipients = async ({
existingRecipient.id === recipient.id || existingRecipient.email === recipient.email,
);
const canPersistedRecipientBeModified =
existing && canRecipientBeModified(existing, document.fields);
if (
existing &&
hasRecipientBeenChanged(existing, recipient) &&
@ -147,6 +150,7 @@ export const setDocumentRecipients = async ({
return {
...recipient,
_persisted: existing,
canPersistedRecipientBeModified,
};
});
@ -162,6 +166,13 @@ export const setDocumentRecipients = async ({
});
}
if (recipient._persisted && !recipient.canPersistedRecipientBeModified) {
return {
...recipient._persisted,
clientId: recipient.clientId,
};
}
const upsertedRecipient = await tx.recipient.upsert({
where: {
id: recipient._persisted?.id ?? -1,

View File

@ -1,7 +1,5 @@
import type { Recipient } from '@prisma/client';
import { RecipientRole } from '@prisma/client';
import { SendStatus, SigningStatus } from '@prisma/client';
import { isDeepEqual } from 'remeda';
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
import type { TRecipientAccessAuthTypes } from '@documenso/lib/types/document-auth';
@ -104,10 +102,7 @@ export const updateDocumentRecipients = async ({
});
}
if (
hasRecipientBeenChanged(originalRecipient, recipient) &&
!canRecipientBeModified(originalRecipient, document.fields)
) {
if (!canRecipientBeModified(originalRecipient, document.fields)) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Cannot modify a recipient who has already interacted with the document',
});
@ -203,9 +198,6 @@ export const updateDocumentRecipients = async ({
};
};
/**
* If you change this you MUST update the `hasRecipientBeenChanged` function.
*/
type RecipientData = {
id: number;
email?: string;
@ -215,19 +207,3 @@ type RecipientData = {
accessAuth?: TRecipientAccessAuthTypes[];
actionAuth?: TRecipientActionAuthTypes[];
};
const hasRecipientBeenChanged = (recipient: Recipient, newRecipientData: RecipientData) => {
const authOptions = ZRecipientAuthOptionsSchema.parse(recipient.authOptions);
const newRecipientAccessAuth = newRecipientData.accessAuth || null;
const newRecipientActionAuth = newRecipientData.actionAuth || null;
return (
recipient.email !== newRecipientData.email ||
recipient.name !== newRecipientData.name ||
recipient.role !== newRecipientData.role ||
recipient.signingOrder !== newRecipientData.signingOrder ||
!isDeepEqual(authOptions.accessAuth, newRecipientAccessAuth) ||
!isDeepEqual(authOptions.actionAuth, newRecipientActionAuth)
);
};

View File

@ -105,7 +105,13 @@ export const createDocumentFromDirectTemplate = async ({
directLink: true,
templateDocumentData: true,
templateMeta: true,
user: true,
user: {
select: {
id: true,
email: true,
name: true,
},
},
},
});

View File

@ -2,6 +2,7 @@ import type { DocumentDistributionMethod, DocumentSigningOrder } from '@prisma/c
import {
DocumentSource,
type Field,
FolderType,
type Recipient,
RecipientRole,
SendStatus,
@ -69,6 +70,7 @@ export type CreateDocumentFromTemplateOptions = {
email: string;
signingOrder?: number | null;
}[];
folderId?: string;
prefillFields?: TFieldMetaPrefillFieldsSchema[];
customDocumentDataId?: string;
@ -274,6 +276,7 @@ export const createDocumentFromTemplate = async ({
customDocumentDataId,
override,
requestMetadata,
folderId,
prefillFields,
}: CreateDocumentFromTemplateOptions) => {
const template = await prisma.template.findUnique({
@ -299,6 +302,22 @@ export const createDocumentFromTemplate = async ({
});
}
if (folderId) {
const folder = await prisma.folder.findUnique({
where: {
id: folderId,
type: FolderType.DOCUMENT,
team: buildTeamWhereQuery({ teamId, userId }),
},
});
if (!folder) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Folder not found',
});
}
}
const settings = await getTeamSettings({
userId,
teamId,
@ -369,6 +388,7 @@ export const createDocumentFromTemplate = async ({
externalId: externalId || template.externalId,
templateId: template.id,
userId,
folderId,
teamId: template.teamId,
title: override?.title || template.title,
documentDataId: documentData.id,

View File

@ -1,41 +0,0 @@
import crypto from 'crypto';
import { prisma } from '@documenso/prisma';
import { ONE_HOUR } from '../../constants/time';
import { sendConfirmationEmail } from '../auth/send-confirmation-email';
const IDENTIFIER = 'confirmation-email';
export const generateConfirmationToken = async ({ email }: { email: string }) => {
const token = crypto.randomBytes(20).toString('hex');
const user = await prisma.user.findFirst({
where: {
email: email,
},
});
if (!user) {
throw new Error('User not found');
}
const createdToken = await prisma.verificationToken.create({
data: {
identifier: IDENTIFIER,
token: token,
expires: new Date(Date.now() + ONE_HOUR),
user: {
connect: {
id: user.id,
},
},
},
});
if (!createdToken) {
throw new Error(`Failed to create the verification token`);
}
return sendConfirmationEmail({ userId: user.id });
};

View File

@ -0,0 +1,21 @@
import { prisma } from '@documenso/prisma';
import { USER_SIGNUP_VERIFICATION_TOKEN_IDENTIFIER } from '../../constants/email';
export type getMostRecentEmailVerificationTokenOptions = {
userId: number;
};
export const getMostRecentEmailVerificationToken = async ({
userId,
}: getMostRecentEmailVerificationTokenOptions) => {
return await prisma.verificationToken.findFirst({
where: {
userId,
identifier: USER_SIGNUP_VERIFICATION_TOKEN_IDENTIFIER,
},
orderBy: {
createdAt: 'desc',
},
});
};

View File

@ -1,18 +0,0 @@
import { prisma } from '@documenso/prisma';
export type GetMostRecentVerificationTokenByUserIdOptions = {
userId: number;
};
export const getMostRecentVerificationTokenByUserId = async ({
userId,
}: GetMostRecentVerificationTokenByUserIdOptions) => {
return await prisma.verificationToken.findFirst({
where: {
userId,
},
orderBy: {
createdAt: 'desc',
},
});
};

View File

@ -1,13 +1,31 @@
import { prisma } from '@documenso/prisma';
import { AppError, AppErrorCode } from '../../errors/app-error';
export interface GetUserByIdOptions {
id: number;
}
export const getUserById = async ({ id }: GetUserByIdOptions) => {
return await prisma.user.findFirstOrThrow({
const user = await prisma.user.findFirst({
where: {
id,
},
select: {
id: true,
name: true,
email: true,
emailVerified: true,
roles: true,
disabled: true,
twoFactorEnabled: true,
signature: true,
},
});
if (!user) {
throw new AppError(AppErrorCode.NOT_FOUND);
}
return user;
};

View File

@ -24,7 +24,14 @@ export const resetPassword = async ({ token, password, requestMetadata }: ResetP
token,
},
include: {
user: true,
user: {
select: {
id: true,
email: true,
name: true,
password: true,
},
},
},
});

View File

@ -3,11 +3,10 @@ import { DateTime } from 'luxon';
import { prisma } from '@documenso/prisma';
import { USER_SIGNUP_VERIFICATION_TOKEN_IDENTIFIER } from '../../constants/email';
import { ONE_HOUR } from '../../constants/time';
import { sendConfirmationEmail } from '../auth/send-confirmation-email';
import { getMostRecentVerificationTokenByUserId } from './get-most-recent-verification-token-by-user-id';
const IDENTIFIER = 'confirmation-email';
import { getMostRecentEmailVerificationToken } from './get-most-recent-email-verification-token';
type SendConfirmationTokenOptions = { email: string; force?: boolean };
@ -31,7 +30,7 @@ export const sendConfirmationToken = async ({
throw new Error('Email verified');
}
const mostRecentToken = await getMostRecentVerificationTokenByUserId({ userId: user.id });
const mostRecentToken = await getMostRecentEmailVerificationToken({ userId: user.id });
// If we've sent a token in the last 5 minutes, don't send another one
if (
@ -44,7 +43,7 @@ export const sendConfirmationToken = async ({
const createdToken = await prisma.verificationToken.create({
data: {
identifier: IDENTIFIER,
identifier: USER_SIGNUP_VERIFICATION_TOKEN_IDENTIFIER,
token: token,
expires: new Date(Date.now() + ONE_HOUR),
user: {

View File

@ -0,0 +1,72 @@
import { plainClient } from '@documenso/lib/plain/client';
import { prisma } from '@documenso/prisma';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { buildOrganisationWhereQuery } from '../../utils/organisations';
import { getTeamById } from '../team/get-team';
type SubmitSupportTicketOptions = {
subject: string;
message: string;
userId: number;
organisationId: string;
teamId?: number | null;
};
export const submitSupportTicket = async ({
subject,
message,
userId,
organisationId,
teamId,
}: SubmitSupportTicketOptions) => {
const user = await prisma.user.findFirst({
where: {
id: userId,
},
});
if (!user) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'User not found',
});
}
const organisation = await prisma.organisation.findFirst({
where: buildOrganisationWhereQuery({
organisationId,
userId,
}),
});
if (!organisation) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Organisation not found',
});
}
const team = teamId
? await getTeamById({
userId,
teamId,
})
: null;
const customMessage = `
Organisation: ${organisation.name} (${organisation.id})
Team: ${team ? `${team.name} (${team.id})` : 'No team provided'}
${message}`;
const res = await plainClient.createThread({
title: subject,
customerIdentifier: { emailAddress: user.email },
components: [{ componentText: { text: customMessage } }],
});
if (res.error) {
throw new Error(res.error.message);
}
return res;
};

View File

@ -24,7 +24,7 @@ export const updateProfile = async ({
},
});
return await prisma.$transaction(async (tx) => {
await prisma.$transaction(async (tx) => {
await tx.userSecurityAuditLog.create({
data: {
userId,
@ -34,7 +34,7 @@ export const updateProfile = async ({
},
});
return await tx.user.update({
await tx.user.update({
where: {
id: userId,
},

View File

@ -2,7 +2,10 @@ import { DateTime } from 'luxon';
import { prisma } from '@documenso/prisma';
import { EMAIL_VERIFICATION_STATE } from '../../constants/email';
import {
EMAIL_VERIFICATION_STATE,
USER_SIGNUP_VERIFICATION_TOKEN_IDENTIFIER,
} from '../../constants/email';
import { jobsClient } from '../../jobs/client';
export type VerifyEmailProps = {
@ -12,10 +15,17 @@ export type VerifyEmailProps = {
export const verifyEmail = async ({ token }: VerifyEmailProps) => {
const verificationToken = await prisma.verificationToken.findFirst({
include: {
user: true,
user: {
select: {
id: true,
email: true,
name: true,
},
},
},
where: {
token,
identifier: USER_SIGNUP_VERIFICATION_TOKEN_IDENTIFIER,
},
});

File diff suppressed because it is too large Load Diff

View File

@ -167,6 +167,10 @@ msgstr "{0} Recipient(s)"
msgid "{0} Teams"
msgstr "{0} Teams"
#: apps/remix/app/components/tables/internal-audit-log-table.tsx
msgid "{browserInfo} on {os}"
msgstr "{browserInfo} on {os}"
#: apps/remix/app/components/general/document-signing/document-signing-text-field.tsx
msgid "{charactersRemaining, plural, one {1 character remaining} other {{charactersRemaining} characters remaining}}"
msgstr "{charactersRemaining, plural, one {1 character remaining} other {{charactersRemaining} characters remaining}}"
@ -368,6 +372,10 @@ msgstr "{teamName} has invited you to {0}<0/>\"{documentName}\""
msgid "{teamName} has invited you to {action} {documentName}"
msgstr "{teamName} has invited you to {action} {documentName}"
#: apps/remix/app/components/tables/internal-audit-log-table.tsx
msgid "{userAgent}"
msgstr "{userAgent}"
#: packages/lib/utils/document-audit-logs.ts
msgid "{userName} approved the document"
msgstr "{userName} approved the document"
@ -745,7 +753,6 @@ msgstr "Acknowledgment"
#: apps/remix/app/components/tables/settings-security-activity-table.tsx
#: apps/remix/app/components/tables/settings-public-profile-templates-table.tsx
#: apps/remix/app/components/tables/internal-audit-log-table.tsx
#: apps/remix/app/components/tables/documents-table-action-dropdown.tsx
#: apps/remix/app/components/tables/document-logs-table.tsx
#: apps/remix/app/components/general/document/document-page-view-dropdown.tsx
@ -1486,10 +1493,14 @@ msgstr "At least one signature type must be enabled"
msgid "Attempts sealing the document again, useful for after a code change has occurred to resolve an erroneous document."
msgstr "Attempts sealing the document again, useful for after a code change has occurred to resolve an erroneous document."
#: apps/remix/app/components/general/document/document-page-view-dropdown.tsx
#: apps/remix/app/routes/_internal+/[__htmltopdf]+/audit-log.tsx
msgid "Audit Log"
msgstr "Audit Log"
#: apps/remix/app/components/general/document/document-page-view-dropdown.tsx
msgid "Audit Logs"
msgstr "Audit Logs"
#: apps/remix/app/routes/_internal+/[__htmltopdf]+/certificate.tsx
msgid "Authentication Level"
msgstr "Authentication Level"
@ -1591,7 +1602,6 @@ msgid "Branding preferences updated"
msgstr "Branding preferences updated"
#: apps/remix/app/components/tables/settings-security-activity-table.tsx
#: apps/remix/app/components/tables/internal-audit-log-table.tsx
msgid "Browser"
msgstr "Browser"
@ -2109,6 +2119,10 @@ msgstr "Controls the formatting of the message that will be sent when inviting a
msgid "Controls the language for the document, including the language to be used for email notifications, and the final certificate that is generated and attached to the document."
msgstr "Controls the language for the document, including the language to be used for email notifications, and the final certificate that is generated and attached to the document."
#: apps/remix/app/components/forms/document-preferences-form.tsx
msgid "Controls whether the audit logs will be included in the document when it is downloaded. The audit logs can still be downloaded from the logs page separately."
msgstr "Controls whether the audit logs will be included in the document when it is downloaded. The audit logs can still be downloaded from the logs page separately."
#: apps/remix/app/components/forms/document-preferences-form.tsx
msgid "Controls whether the signing certificate will be included in the document when it is downloaded. The signing certificate can still be downloaded from the logs page separately."
msgstr "Controls whether the signing certificate will be included in the document when it is downloaded. The signing certificate can still be downloaded from the logs page separately."
@ -3140,6 +3154,7 @@ msgstr "Drag & drop your PDF here."
msgid "Drag and drop or click to upload"
msgstr "Drag and drop or click to upload"
#: apps/remix/app/components/general/template/template-drop-zone-wrapper.tsx
#: apps/remix/app/components/general/document/document-drop-zone-wrapper.tsx
msgid "Drag and drop your PDF file here"
msgstr "Drag and drop your PDF file here"
@ -3668,6 +3683,7 @@ msgstr "Fields"
msgid "Fields updated"
msgstr "Fields updated"
#: apps/remix/app/components/general/template/template-drop-zone-wrapper.tsx
#: apps/remix/app/components/general/document/document-upload.tsx
#: apps/remix/app/components/general/document/document-drop-zone-wrapper.tsx
#: apps/remix/app/components/embed/authoring/configure-document-upload.tsx
@ -4042,6 +4058,10 @@ msgstr "Inbox"
msgid "Inbox documents"
msgstr "Inbox documents"
#: apps/remix/app/components/forms/document-preferences-form.tsx
msgid "Include the Audit Logs in the Document"
msgstr "Include the Audit Logs in the Document"
#: apps/remix/app/components/forms/document-preferences-form.tsx
msgid "Include the Signing Certificate in the Document"
msgstr "Include the Signing Certificate in the Document"
@ -4064,6 +4084,7 @@ msgstr "Inherit authentication method"
#: apps/remix/app/components/forms/document-preferences-form.tsx
#: apps/remix/app/components/forms/document-preferences-form.tsx
#: apps/remix/app/components/forms/document-preferences-form.tsx
#: apps/remix/app/components/forms/document-preferences-form.tsx
#: apps/remix/app/components/forms/branding-preferences-form.tsx
msgid "Inherit from organisation"
msgstr "Inherit from organisation"
@ -4599,6 +4620,10 @@ msgstr "Multiple access methods can be selected."
msgid "My Folder"
msgstr "My Folder"
#: apps/remix/app/components/tables/internal-audit-log-table.tsx
msgid "N/A"
msgstr "N/A"
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.email-domains.$id.tsx
#: apps/remix/app/routes/_authenticated+/admin+/users.$id.tsx
#: apps/remix/app/components/tables/settings-security-passkey-table.tsx
@ -4679,6 +4704,7 @@ msgstr "Next"
msgid "Next field"
msgstr "Next field"
#: apps/remix/app/components/forms/document-preferences-form.tsx
#: apps/remix/app/components/forms/document-preferences-form.tsx
#: apps/remix/app/components/forms/document-preferences-form.tsx
#: apps/remix/app/components/forms/branding-preferences-form.tsx
@ -5338,6 +5364,7 @@ msgstr "Please try a different domain."
msgid "Please try again and make sure you enter the correct email address."
msgstr "Please try again and make sure you enter the correct email address."
#: apps/remix/app/components/general/template/template-drop-zone-wrapper.tsx
#: apps/remix/app/components/dialogs/template-create-dialog.tsx
msgid "Please try again later."
msgstr "Please try again later."
@ -6390,6 +6417,7 @@ msgstr "Some signers have not been assigned a signature field. Please assign at
#: apps/remix/app/components/general/share-document-download-button.tsx
#: apps/remix/app/components/general/billing-plans.tsx
#: apps/remix/app/components/general/billing-plans.tsx
#: apps/remix/app/components/general/template/template-drop-zone-wrapper.tsx
#: apps/remix/app/components/general/teams/team-email-usage.tsx
#: apps/remix/app/components/general/teams/team-email-dropdown.tsx
#: apps/remix/app/components/general/organisations/organisation-invitations.tsx
@ -6827,6 +6855,10 @@ msgstr "Template title"
msgid "Template updated successfully"
msgstr "Template updated successfully"
#: apps/remix/app/components/general/template/template-drop-zone-wrapper.tsx
msgid "Template uploaded"
msgstr "Template uploaded"
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/templates._index.tsx
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/templates.$id._index.tsx
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.public-profile.tsx
@ -7445,7 +7477,6 @@ msgstr "This will remove all emails associated with this email domain"
msgid "This will sign you out of all other devices. You will need to sign in again on those devices to continue using your account."
msgstr "This will sign you out of all other devices. You will need to sign in again on those devices to continue using your account."
#: apps/remix/app/components/tables/internal-audit-log-table.tsx
#: apps/remix/app/components/tables/document-logs-table.tsx
msgid "Time"
msgstr "Time"
@ -7479,6 +7510,10 @@ msgstr "Title cannot be empty"
msgid "To accept this invitation you must create an account."
msgstr "To accept this invitation you must create an account."
#: apps/remix/app/components/dialogs/team-member-create-dialog.tsx
msgid "To be able to add members to a team, you must first add them to the organisation. For more information, please see the <0>documentation</0>."
msgstr "To be able to add members to a team, you must first add them to the organisation. For more information, please see the <0>documentation</0>."
#: apps/remix/app/components/dialogs/team-email-update-dialog.tsx
msgid "To change the email you must remove and add a new email address."
msgstr "To change the email you must remove and add a new email address."
@ -7928,6 +7963,10 @@ msgstr "Upload Document"
msgid "Upload Signature"
msgstr "Upload Signature"
#: apps/remix/app/components/general/template/template-drop-zone-wrapper.tsx
msgid "Upload Template"
msgstr "Upload Template"
#: packages/ui/primitives/document-upload.tsx
#: packages/ui/primitives/document-dropzone.tsx
msgid "Upload Template Document"
@ -7958,6 +7997,10 @@ msgstr "Uploaded file not an allowed file type"
msgid "Uploading document..."
msgstr "Uploading document..."
#: apps/remix/app/components/general/template/template-drop-zone-wrapper.tsx
msgid "Uploading template..."
msgstr "Uploading template..."
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/templates.$id._index.tsx
msgid "Use"
msgstr "Use"
@ -7985,6 +8028,10 @@ msgstr "Use your passkey for authentication"
msgid "User"
msgstr "User"
#: apps/remix/app/components/tables/internal-audit-log-table.tsx
msgid "User Agent"
msgstr "User Agent"
#: apps/remix/app/components/forms/password.tsx
msgid "User has no password."
msgstr "User has no password."
@ -8062,10 +8109,6 @@ msgstr "Verify your email to upload documents."
msgid "Verify your team email address"
msgstr "Verify your team email address"
#: apps/remix/app/routes/_internal+/[__htmltopdf]+/audit-log.tsx
msgid "Version History"
msgstr "Version History"
#: packages/ui/primitives/document-flow/field-items-advanced-settings/checkbox-field.tsx
msgid "Vertical"
msgstr "Vertical"
@ -8616,6 +8659,7 @@ msgstr "Write a description to display on your public profile"
msgid "Yearly"
msgstr "Yearly"
#: apps/remix/app/components/forms/document-preferences-form.tsx
#: apps/remix/app/components/forms/document-preferences-form.tsx
#: apps/remix/app/components/forms/document-preferences-form.tsx
#: apps/remix/app/components/forms/branding-preferences-form.tsx
@ -9224,6 +9268,10 @@ msgstr "Your team has been successfully deleted."
msgid "Your team has been successfully updated."
msgstr "Your team has been successfully updated."
#: apps/remix/app/components/general/template/template-drop-zone-wrapper.tsx
msgid "Your template failed to upload."
msgstr "Your template failed to upload."
#: apps/remix/app/routes/embed+/v1+/authoring_.completed.create.tsx
msgid "Your template has been created successfully"
msgstr "Your template has been created successfully"
@ -9236,6 +9284,10 @@ msgstr "Your template has been duplicated successfully."
msgid "Your template has been successfully deleted."
msgstr "Your template has been successfully deleted."
#: apps/remix/app/components/general/template/template-drop-zone-wrapper.tsx
msgid "Your template has been uploaded successfully. You will be redirected to the template page."
msgstr "Your template has been uploaded successfully. You will be redirected to the template page."
#: apps/remix/app/components/dialogs/template-duplicate-dialog.tsx
msgid "Your template will be duplicated."
msgstr "Your template will be duplicated."

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -1,4 +1,4 @@
import type { z } from 'zod';
import { z } from 'zod';
import OrganisationClaimSchema from '@documenso/prisma/generated/zod/modelSchema/OrganisationClaimSchema';
import { OrganisationSchema } from '@documenso/prisma/generated/zod/modelSchema/OrganisationSchema';
@ -43,3 +43,19 @@ export const ZOrganisationLiteSchema = OrganisationSchema.pick({
* A version of the organisation response schema when returning multiple organisations at once from a single API endpoint.
*/
export const ZOrganisationManySchema = ZOrganisationLiteSchema;
export const ZOrganisationAccountLinkMetadataSchema = z.object({
type: z.enum(['link', 'create']),
userId: z.number(),
organisationId: z.string(),
oauthConfig: z.object({
providerAccountId: z.string(),
accessToken: z.string(),
expiresAt: z.number(),
idToken: z.string(),
}),
});
export type TOrganisationAccountLinkMetadata = z.infer<
typeof ZOrganisationAccountLinkMetadataSchema
>;

View File

@ -28,6 +28,8 @@ export const ZClaimFlagsSchema = z.object({
embedSigningWhiteLabel: z.boolean().optional(),
cfr21: z.boolean().optional(),
authenticationPortal: z.boolean().optional(),
});
export type TClaimFlags = z.infer<typeof ZClaimFlagsSchema>;
@ -76,6 +78,10 @@ export const SUBSCRIPTION_CLAIM_FEATURE_FLAGS: Record<
key: 'cfr21',
label: '21 CFR',
},
authenticationPortal: {
key: 'authenticationPortal',
label: 'Authentication portal',
},
};
export enum INTERNAL_CLAIM_ID {
@ -134,7 +140,7 @@ export const internalClaims: InternalClaims = {
unlimitedDocuments: true,
allowCustomBranding: true,
hidePoweredBy: true,
emailDomains: true,
emailDomains: false,
embedAuthoring: false,
embedAuthoringWhiteLabel: true,
embedSigning: false,
@ -157,6 +163,7 @@ export const internalClaims: InternalClaims = {
embedSigning: true,
embedSigningWhiteLabel: true,
cfr21: true,
authenticationPortal: true,
},
},
[INTERNAL_CLAIM_ID.EARLY_ADOPTER]: {

Some files were not shown because too many files have changed in this diff Show More