Merge branch 'main' into feat/change-radio-direction

This commit is contained in:
Ephraim Duncan
2025-10-07 20:32:45 +00:00
committed by GitHub
335 changed files with 15880 additions and 3438 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,
@ -980,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>;
@ -310,12 +310,11 @@ export const ZGenerateDocumentFromTemplateMutationSchema = z.object({
)
.refine(
(schema) => {
const emails = schema.map((signer) => signer.email.toLowerCase());
const ids = schema.map((signer) => signer.id);
return new Set(emails).size === emails.length && new Set(ids).size === ids.length;
return new Set(ids).size === ids.length;
},
{ message: 'Recipient IDs and emails must be unique' },
{ message: 'Recipient IDs must be unique' },
),
meta: z
.object({
@ -637,5 +636,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

@ -0,0 +1,435 @@
import { expect, test } from '@playwright/test';
import { nanoid } from '@documenso/lib/universal/id';
import { seedOrganisationMembers } from '@documenso/prisma/seed/organisations';
import { seedUser } from '@documenso/prisma/seed/users';
import { apiSignin } from '../../fixtures/authentication';
test('[ADMIN]: promote member to owner', async ({ page }) => {
// Create an admin user who can access the admin panel
const { user: adminUser } = await seedUser({
isAdmin: true,
});
// Create an organisation owner
const { user: ownerUser, organisation } = await seedUser({
isPersonalOrganisation: false,
});
// Create organisation members with different roles
const memberEmail = `member-${nanoid()}@test.documenso.com`;
const managerEmail = `manager-${nanoid()}@test.documenso.com`;
const adminMemberEmail = `admin-member-${nanoid()}@test.documenso.com`;
const [memberUser, managerUser, adminMemberUser] = await seedOrganisationMembers({
members: [
{
email: memberEmail,
name: 'Test Member',
organisationRole: 'MEMBER',
},
{
email: managerEmail,
name: 'Test Manager',
organisationRole: 'MANAGER',
},
{
email: adminMemberEmail,
name: 'Test Admin Member',
organisationRole: 'ADMIN',
},
],
organisationId: organisation.id,
});
// Sign in as admin and navigate to the organisation admin page
await apiSignin({
page,
email: adminUser.email,
redirectPath: `/admin/organisations/${organisation.id}`,
});
// Verify we're on the admin organisation page
await expect(page.getByText(`Manage organisation`)).toBeVisible();
await expect(page.getByLabel('Organisation Name')).toHaveValue(organisation.name);
// Check that the organisation members table shows the correct roles
const ownerRow = page.getByRole('row', { name: ownerUser.email });
await expect(ownerRow).toBeVisible();
await expect(ownerRow.getByRole('status').filter({ hasText: 'Owner' })).toBeVisible();
await expect(page.getByRole('row', { name: memberUser.email })).toBeVisible();
await expect(page.getByRole('row', { name: adminMemberUser.email })).toBeVisible();
await expect(page.getByRole('row', { name: managerUser.email })).toBeVisible();
// Test promoting a MEMBER to owner
const memberRow = page.getByRole('row', { name: memberUser.email });
// Find and click the "Promote to owner" button for the member
const promoteButton = memberRow.getByRole('button', { name: 'Promote to owner' });
await expect(promoteButton).toBeVisible();
await expect(promoteButton).not.toBeDisabled();
await promoteButton.click();
// Verify success toast appears
await expect(page.getByText('Member promoted to owner successfully').first()).toBeVisible();
// Reload the page to see the changes
await page.reload();
// Verify that the member is now the owner
const newOwnerRow = page.getByRole('row', { name: memberUser.email });
await expect(newOwnerRow.getByRole('status').filter({ hasText: 'Owner' })).toBeVisible();
// Verify that the previous owner is no longer marked as owner
const previousOwnerRow = page.getByRole('row', { name: ownerUser.email });
await expect(previousOwnerRow.getByRole('status').filter({ hasText: 'Owner' })).not.toBeVisible();
// Verify that the promote button is now disabled for the new owner
const newOwnerPromoteButton = newOwnerRow.getByRole('button', { name: 'Promote to owner' });
await expect(newOwnerPromoteButton).toBeDisabled();
// Test that we can't promote the current owner (button should be disabled)
await expect(newOwnerPromoteButton).toHaveAttribute('disabled');
});
test('[ADMIN]: promote manager to owner', async ({ page }) => {
// Create an admin user who can access the admin panel
const { user: adminUser } = await seedUser({
isAdmin: true,
});
// Create an organisation with owner and manager
const { organisation } = await seedUser({
isPersonalOrganisation: false,
});
const managerEmail = `manager-${nanoid()}@test.documenso.com`;
const [managerUser] = await seedOrganisationMembers({
members: [
{
email: managerEmail,
name: 'Test Manager',
organisationRole: 'MANAGER',
},
],
organisationId: organisation.id,
});
// Sign in as admin
await apiSignin({
page,
email: adminUser.email,
redirectPath: `/admin/organisations/${organisation.id}`,
});
// Promote the manager to owner
const managerRow = page.getByRole('row', { name: managerUser.email });
const promoteButton = managerRow.getByRole('button', { name: 'Promote to owner' });
await promoteButton.click();
await expect(page.getByText('Member promoted to owner successfully').first()).toBeVisible();
// Reload and verify the change
await page.reload();
await expect(managerRow.getByRole('status').filter({ hasText: 'Owner' })).toBeVisible();
});
test('[ADMIN]: promote admin member to owner', async ({ page }) => {
// Create an admin user who can access the admin panel
const { user: adminUser } = await seedUser({
isAdmin: true,
});
// Create an organisation with owner and admin member
const { organisation } = await seedUser({
isPersonalOrganisation: false,
});
const adminMemberEmail = `admin-member-${nanoid()}@test.documenso.com`;
const [adminMemberUser] = await seedOrganisationMembers({
members: [
{
email: adminMemberEmail,
name: 'Test Admin Member',
organisationRole: 'ADMIN',
},
],
organisationId: organisation.id,
});
// Sign in as admin
await apiSignin({
page,
email: adminUser.email,
redirectPath: `/admin/organisations/${organisation.id}`,
});
// Promote the admin member to owner
const adminMemberRow = page.getByRole('row', { name: adminMemberUser.email });
const promoteButton = adminMemberRow.getByRole('button', { name: 'Promote to owner' });
await promoteButton.click();
await expect(page.getByText('Member promoted to owner successfully').first()).toBeVisible({
timeout: 10_000,
});
// Reload and verify the change
await page.reload();
await expect(adminMemberRow.getByRole('status').filter({ hasText: 'Owner' })).toBeVisible();
});
test('[ADMIN]: cannot promote non-existent user', async ({ page }) => {
// Create an admin user
const { user: adminUser } = await seedUser({
isAdmin: true,
});
// Create an organisation
const { organisation } = await seedUser({
isPersonalOrganisation: false,
});
// Sign in as admin
await apiSignin({
page,
email: adminUser.email,
redirectPath: `/admin/organisations/${organisation.id}`,
});
// Try to manually call the API with invalid data - this should be handled by the UI validation
// In a real scenario, the promote button wouldn't be available for non-existent users
// But we can test that the API properly handles invalid requests
// For now, just verify that non-existent users don't show up in the members table
await expect(page.getByRole('row', { name: 'Non Existent User' })).not.toBeVisible();
});
test('[ADMIN]: verify role hierarchy after promotion', async ({ page }) => {
// Create an admin user
const { user: adminUser } = await seedUser({
isAdmin: true,
});
// Create organisation with a member
const { organisation } = await seedUser({
isPersonalOrganisation: false,
});
const memberEmail = `member-${nanoid()}@test.documenso.com`;
const [memberUser] = await seedOrganisationMembers({
members: [
{
email: memberEmail,
name: 'Test Member',
organisationRole: 'MEMBER',
},
],
organisationId: organisation.id,
});
// Sign in as admin
await apiSignin({
page,
email: adminUser.email,
redirectPath: `/admin/organisations/${organisation.id}`,
});
// Before promotion - verify member has MEMBER role
let memberRow = page.getByRole('row', { name: memberUser.email });
await expect(memberRow).toBeVisible();
await expect(memberRow.getByRole('status').filter({ hasText: 'Owner' })).not.toBeVisible();
// Promote member to owner
const promoteButton = memberRow.getByRole('button', { name: 'Promote to owner' });
await promoteButton.click();
await expect(page.getByText('Member promoted to owner successfully').first()).toBeVisible({
timeout: 10_000,
});
// Reload page to see updated state
await page.reload();
// After promotion - verify member is now owner and has admin permissions
memberRow = page.getByRole('row', { name: memberUser.email });
await expect(memberRow.getByRole('status').filter({ hasText: 'Owner' })).toBeVisible();
// Verify the promote button is now disabled for the new owner
const newOwnerPromoteButton = memberRow.getByRole('button', { name: 'Promote to owner' });
await expect(newOwnerPromoteButton).toBeDisabled();
// Sign in as the newly promoted user to verify they have owner permissions
await apiSignin({
page,
email: memberUser.email,
redirectPath: `/o/${organisation.url}/settings/general`,
});
// Verify they can access organisation settings (owner permission)
await expect(page.getByText('Organisation Settings')).toBeVisible();
await expect(page.getByRole('button', { name: 'Delete' })).toBeVisible();
});
test('[ADMIN]: error handling for invalid organisation', async ({ page }) => {
// Create an admin user
const { user: adminUser } = await seedUser({
isAdmin: true,
});
// Sign in as admin and try to access non-existent organisation
await apiSignin({
page,
email: adminUser.email,
redirectPath: `/admin/organisations/non-existent-org-id`,
});
// Should show 404 error
await expect(page.getByRole('heading', { name: 'Organisation not found' })).toBeVisible({
timeout: 10_000,
});
});
test('[ADMIN]: multiple promotions in sequence', async ({ page }) => {
// Create an admin user
const { user: adminUser } = await seedUser({
isAdmin: true,
});
// Create organisation with multiple members
const { organisation } = await seedUser({
isPersonalOrganisation: false,
});
const member1Email = `member1-${nanoid()}@test.documenso.com`;
const member2Email = `member2-${nanoid()}@test.documenso.com`;
const [member1User, member2User] = await seedOrganisationMembers({
members: [
{
email: member1Email,
name: 'Test Member 1',
organisationRole: 'MEMBER',
},
{
email: member2Email,
name: 'Test Member 2',
organisationRole: 'MANAGER',
},
],
organisationId: organisation.id,
});
// Sign in as admin
await apiSignin({
page,
email: adminUser.email,
redirectPath: `/admin/organisations/${organisation.id}`,
});
// First promotion: Member 1 becomes owner
let member1Row = page.getByRole('row', { name: member1User.email });
let promoteButton1 = member1Row.getByRole('button', { name: 'Promote to owner' });
await promoteButton1.click();
await expect(page.getByText('Member promoted to owner successfully').first()).toBeVisible({
timeout: 10_000,
});
await page.reload();
// Verify Member 1 is now owner and button is disabled
member1Row = page.getByRole('row', { name: member1User.email });
await expect(member1Row.getByRole('status').filter({ hasText: 'Owner' })).toBeVisible();
promoteButton1 = member1Row.getByRole('button', { name: 'Promote to owner' });
await expect(promoteButton1).toBeDisabled();
// Second promotion: Member 2 becomes the new owner
const member2Row = page.getByRole('row', { name: member2User.email });
const promoteButton2 = member2Row.getByRole('button', { name: 'Promote to owner' });
await expect(promoteButton2).not.toBeDisabled();
await promoteButton2.click();
await expect(page.getByText('Member promoted to owner successfully').first()).toBeVisible({
timeout: 10_000,
});
await page.reload();
// Verify Member 2 is now owner and Member 1 is no longer owner
await expect(member2Row.getByRole('status').filter({ hasText: 'Owner' })).toBeVisible();
await expect(member1Row.getByRole('status').filter({ hasText: 'Owner' })).not.toBeVisible();
// Verify Member 1's promote button is now enabled again
const newPromoteButton1 = member1Row.getByRole('button', { name: 'Promote to owner' });
await expect(newPromoteButton1).not.toBeDisabled();
});
test('[ADMIN]: verify organisation access after ownership change', async ({ page }) => {
// Create admin user
const { user: adminUser } = await seedUser({
isAdmin: true,
});
// Create organisation with owner and member
const { user: originalOwner, organisation } = await seedUser({
isPersonalOrganisation: false,
});
const memberEmail = `member-${nanoid()}@test.documenso.com`;
const [memberUser] = await seedOrganisationMembers({
members: [
{
email: memberEmail,
name: 'Test Member',
organisationRole: 'MEMBER',
},
],
organisationId: organisation.id,
});
// Sign in as admin and promote member to owner
await apiSignin({
page,
email: adminUser.email,
redirectPath: `/admin/organisations/${organisation.id}`,
});
const memberRow = page.getByRole('row', { name: memberUser.email });
const promoteButton = memberRow.getByRole('button', { name: 'Promote to owner' });
await promoteButton.click();
await expect(page.getByText('Member promoted to owner successfully').first()).toBeVisible({
timeout: 10_000,
});
// Test that the new owner can access organisation settings
await apiSignin({
page,
email: memberUser.email,
redirectPath: `/o/${organisation.url}/settings/general`,
});
// Should be able to access organisation settings
await expect(page.getByText('Organisation Settings')).toBeVisible();
await expect(page.getByLabel('Organisation Name*')).toBeVisible();
await expect(page.getByRole('button', { name: 'Update organisation' })).toBeVisible();
// Should have delete permissions
await expect(page.getByRole('button', { name: 'Delete' })).toBeVisible();
// Test that the original owner no longer has owner-level access
await apiSignin({
page,
email: originalOwner.email,
redirectPath: `/o/${organisation.url}/settings/general`,
});
// Should still be able to access settings (as they should now be an admin)
await expect(page.getByText('Organisation Settings')).toBeVisible();
});

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('body').click({ position: { x: 0, y: 0 } });
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').first().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').first().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').first().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').first().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').first().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('body').click({ position: { x: 0, y: 0 } });
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,179 @@
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('body').click({ position: { x: 0, y: 0 } });
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').first().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);
const firstRecipient = retrievedRecipients.find(
(r) => r.email === 'recipient1@documenso.com',
);
const secondRecipient = retrievedRecipients.find(
(r) => r.email === 'recipient2@documenso.com',
);
const thirdRecipient = retrievedRecipients.find(
(r) => r.email === 'recipient3@documenso.com',
);
expect(firstRecipient?.signingOrder).toBe(2);
expect(secondRecipient?.signingOrder).toBe(3);
expect(thirdRecipient?.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('body').click({ position: { x: 0, y: 0 } });
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

@ -0,0 +1,56 @@
import { expect, test } from '@playwright/test';
import { seedBlankDocument } from '@documenso/prisma/seed/documents';
import { seedUser } from '@documenso/prisma/seed/users';
import { apiSignin } from '../fixtures/authentication';
test('[DOCUMENT_FLOW]: Simple duplicate recipients test', async ({ page }) => {
const { user, team } = await seedUser();
const document = await seedBlankDocument(user, team.id);
await apiSignin({
page,
email: user.email,
redirectPath: `/t/${team.url}/documents/${document.id}/edit`,
});
// Step 1: Settings - Continue with defaults
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('heading', { name: 'Add Signers' })).toBeVisible();
// Step 2: Add duplicate recipients
await page.getByPlaceholder('Email').fill('duplicate@example.com');
await page.getByPlaceholder('Name').fill('Duplicate 1');
await page.getByRole('button', { name: 'Add Signer' }).click();
await page.getByLabel('Email').nth(1).fill('duplicate@example.com');
await page.getByLabel('Name').nth(1).fill('Duplicate 2');
// Continue to fields
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
// Step 3: Add fields
await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({ position: { x: 100, y: 100 } });
await page.getByRole('combobox').first().click();
// Switch to second duplicate and add field
await page.getByText('Duplicate 2 (duplicate@example.com)').first().click();
await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({ position: { x: 200, y: 100 } });
// Continue to send
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('heading', { name: 'Distribute Document' })).toBeVisible();
// Send document
await page.waitForTimeout(2500);
await page.getByRole('button', { name: 'Send' }).click();
await page.waitForURL(new RegExp(`/t/${team.url}/documents/\\d+`));
await expect(page.getByRole('link', { name: document.title })).toBeVisible();
});

View File

@ -0,0 +1,355 @@
import { type Page, expect, test } from '@playwright/test';
import type { Document, Team } from '@prisma/client';
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
import { prisma } from '@documenso/prisma';
import { seedBlankDocument } from '@documenso/prisma/seed/documents';
import { seedUser } from '@documenso/prisma/seed/users';
import { apiSignin } from '../fixtures/authentication';
import { signSignaturePad } from '../fixtures/signature';
/**
* Test helper to complete the document creation flow with duplicate recipients
*/
const completeDocumentFlowWithDuplicateRecipients = async (options: {
page: Page;
team: Team;
document: Document;
}) => {
const { page, team, document } = options;
// Step 1: Settings - Continue with defaults
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('heading', { name: 'Add Signers' })).toBeVisible();
// Step 2: Add duplicate recipients
await page.getByPlaceholder('Email').fill('duplicate@example.com');
await page.getByPlaceholder('Name').fill('Duplicate Recipient 1');
// Add second signer with same email
await page.getByRole('button', { name: 'Add Signer' }).click();
await page.getByLabel('Email').nth(1).fill('duplicate@example.com');
await page.getByLabel('Name').nth(1).fill('Duplicate Recipient 2');
// Add third signer with different email for comparison
await page.getByRole('button', { name: 'Add Signer' }).click();
await page.getByLabel('Email').nth(2).fill('unique@example.com');
await page.getByLabel('Name').nth(2).fill('Unique Recipient');
// Continue to fields
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
// Step 3: Add fields for each recipient
// Add signature field for first duplicate recipient
await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({ position: { x: 100, y: 100 } });
await page.getByText('Duplicate Recipient 1 (duplicate@example.com)').click();
// Switch to second duplicate recipient and add their field
await page.getByText('Duplicate Recipient 2 (duplicate@example.com)').click();
await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({ position: { x: 200, y: 100 } });
await page.getByText('Duplicate Recipient 2 (duplicate@example.com)').click();
// Switch to unique recipient and add their field
await page.getByText('Unique Recipient (unique@example.com)').click();
await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({ position: { x: 300, y: 100 } });
// Continue to subject
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('heading', { name: 'Distribute Document' })).toBeVisible();
// Step 4: Complete with subject and send
await page.waitForTimeout(2500);
await page.getByRole('button', { name: 'Send' }).click();
// Wait for send confirmation
await page.waitForURL(new RegExp(`/t/${team.url}/documents/\\d+`));
await expect(page.getByRole('link', { name: document.title })).toBeVisible();
};
test.describe('[DOCUMENT_FLOW]: Duplicate Recipients', () => {
test('should allow creating document with duplicate recipient emails', async ({ page }) => {
const { user, team } = await seedUser();
const document = await seedBlankDocument(user, team.id);
await apiSignin({
page,
email: user.email,
redirectPath: `/t/${team.url}/documents/${document.id}/edit`,
});
// Complete the flow
await completeDocumentFlowWithDuplicateRecipients({
page,
team,
document,
});
// Verify document was created successfully
await expect(page).toHaveURL(new RegExp(`/t/${team.url}/documents`));
});
test('should allow adding duplicate recipient after saving document initially', async ({
page,
}) => {
const { user, team } = await seedUser();
const document = await seedBlankDocument(user, team.id);
await apiSignin({
page,
email: user.email,
redirectPath: `/t/${team.url}/documents/${document.id}/edit`,
});
// Step 1: Settings - Continue with defaults
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('heading', { name: 'Add Signers' })).toBeVisible();
// Step 2: Add initial recipient
await page.getByPlaceholder('Email').fill('test@example.com');
await page.getByPlaceholder('Name').fill('Test Recipient');
// Continue to fields and add a field
await page.getByRole('button', { name: 'Continue' }).click();
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 } });
// Save the document by going to subject
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('heading', { name: 'Distribute Document' })).toBeVisible();
// Navigate back to signers to add duplicate
await page.getByRole('button', { name: 'Go Back' }).click();
await page.getByRole('button', { name: 'Go Back' }).click();
await expect(page.getByRole('heading', { name: 'Add Signers' })).toBeVisible();
// Add duplicate recipient
await page.getByRole('button', { name: 'Add Signer' }).click();
await page.getByLabel('Email').nth(1).fill('test@example.com');
await page.getByLabel('Name').nth(1).fill('Test Recipient Duplicate');
// Continue and add field for duplicate
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
await page.waitForTimeout(1000);
// Switch to duplicate recipient and add field
await page.getByRole('combobox').first().click();
await page.getByText('Test Recipient Duplicate (test@example.com)').first().click();
await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({ position: { x: 200, y: 100 } });
// Complete the flow
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('heading', { name: 'Distribute Document' })).toBeVisible();
await page.waitForTimeout(2500);
await page.getByRole('button', { name: 'Send' }).click();
await page.waitForURL(new RegExp(`/t/${team.url}/documents/\\d+`));
await expect(page.getByRole('link', { name: document.title })).toBeVisible();
});
test('should isolate fields per recipient token even with duplicate emails', async ({
page,
context,
}) => {
const { user, team } = await seedUser();
const document = await seedBlankDocument(user, team.id);
await apiSignin({
page,
email: user.email,
redirectPath: `/t/${team.url}/documents/${document.id}/edit`,
});
// Complete the document flow
await completeDocumentFlowWithDuplicateRecipients({
page,
team,
document,
});
// Navigate to documents list and get the document
await expect(page).toHaveURL(new RegExp(`/t/${team.url}/documents`));
const recipients = await prisma.recipient.findMany({
where: {
documentId: document.id,
},
});
expect(recipients).toHaveLength(3);
const tokens = recipients.map((r) => r.token);
expect(new Set(tokens).size).toBe(3); // All tokens should be unique
// Test each signing experience in separate browser contexts
for (const recipient of recipients) {
// Navigate to signing URL
await page.goto(`/sign/${recipient.token}`, {
waitUntil: 'networkidle',
});
await page.waitForSelector(PDF_VIEWER_PAGE_SELECTOR);
// Verify only one signature field is visible for this recipient
expect(
await page.locator(`[data-field-type="SIGNATURE"]:not([data-readonly="true"])`).all(),
).toHaveLength(1);
// Verify recipient name is correct
await expect(page.getByLabel('Full Name')).toHaveValue(recipient.name);
// Sign the document
await signSignaturePad(page);
await page
.locator('[data-field-type="SIGNATURE"]:not([data-readonly="true"])')
.first()
.click();
await page.getByRole('button', { name: 'Complete' }).click();
await page.getByRole('button', { name: 'Sign' }).click();
// Verify completion
await page.waitForURL(`/sign/${recipient?.token}/complete`);
await expect(page.getByText('Document Signed')).toBeVisible();
}
});
test('should handle duplicate recipient workflow with different field types', async ({
page,
}) => {
const { user, team } = await seedUser();
const document = await seedBlankDocument(user, team.id);
await apiSignin({
page,
email: user.email,
redirectPath: `/t/${team.url}/documents/${document.id}/edit`,
});
// Step 1: Settings
await page.getByRole('button', { name: 'Continue' }).click();
// Step 2: Add duplicate recipients with different roles
await page.getByPlaceholder('Email').fill('signer@example.com');
await page.getByPlaceholder('Name').fill('Signer Role');
await page.getByRole('button', { name: 'Add Signer' }).click();
await page.getByLabel('Email').nth(1).fill('signer@example.com');
await page.getByLabel('Name').nth(1).fill('Approver Role');
// Change second recipient role if role selector is available
const roleDropdown = page.getByLabel('Role').nth(1);
if (await roleDropdown.isVisible()) {
await roleDropdown.click();
await page.getByText('Approver').click();
}
// Step 3: Add different field types for each duplicate
await page.getByRole('button', { name: 'Continue' }).click();
// Add signature for first recipient
await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({ position: { x: 100, y: 100 } });
// Add name field for second recipient
await page.getByRole('combobox').first().click();
await page.getByText('Approver Role (signer@example.com)').first().click();
await page.getByRole('button', { name: 'Name' }).click();
await page.locator('canvas').click({ position: { x: 200, y: 100 } });
// Add date field for second recipient
await page.getByRole('button', { name: 'Date' }).click();
await page.locator('canvas').click({ position: { x: 200, y: 150 } });
// Complete the document
await page.getByRole('button', { name: 'Continue' }).click();
await page.getByRole('button', { name: 'Send' }).click();
await page.waitForURL(new RegExp(`/t/${team.url}/documents/\\d+`));
await expect(page.getByRole('link', { name: document.title })).toBeVisible();
});
test('should preserve field assignments when editing document with duplicates', async ({
page,
}) => {
const { user, team } = await seedUser();
const document = await seedBlankDocument(user, team.id);
await apiSignin({
page,
email: user.email,
redirectPath: `/t/${team.url}/documents/${document.id}/edit`,
});
// Create document with duplicates and fields
await completeDocumentFlowWithDuplicateRecipients({
page,
team,
document,
});
// Navigate back to edit the document
await page.goto(`/t/${team.url}/documents/${document.id}/edit`);
// Go to fields step
await page.getByRole('button', { name: 'Continue' }).click(); // Settings
await page.getByRole('button', { name: 'Continue' }).click(); // Signers
// Verify fields are assigned to correct recipients
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
// Click on first duplicate recipient
await page.getByText('Duplicate Recipient 1 (duplicate@example.com)').click();
// Verify their field is visible and can be selected
const firstRecipientFields = await page
.locator(`[data-field-type="SIGNATURE"]:not(:disabled)`)
.all();
expect(firstRecipientFields.length).toBeGreaterThan(0);
// Switch to second duplicate recipient
await page.getByText('Duplicate Recipient 2 (duplicate@example.com)').click();
// Verify they have their own field
const secondRecipientFields = await page
.locator(`[data-field-type="SIGNATURE"]:not(:disabled)`)
.all();
expect(secondRecipientFields.length).toBeGreaterThan(0);
// Add another field to the second duplicate
await page.getByRole('button', { name: 'Name' }).click();
await page.locator('canvas').click({ position: { x: 250, y: 150 } });
// Save changes
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('heading', { name: 'Distribute Document' })).toBeVisible();
await page.waitForTimeout(2500);
await page.getByRole('button', { name: 'Send' }).click();
await page.waitForURL(new RegExp(`/t/${team.url}/documents/\\d+`));
await expect(page.getByRole('link', { name: document.title })).toBeVisible();
});
});

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();
@ -573,6 +573,7 @@ test('[DOCUMENT_FLOW]: should be able to create and sign a document with 3 recip
y: 100 * i,
},
});
await page.getByText(`User ${i} (user${i}@example.com)`).click();
}

View File

@ -277,13 +277,13 @@ test('[TEAMS]: document folder and its contents can be deleted', async ({ page }
await page.goto(`/t/${team.url}/documents`);
await expect(page.locator('div').filter({ hasText: folder.name })).not.toBeVisible();
await expect(page.locator(`[data-folder-id="${folder.id}"]`)).not.toBeVisible();
await expect(page.getByText(proposal.title)).not.toBeVisible();
await page.goto(`/t/${team.url}/documents/f/${folder.id}`);
await expect(page.getByText(report.title)).not.toBeVisible();
await expect(page.locator('div').filter({ hasText: reportsFolder.name })).not.toBeVisible();
await expect(page.locator(`[data-folder-id="${reportsFolder.id}"]`)).not.toBeVisible();
});
test('[TEAMS]: create folder button is visible on templates page', async ({ page }) => {
@ -318,9 +318,7 @@ test('[TEAMS]: can create a template folder', async ({ page }) => {
await expect(page.getByText('Team template folder')).toBeVisible();
await page.goto(`/t/${team.url}/templates`);
await expect(
page.locator('div').filter({ hasText: 'Team template folder' }).nth(3),
).toBeVisible();
await expect(page.locator(`[data-folder-name="Team template folder"]`)).toBeVisible();
});
test('[TEAMS]: can create a template subfolder inside a template folder', async ({ page }) => {
@ -374,11 +372,8 @@ test('[TEAMS]: can create a template inside a template folder', async ({ page })
await page.getByRole('button', { name: 'New Template' }).click();
await page
.locator('div')
.filter({ hasText: /^Upload Template DocumentDrag & drop your PDF here\.$/ })
.nth(2)
.click();
await page.getByText('Upload Template Document').click();
await page.locator('input[type="file"]').nth(0).waitFor({ state: 'attached' });
await page
@ -537,7 +532,7 @@ test('[TEAMS]: template folder can be moved to another template folder', async (
await expect(page.getByText('Team Contract Templates')).toBeVisible();
});
test('[TEAMS]: template folder and its contents can be deleted', async ({ page }) => {
test('[TEAMS]: template folder can be deleted', async ({ page }) => {
const { team, teamOwner } = await seedTeamDocuments();
const folder = await seedBlankFolder(teamOwner, team.id, {
@ -585,13 +580,16 @@ test('[TEAMS]: template folder and its contents can be deleted', async ({ page }
await page.goto(`/t/${team.url}/templates`);
await expect(page.locator('div').filter({ hasText: folder.name })).not.toBeVisible();
await expect(page.getByText(template.title)).not.toBeVisible();
await page.waitForTimeout(1000);
// !: This is no longer the case, when deleting a folder its contents will be moved to the root folder.
// await expect(page.locator(`[data-folder-id="${folder.id}"]`)).not.toBeVisible();
// await expect(page.getByText(template.title)).not.toBeVisible();
await page.goto(`/t/${team.url}/templates/f/${folder.id}`);
await expect(page.getByText(reportTemplate.title)).not.toBeVisible();
await expect(page.locator('div').filter({ hasText: subfolder.name })).not.toBeVisible();
await expect(page.locator(`[data-folder-id="${subfolder.id}"]`)).not.toBeVisible();
});
test('[TEAMS]: can navigate between template folders', async ({ page }) => {
@ -843,10 +841,15 @@ test('[TEAMS]: documents inherit folder visibility', async ({ page }) => {
await page.getByText('Admin Only Folder').click();
const fileInput = page.locator('input[type="file"]').nth(1);
await fileInput.waitFor({ state: 'attached' });
await page.waitForURL(new RegExp(`/t/${team.url}/documents/f/.+`));
await fileInput.setInputFiles(
// Upload document.
const [fileChooser] = await Promise.all([
page.waitForEvent('filechooser'),
page.getByRole('button', { name: 'Upload Document' }).click(),
]);
await fileChooser.setFiles(
path.join(__dirname, '../../../assets/documenso-supporter-pledge.pdf'),
);

View File

@ -30,8 +30,8 @@ test('[ORGANISATIONS]: manage document preferences', async ({ page }) => {
await page.getByRole('option', { name: 'Australia/Perth' }).click();
// Set default date
await page.getByRole('combobox').filter({ hasText: 'yyyy-MM-dd hh:mm a' }).click();
await page.getByRole('option', { name: 'DD/MM/YYYY' }).click();
await page.getByRole('combobox').filter({ hasText: 'yyyy-MM-dd hh:mm AM/PM' }).click();
await page.getByRole('option', { name: 'DD/MM/YYYY', exact: true }).click();
await page.getByTestId('signature-types-trigger').click();
await page.getByRole('option', { name: 'Draw' }).click();
@ -51,7 +51,7 @@ test('[ORGANISATIONS]: manage document preferences', async ({ page }) => {
expect(teamSettings.documentVisibility).toEqual(DocumentVisibility.MANAGER_AND_ABOVE);
expect(teamSettings.documentLanguage).toEqual('de');
expect(teamSettings.documentTimezone).toEqual('Australia/Perth');
expect(teamSettings.documentDateFormat).toEqual('dd/MM/yyyy hh:mm a');
expect(teamSettings.documentDateFormat).toEqual('dd/MM/yyyy');
expect(teamSettings.includeSenderDetails).toEqual(false);
expect(teamSettings.includeSigningCertificate).toEqual(false);
expect(teamSettings.typedSignatureEnabled).toEqual(true);
@ -72,7 +72,7 @@ test('[ORGANISATIONS]: manage document preferences', async ({ page }) => {
// Override team date format settings
await page.getByTestId('document-date-format-trigger').click();
await page.getByRole('option', { name: 'MM/DD/YYYY' }).click();
await page.getByRole('option', { name: 'MM/DD/YYYY', exact: true }).click();
await page.getByRole('button', { name: 'Update' }).first().click();
await expect(page.getByText('Your document preferences have been updated').first()).toBeVisible();
@ -85,7 +85,7 @@ test('[ORGANISATIONS]: manage document preferences', async ({ page }) => {
expect(updatedTeamSettings.documentVisibility).toEqual(DocumentVisibility.EVERYONE);
expect(updatedTeamSettings.documentLanguage).toEqual('pl');
expect(updatedTeamSettings.documentTimezone).toEqual('Europe/London');
expect(updatedTeamSettings.documentDateFormat).toEqual('MM/dd/yyyy hh:mm a');
expect(updatedTeamSettings.documentDateFormat).toEqual('MM/dd/yyyy');
expect(updatedTeamSettings.includeSenderDetails).toEqual(false);
expect(updatedTeamSettings.includeSigningCertificate).toEqual(false);
expect(updatedTeamSettings.typedSignatureEnabled).toEqual(true);
@ -108,7 +108,7 @@ test('[ORGANISATIONS]: manage document preferences', async ({ page }) => {
expect(documentMeta.drawSignatureEnabled).toEqual(false);
expect(documentMeta.language).toEqual('pl');
expect(documentMeta.timezone).toEqual('Europe/London');
expect(documentMeta.dateFormat).toEqual('MM/dd/yyyy hh:mm a');
expect(documentMeta.dateFormat).toEqual('MM/dd/yyyy');
});
test('[ORGANISATIONS]: manage branding preferences', async ({ page }) => {

View File

@ -0,0 +1,283 @@
import { type Page, expect, test } from '@playwright/test';
import type { Team, Template } from '@prisma/client';
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
import { prisma } from '@documenso/prisma';
import { seedBlankTemplate } from '@documenso/prisma/seed/templates';
import { seedUser } from '@documenso/prisma/seed/users';
import { apiSignin } from '../fixtures/authentication';
/**
* Test helper to complete template creation with duplicate recipients
*/
const completeTemplateFlowWithDuplicateRecipients = async (options: {
page: Page;
team: Team;
template: Template;
}) => {
const { page, team, template } = options;
// Step 1: Settings - Continue with defaults
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('heading', { name: 'Add Placeholders' })).toBeVisible();
// Step 2: Add duplicate recipients with real emails for testing
await page.getByPlaceholder('Email').fill('duplicate@example.com');
await page.getByPlaceholder('Name').fill('First Instance');
// Add second signer with same email
await page.getByRole('button', { name: 'Add Placeholder Recipient' }).click();
await page.getByPlaceholder('Email').nth(1).fill('duplicate@example.com');
await page.getByPlaceholder('Name').nth(1).fill('Second Instance');
// Add third signer with different email
await page.getByRole('button', { name: 'Add Placeholder Recipient' }).click();
await page.getByPlaceholder('Email').nth(2).fill('unique@example.com');
await page.getByPlaceholder('Name').nth(2).fill('Different Recipient');
// Continue to fields
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
// Step 3: Add fields for each recipient instance
// Add signature field for first instance
await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({ position: { x: 100, y: 100 } });
// Switch to second instance and add their field
await page.getByRole('combobox').first().click();
await page.getByText('Second Instance').first().click();
await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({ position: { x: 200, y: 100 } });
// Switch to different recipient and add their field
await page.getByRole('combobox').first().click();
await page.getByText('Different Recipient').first().click();
await page.getByRole('button', { name: 'Name' }).click();
await page.locator('canvas').click({ position: { x: 300, y: 100 } });
// Save template
await page.getByRole('button', { name: 'Save Template' }).click();
// Wait for creation confirmation
await page.waitForURL(`/t/${team.url}/templates`);
await expect(page.getByRole('link', { name: template.title })).toBeVisible();
};
test.describe('[TEMPLATE_FLOW]: Duplicate Recipients', () => {
test('should allow creating template with duplicate recipient emails', async ({ page }) => {
const { user, team } = await seedUser();
const template = await seedBlankTemplate(user, team.id);
await apiSignin({
page,
email: user.email,
redirectPath: `/t/${team.url}/templates/${template.id}/edit`,
});
// Complete the template flow
await completeTemplateFlowWithDuplicateRecipients({ page, team, template });
// Verify template was created successfully
await expect(page).toHaveURL(`/t/${team.url}/templates`);
});
test('should create document from template with duplicate recipients using same email', async ({
page,
context,
}) => {
const { user, team } = await seedUser();
const template = await seedBlankTemplate(user, team.id);
await apiSignin({
page,
email: user.email,
redirectPath: `/t/${team.url}/templates/${template.id}/edit`,
});
// Complete template creation
await completeTemplateFlowWithDuplicateRecipients({ page, team, template });
// Navigate to template and create document
await page.goto(`/t/${team.url}/templates`);
await page
.getByRole('row', { name: template.title })
.getByRole('button', { name: 'Use Template' })
.click();
// Fill recipient information with same email for both instances
await expect(page.getByRole('heading', { name: 'Create document' })).toBeVisible();
// Set same email for both recipient instances
const emailInputs = await page.locator('[aria-label="Email"]').all();
const nameInputs = await page.locator('[aria-label="Name"]').all();
// First instance
await emailInputs[0].fill('same@example.com');
await nameInputs[0].fill('John Doe - Role 1');
// Second instance (same email)
await emailInputs[1].fill('same@example.com');
await nameInputs[1].fill('John Doe - Role 2');
// Different recipient
await emailInputs[2].fill('different@example.com');
await nameInputs[2].fill('Jane Smith');
await page.getByLabel('Send document').click();
// Create document
await page.getByRole('button', { name: 'Create and send' }).click();
await page.waitForURL(new RegExp(`/t/${team.url}/documents/\\d+`));
// Get the document ID from URL for database queries
const url = page.url();
const documentIdMatch = url.match(/\/documents\/(\d+)/);
const documentId = documentIdMatch ? parseInt(documentIdMatch[1]) : null;
expect(documentId).not.toBeNull();
// Get recipients directly from database
const recipients = await prisma.recipient.findMany({
where: {
documentId: documentId!,
},
});
expect(recipients).toHaveLength(3);
// Verify all tokens are unique
const tokens = recipients.map((r) => r.token);
expect(new Set(tokens).size).toBe(3);
// Test signing experience for duplicate email recipients
const duplicateRecipients = recipients.filter((r) => r.email === 'same@example.com');
expect(duplicateRecipients).toHaveLength(2);
for (const recipient of duplicateRecipients) {
// Navigate to signing URL
await page.goto(`/sign/${recipient.token}`, {
waitUntil: 'networkidle',
});
await page.waitForSelector(PDF_VIEWER_PAGE_SELECTOR);
// Verify correct recipient name is shown
await expect(page.getByLabel('Full Name')).toHaveValue(recipient.name);
// Verify only one signature field is visible for this recipient
expect(
await page.locator(`[data-field-type="SIGNATURE"]:not([data-readonly="true"])`).all(),
).toHaveLength(1);
}
});
test('should handle template with different types of duplicate emails', async ({ page }) => {
const { user, team } = await seedUser();
const template = await seedBlankTemplate(user, team.id);
await apiSignin({
page,
email: user.email,
redirectPath: `/t/${team.url}/templates/${template.id}/edit`,
});
// Step 1: Settings
await page.getByRole('button', { name: 'Continue' }).click();
// Step 2: Add multiple recipients with duplicate emails
await page.getByPlaceholder('Email').fill('duplicate@example.com');
await page.getByPlaceholder('Name').fill('Duplicate Recipient 1');
await page.getByRole('button', { name: 'Add Placeholder Recipient' }).click();
await page.getByPlaceholder('Email').nth(1).fill('duplicate@example.com');
await page.getByPlaceholder('Name').nth(1).fill('Duplicate Recipient 2');
await page.getByRole('button', { name: 'Add Placeholder Recipient' }).click();
await page.getByPlaceholder('Email').nth(2).fill('different@example.com');
await page.getByPlaceholder('Name').nth(2).fill('Different Recipient');
// Continue and add fields
await page.getByRole('button', { name: 'Continue' }).click();
// Add fields for each recipient
await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({ position: { x: 100, y: 100 } });
await page.getByRole('combobox').first().click();
await page.getByText('Duplicate Recipient 2').first().click();
await page.getByRole('button', { name: 'Date' }).click();
await page.locator('canvas').click({ position: { x: 200, y: 100 } });
await page.getByRole('combobox').first().click();
await page.getByText('Different Recipient').first().click();
await page.getByRole('button', { name: 'Name' }).click();
await page.locator('canvas').click({ position: { x: 100, y: 200 } });
// Save template
await page.getByRole('button', { name: 'Save Template' }).click();
await page.waitForURL(`/t/${team.url}/templates`);
await expect(page.getByRole('link', { name: template.title })).toBeVisible();
});
test('should validate field assignments per recipient in template editing', async ({ page }) => {
const { user, team } = await seedUser();
const template = await seedBlankTemplate(user, team.id);
await apiSignin({
page,
email: user.email,
redirectPath: `/t/${team.url}/templates/${template.id}/edit`,
});
// Create template with duplicates
await completeTemplateFlowWithDuplicateRecipients({ page, team, template });
// Navigate back to edit the template
await page.goto(`/t/${team.url}/templates/${template.id}/edit`);
// Go to fields step
await page.getByRole('button', { name: 'Continue' }).click(); // Settings
await page.getByRole('button', { name: 'Continue' }).click(); // Signers
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
// Verify fields are correctly assigned to each recipient instance
await page.getByRole('combobox').first().click();
await page.getByRole('option', { name: 'First Instance' }).first().click();
let visibleFields = await page.locator(`[data-field-type="SIGNATURE"]:not(:disabled)`).all();
expect(visibleFields.length).toBeGreaterThan(0);
await page.getByRole('combobox').first().click();
await page.getByRole('option', { name: 'Second Instance' }).first().click();
visibleFields = await page.locator(`[data-field-type="SIGNATURE"]:not(:disabled)`).all();
expect(visibleFields.length).toBeGreaterThan(0);
await page.getByRole('combobox').first().click();
await page.getByRole('option', { name: 'Different Recipient' }).first().click();
const nameFields = await page.locator(`[data-field-type="NAME"]:not(:disabled)`).all();
expect(nameFields.length).toBeGreaterThan(0);
// Add additional field to verify proper assignment
await page.getByRole('combobox').first().click();
await page.getByRole('option', { name: 'First Instance' }).first().click();
await page.getByRole('button', { name: 'Name' }).click();
await page.locator('canvas').click({ position: { x: 100, y: 300 } });
await page.waitForTimeout(2500);
// Save changes
await page.getByRole('button', { name: 'Save Template' }).click();
await page.waitForURL(`/t/${team.url}/templates`);
await expect(page.getByRole('link', { name: template.title })).toBeVisible();
});
});

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('body').click({ position: { x: 0, y: 0 } });
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').first().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').first().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').first().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').first().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').first().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('body').click({ position: { x: 0, y: 0 } });
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('body').click({ position: { x: 0, y: 0 } });
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

@ -47,8 +47,8 @@ test('[TEMPLATE]: should create a document from a template', async ({ page }) =>
// Set advanced options.
await page.getByRole('button', { name: 'Advanced Options' }).click();
await page.locator('button').filter({ hasText: 'YYYY-MM-DD HH:mm a' }).click();
await page.getByLabel('DD/MM/YYYY').click();
await page.locator('button').filter({ hasText: 'YYYY-MM-DD hh:mm AM/PM' }).click();
await page.getByLabel('DD/MM/YYYY HH:mm', { exact: true }).click();
await page.locator('.time-zone-field').click();
await page.getByRole('option', { name: 'Etc/UTC' }).click();
@ -96,7 +96,7 @@ test('[TEMPLATE]: should create a document from a template', async ({ page }) =>
expect(document.title).toEqual('TEMPLATE_TITLE');
expect(documentAuth.documentAuthOption.globalAccessAuth).toContain('ACCOUNT');
expect(document.documentMeta?.dateFormat).toEqual('dd/MM/yyyy hh:mm a');
expect(document.documentMeta?.dateFormat).toEqual('dd/MM/yyyy HH:mm');
expect(document.documentMeta?.message).toEqual('MESSAGE');
expect(document.documentMeta?.redirectUrl).toEqual('https://documenso.com');
expect(document.documentMeta?.subject).toEqual('SUBJECT');
@ -150,8 +150,8 @@ test('[TEMPLATE]: should create a team document from a team template', async ({
// Set advanced options.
await page.getByRole('button', { name: 'Advanced Options' }).click();
await page.locator('button').filter({ hasText: 'YYYY-MM-DD HH:mm a' }).click();
await page.getByLabel('DD/MM/YYYY').click();
await page.locator('button').filter({ hasText: 'YYYY-MM-DD hh:mm AM/PM' }).click();
await page.getByLabel('DD/MM/YYYY HH:mm', { exact: true }).click();
await page.locator('.time-zone-field').click();
await page.getByRole('option', { name: 'Etc/UTC' }).click();
@ -200,7 +200,7 @@ test('[TEMPLATE]: should create a team document from a team template', async ({
expect(document.title).toEqual('TEMPLATE_TITLE');
expect(documentAuth.documentAuthOption.globalAccessAuth).toContain('ACCOUNT');
expect(document.documentMeta?.dateFormat).toEqual('dd/MM/yyyy hh:mm a');
expect(document.documentMeta?.dateFormat).toEqual('dd/MM/yyyy HH:mm');
expect(document.documentMeta?.message).toEqual('MESSAGE');
expect(document.documentMeta?.redirectUrl).toEqual('https://documenso.com');
expect(document.documentMeta?.subject).toEqual('SUBJECT');

View File

@ -17,7 +17,7 @@ export default defineConfig({
testDir: './e2e',
/* Run tests in files in parallel */
fullyParallel: false,
workers: 1,
workers: 2,
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,
@ -33,7 +33,7 @@ export default defineConfig({
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
trace: 'on',
video: 'retain-on-failure',
video: 'on-first-retry',
/* Add explicit timeouts for actions */
actionTimeout: 15_000,
@ -48,7 +48,7 @@ export default defineConfig({
name: 'chromium',
use: {
...devices['Desktop Chrome'],
viewport: { width: 1920, height: 1080 },
viewport: { width: 1920, height: 1200 },
},
},

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

@ -23,12 +23,17 @@ type HandleOAuthAuthorizeUrlOptions = {
* Optional redirect path to redirect the user somewhere on the app after authorization.
*/
redirectPath?: string;
/**
* Optional prompt to pass to the authorization endpoint.
*/
prompt?: 'login' | 'consent' | 'select_account';
};
const oauthCookieMaxAge = 60 * 10; // 10 minutes.
export const handleOAuthAuthorizeUrl = async (options: HandleOAuthAuthorizeUrlOptions) => {
const { c, clientOptions, redirectPath } = options;
const { c, clientOptions, redirectPath, prompt = 'login' } = options;
if (!clientOptions.clientId || !clientOptions.clientSecret) {
throw new AppError(AppErrorCode.NOT_SETUP);
@ -57,8 +62,8 @@ export const handleOAuthAuthorizeUrl = async (options: HandleOAuthAuthorizeUrlOp
scopes,
);
// Allow user to select account during login.
url.searchParams.append('prompt', 'login');
// Pass the prompt to the authorization endpoint.
url.searchParams.append('prompt', prompt);
setCookie(c, `${clientOptions.id}_oauth_state`, state, {
...sessionCookieOptions,

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,21 @@ 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,
prompt: 'select_account',
});
});

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,60 @@
import { Trans } from '@lingui/react/macro';
import { Heading, Img, Section, Text } from '../components';
export type TemplateAccessAuth2FAProps = {
documentTitle: string;
code: string;
userEmail: string;
userName: string;
expiresInMinutes: number;
assetBaseUrl?: string;
};
export const TemplateAccessAuth2FA = ({
documentTitle,
code,
userName,
expiresInMinutes,
assetBaseUrl = 'http://localhost:3002',
}: TemplateAccessAuth2FAProps) => {
const getAssetUrl = (path: string) => {
return new URL(path, assetBaseUrl).toString();
};
return (
<div>
<Img src={getAssetUrl('/static/document.png')} alt="Document" className="mx-auto h-12 w-12" />
<Section className="mt-8">
<Heading className="text-center text-lg font-semibold text-slate-900">
<Trans>Verification Code Required</Trans>
</Heading>
<Text className="mt-2 text-center text-slate-700">
<Trans>
Hi {userName}, you need to enter a verification code to complete the document "
{documentTitle}".
</Trans>
</Text>
<Section className="mt-6 rounded-lg bg-slate-50 p-6 text-center">
<Text className="mb-2 text-sm font-medium text-slate-600">
<Trans>Your verification code:</Trans>
</Text>
<Text className="text-2xl font-bold tracking-wider text-slate-900">{code}</Text>
</Section>
<Text className="mt-4 text-center text-sm text-slate-600">
<Trans>This code will expire in {expiresInMinutes} minutes.</Trans>
</Text>
<Text className="mt-4 text-center text-sm text-slate-500">
<Trans>
If you didn't request this verification code, you can safely ignore this email.
</Trans>
</Text>
</Section>
</div>
);
};

View File

@ -1,5 +1,3 @@
import { useMemo } from 'react';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { OrganisationType, RecipientRole } from '@prisma/client';
@ -38,12 +36,6 @@ export const TemplateDocumentInvite = ({
const { actionVerb } = RECIPIENT_ROLES_DESCRIPTION[role];
const rejectDocumentLink = useMemo(() => {
const url = new URL(signDocumentLink);
url.searchParams.set('reject', 'true');
return url.toString();
}, [signDocumentLink]);
return (
<>
<TemplateDocumentImage className="mt-6" assetBaseUrl={assetBaseUrl} />
@ -99,22 +91,15 @@ export const TemplateDocumentInvite = ({
<Section className="mb-6 mt-8 text-center">
<Button
className="mr-4 inline-flex items-center justify-center rounded-lg bg-red-500 px-6 py-3 text-center text-sm font-medium text-black no-underline"
href={rejectDocumentLink}
>
<Trans>Reject Document</Trans>
</Button>
<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"
className="bg-documenso-500 text-sbase inline-flex items-center justify-center rounded-lg px-6 py-3 text-center font-medium text-black no-underline"
href={signDocumentLink}
>
{match(role)
.with(RecipientRole.SIGNER, () => <Trans>Sign Document</Trans>)
.with(RecipientRole.SIGNER, () => <Trans>View Document to sign</Trans>)
.with(RecipientRole.VIEWER, () => <Trans>View Document</Trans>)
.with(RecipientRole.APPROVER, () => <Trans>Approve Document</Trans>)
.with(RecipientRole.APPROVER, () => <Trans>View Document to approve</Trans>)
.with(RecipientRole.CC, () => '')
.with(RecipientRole.ASSISTANT, () => <Trans>Assist Document</Trans>)
.with(RecipientRole.ASSISTANT, () => <Trans>View Document to assist</Trans>)
.exhaustive()}
</Button>
</Section>

View File

@ -0,0 +1,77 @@
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Body, Container, Head, Html, Img, Preview, Section } from '../components';
import { useBranding } from '../providers/branding';
import { TemplateAccessAuth2FA } from '../template-components/template-access-auth-2fa';
import { TemplateFooter } from '../template-components/template-footer';
export type AccessAuth2FAEmailTemplateProps = {
documentTitle: string;
code: string;
userEmail: string;
userName: string;
expiresInMinutes: number;
assetBaseUrl?: string;
};
export const AccessAuth2FAEmailTemplate = ({
documentTitle,
code,
userEmail,
userName,
expiresInMinutes,
assetBaseUrl = 'http://localhost:3002',
}: AccessAuth2FAEmailTemplateProps) => {
const { _ } = useLingui();
const branding = useBranding();
const previewText = msg`Your verification code is ${code}`;
const getAssetUrl = (path: string) => {
return new URL(path, assetBaseUrl).toString();
};
return (
<Html>
<Head />
<Preview>{_(previewText)}</Preview>
<Body className="mx-auto my-auto bg-white font-sans">
<Section>
<Container className="mx-auto mb-2 mt-8 max-w-xl rounded-lg border border-solid border-slate-200 p-4 backdrop-blur-sm">
<Section>
{branding.brandingEnabled && branding.brandingLogo ? (
<Img src={branding.brandingLogo} alt="Branding Logo" className="mb-4 h-6" />
) : (
<Img
src={getAssetUrl('/static/logo.png')}
alt="Documenso Logo"
className="mb-4 h-6"
/>
)}
<TemplateAccessAuth2FA
documentTitle={documentTitle}
code={code}
userEmail={userEmail}
userName={userName}
expiresInMinutes={expiresInMinutes}
assetBaseUrl={assetBaseUrl}
/>
</Section>
</Container>
<div className="mx-auto mt-12 max-w-xl" />
<Container className="mx-auto max-w-xl">
<TemplateFooter isDocument={false} />
</Container>
</Section>
</Body>
</Html>
);
};
export default AccessAuth2FAEmailTemplate;

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,64 @@
import { useCallback, useEffect, useRef } from 'react';
type SaveRequest<T, R> = {
data: T;
onResponse?: (response: R) => void;
};
export const useAutoSave = <T, R = void>(
onSave: (data: T) => Promise<R>,
options: { delay?: number } = {},
) => {
const { delay = 2000 } = options;
const saveTimeoutRef = useRef<NodeJS.Timeout>();
const saveQueueRef = useRef<SaveRequest<T, R>[]>([]);
const isProcessingRef = useRef(false);
const processQueue = async () => {
if (isProcessingRef.current || saveQueueRef.current.length === 0) {
return;
}
isProcessingRef.current = true;
while (saveQueueRef.current.length > 0) {
const request = saveQueueRef.current.shift()!;
try {
const response = await onSave(request.data);
request.onResponse?.(response);
} catch (error) {
console.error('Auto-save failed:', error);
}
}
isProcessingRef.current = false;
};
const saveFormData = async (data: T, onResponse?: (response: R) => void) => {
saveQueueRef.current.push({ data, onResponse });
await processQueue();
};
const scheduleSave = useCallback(
(data: T, onResponse?: (response: R) => void) => {
if (saveTimeoutRef.current) {
clearTimeout(saveTimeoutRef.current);
}
saveTimeoutRef.current = setTimeout(() => void saveFormData(data, onResponse), delay);
},
[delay],
);
useEffect(() => {
return () => {
if (saveTimeoutRef.current) {
clearTimeout(saveTimeoutRef.current);
}
};
}, []);
return { scheduleSave };
};

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

@ -7,14 +7,25 @@ export const DEFAULT_DOCUMENT_DATE_FORMAT = 'yyyy-MM-dd hh:mm a';
export const VALID_DATE_FORMAT_VALUES = [
DEFAULT_DOCUMENT_DATE_FORMAT,
'yyyy-MM-dd',
'dd/MM/yyyy',
'MM/dd/yyyy',
'yy-MM-dd',
'MMMM dd, yyyy',
'EEEE, MMMM dd, yyyy',
'dd/MM/yyyy hh:mm a',
'dd/MM/yyyy HH:mm',
'MM/dd/yyyy hh:mm a',
'MM/dd/yyyy HH:mm',
'dd.MM.yyyy',
'dd.MM.yyyy HH:mm',
'yyyy-MM-dd HH:mm',
'yy-MM-dd hh:mm a',
'yy-MM-dd HH:mm',
'yyyy-MM-dd HH:mm:ss',
'MMMM dd, yyyy hh:mm a',
'MMMM dd, yyyy HH:mm',
'EEEE, MMMM dd, yyyy hh:mm a',
'EEEE, MMMM dd, yyyy HH:mm',
"yyyy-MM-dd'T'HH:mm:ss.SSSXXX",
] as const;
@ -22,10 +33,80 @@ export type ValidDateFormat = (typeof VALID_DATE_FORMAT_VALUES)[number];
export const DATE_FORMATS = [
{
key: 'yyyy-MM-dd_hh:mm_a',
label: 'YYYY-MM-DD HH:mm a',
key: 'yyyy-MM-dd_HH:mm_12H',
label: 'YYYY-MM-DD hh:mm AM/PM',
value: DEFAULT_DOCUMENT_DATE_FORMAT,
},
{
key: 'yyyy-MM-dd_HH:mm',
label: 'YYYY-MM-DD HH:mm',
value: 'yyyy-MM-dd HH:mm',
},
{
key: 'DDMMYYYY_TIME',
label: 'DD/MM/YYYY HH:mm',
value: 'dd/MM/yyyy HH:mm',
},
{
key: 'DDMMYYYY_TIME_12H',
label: 'DD/MM/YYYY HH:mm AM/PM',
value: 'dd/MM/yyyy hh:mm a',
},
{
key: 'MMDDYYYY_TIME',
label: 'MM/DD/YYYY HH:mm',
value: 'MM/dd/yyyy HH:mm',
},
{
key: 'MMDDYYYY_TIME_12H',
label: 'MM/DD/YYYY HH:mm AM/PM',
value: 'MM/dd/yyyy hh:mm a',
},
{
key: 'DDMMYYYYHHMM',
label: 'DD.MM.YYYY HH:mm',
value: 'dd.MM.yyyy HH:mm',
},
{
key: 'YYMMDD_TIME',
label: 'YY-MM-DD HH:mm',
value: 'yy-MM-dd HH:mm',
},
{
key: 'YYMMDD_TIME_12H',
label: 'YY-MM-DD HH:mm AM/PM',
value: 'yy-MM-dd hh:mm a',
},
{
key: 'YYYY_MM_DD_HH_MM_SS',
label: 'YYYY-MM-DD HH:mm:ss',
value: 'yyyy-MM-dd HH:mm:ss',
},
{
key: 'MonthDateYear_TIME',
label: 'Month Date, Year HH:mm',
value: 'MMMM dd, yyyy HH:mm',
},
{
key: 'MonthDateYear_TIME_12H',
label: 'Month Date, Year HH:mm AM/PM',
value: 'MMMM dd, yyyy hh:mm a',
},
{
key: 'DayMonthYear_TIME',
label: 'Day, Month Year HH:mm',
value: 'EEEE, MMMM dd, yyyy HH:mm',
},
{
key: 'DayMonthYear_TIME_12H',
label: 'Day, Month Year HH:mm AM/PM',
value: 'EEEE, MMMM dd, yyyy hh:mm a',
},
{
key: 'ISO8601',
label: 'ISO 8601',
value: "yyyy-MM-dd'T'HH:mm:ss.SSSXXX",
},
{
key: 'YYYYMMDD',
label: 'YYYY-MM-DD',
@ -34,47 +115,32 @@ export const DATE_FORMATS = [
{
key: 'DDMMYYYY',
label: 'DD/MM/YYYY',
value: 'dd/MM/yyyy hh:mm a',
value: 'dd/MM/yyyy',
},
{
key: 'MMDDYYYY',
label: 'MM/DD/YYYY',
value: 'MM/dd/yyyy hh:mm a',
value: 'MM/dd/yyyy',
},
{
key: 'DDMMYYYYHHMM',
label: 'DD.MM.YYYY HH:mm',
value: 'dd.MM.yyyy HH:mm',
},
{
key: 'YYYYMMDDHHmm',
label: 'YYYY-MM-DD HH:mm',
value: 'yyyy-MM-dd HH:mm',
key: 'DDMMYYYY_DOT',
label: 'DD.MM.YYYY',
value: 'dd.MM.yyyy',
},
{
key: 'YYMMDD',
label: 'YY-MM-DD',
value: 'yy-MM-dd hh:mm a',
},
{
key: 'YYYYMMDDhhmmss',
label: 'YYYY-MM-DD HH:mm:ss',
value: 'yyyy-MM-dd HH:mm:ss',
value: 'yy-MM-dd',
},
{
key: 'MonthDateYear',
label: 'Month Date, Year',
value: 'MMMM dd, yyyy hh:mm a',
value: 'MMMM dd, yyyy',
},
{
key: 'DayMonthYear',
label: 'Day, Month Year',
value: 'EEEE, MMMM dd, yyyy hh:mm a',
},
{
key: 'ISO8601',
label: 'ISO 8601',
value: "yyyy-MM-dd'T'HH:mm:ss.SSSXXX",
value: 'EEEE, MMMM dd, yyyy',
},
] satisfies {
key: string;

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

@ -17,6 +17,7 @@ export enum AppErrorCode {
'RETRY_EXCEPTION' = 'RETRY_EXCEPTION',
'SCHEMA_FAILED' = 'SCHEMA_FAILED',
'TOO_MANY_REQUESTS' = 'TOO_MANY_REQUESTS',
'TWO_FACTOR_AUTH_FAILED' = 'TWO_FACTOR_AUTH_FAILED',
}
export const genericErrorCodeToTrpcErrorCodeMap: Record<string, { code: string; status: number }> =
@ -32,6 +33,7 @@ export const genericErrorCodeToTrpcErrorCodeMap: Record<string, { code: string;
[AppErrorCode.RETRY_EXCEPTION]: { code: 'INTERNAL_SERVER_ERROR', status: 500 },
[AppErrorCode.SCHEMA_FAILED]: { code: 'INTERNAL_SERVER_ERROR', status: 500 },
[AppErrorCode.TOO_MANY_REQUESTS]: { code: 'TOO_MANY_REQUESTS', status: 429 },
[AppErrorCode.TWO_FACTOR_AUTH_FAILED]: { code: 'UNAUTHORIZED', status: 401 },
};
export const ZAppErrorJsonSchema = z.object({

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: {

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,
},
});

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: {

View File

@ -42,6 +42,11 @@ export const run = async ({
where: {
id: userId,
},
select: {
id: true,
email: true,
name: true,
},
}),
prisma.document.findFirstOrThrow({
where: {

View File

@ -0,0 +1 @@
export const TWO_FACTOR_EMAIL_EXPIRATION_MINUTES = 5;

View File

@ -0,0 +1,38 @@
import { hmac } from '@noble/hashes/hmac';
import { sha256 } from '@noble/hashes/sha256';
import { createTOTPKeyURI } from 'oslo/otp';
import { DOCUMENSO_ENCRYPTION_KEY } from '../../../constants/crypto';
const ISSUER = 'Documenso Email 2FA';
export type GenerateTwoFactorCredentialsFromEmailOptions = {
documentId: number;
email: string;
};
/**
* Generate an encrypted token containing a 6-digit 2FA code for email verification.
*
* @param options - The options for generating the token
* @returns Object containing the token and the 6-digit code
*/
export const generateTwoFactorCredentialsFromEmail = ({
documentId,
email,
}: GenerateTwoFactorCredentialsFromEmailOptions) => {
if (!DOCUMENSO_ENCRYPTION_KEY) {
throw new Error('Missing DOCUMENSO_ENCRYPTION_KEY');
}
const identity = `email-2fa|v1|email:${email}|id:${documentId}`;
const secret = hmac(sha256, DOCUMENSO_ENCRYPTION_KEY, identity);
const uri = createTOTPKeyURI(ISSUER, email, secret);
return {
uri,
secret,
};
};

View File

@ -0,0 +1,23 @@
import { generateHOTP } from 'oslo/otp';
import { generateTwoFactorCredentialsFromEmail } from './generate-2fa-credentials-from-email';
export type GenerateTwoFactorTokenFromEmailOptions = {
documentId: number;
email: string;
period?: number;
};
export const generateTwoFactorTokenFromEmail = async ({
email,
documentId,
period = 30_000,
}: GenerateTwoFactorTokenFromEmailOptions) => {
const { secret } = generateTwoFactorCredentialsFromEmail({ email, documentId });
const counter = Math.floor(Date.now() / period);
const token = await generateHOTP(secret, counter);
return token;
};

View File

@ -0,0 +1,124 @@
import { createElement } from 'react';
import { msg } from '@lingui/core/macro';
import { mailer } from '@documenso/email/mailer';
import { AccessAuth2FAEmailTemplate } from '@documenso/email/templates/access-auth-2fa';
import { prisma } from '@documenso/prisma';
import { getI18nInstance } from '../../../client-only/providers/i18n-server';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../../constants/app';
import { AppError, AppErrorCode } from '../../../errors/app-error';
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../../types/document-audit-logs';
import { createDocumentAuditLogData } from '../../../utils/document-audit-logs';
import { renderEmailWithI18N } from '../../../utils/render-email-with-i18n';
import { getEmailContext } from '../../email/get-email-context';
import { TWO_FACTOR_EMAIL_EXPIRATION_MINUTES } from './constants';
import { generateTwoFactorTokenFromEmail } from './generate-2fa-token-from-email';
export type Send2FATokenEmailOptions = {
token: string;
documentId: number;
};
export const send2FATokenEmail = async ({ token, documentId }: Send2FATokenEmailOptions) => {
const document = await prisma.document.findFirst({
where: {
id: documentId,
recipients: {
some: {
token,
},
},
},
include: {
recipients: {
where: {
token,
},
},
documentMeta: true,
team: {
select: {
teamEmail: true,
name: true,
},
},
},
});
if (!document) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Document not found',
});
}
const [recipient] = document.recipients;
if (!recipient) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Recipient not found',
});
}
const twoFactorTokenToken = await generateTwoFactorTokenFromEmail({
documentId,
email: recipient.email,
});
const { branding, emailLanguage, senderEmail, replyToEmail } = await getEmailContext({
emailType: 'RECIPIENT',
source: {
type: 'team',
teamId: document.teamId,
},
meta: document.documentMeta,
});
const i18n = await getI18nInstance(emailLanguage);
const subject = i18n._(msg`Your two-factor authentication code`);
const template = createElement(AccessAuth2FAEmailTemplate, {
documentTitle: document.title,
userName: recipient.name,
userEmail: recipient.email,
code: twoFactorTokenToken,
expiresInMinutes: TWO_FACTOR_EMAIL_EXPIRATION_MINUTES,
assetBaseUrl: NEXT_PUBLIC_WEBAPP_URL(),
});
const [html, text] = await Promise.all([
renderEmailWithI18N(template, { lang: emailLanguage, branding }),
renderEmailWithI18N(template, { lang: emailLanguage, branding, plainText: true }),
]);
await prisma.$transaction(
async (tx) => {
await mailer.sendMail({
to: {
address: recipient.email,
name: recipient.name,
},
from: senderEmail,
replyTo: replyToEmail,
subject,
html,
text,
});
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_ACCESS_AUTH_2FA_REQUESTED,
documentId: document.id,
data: {
recipientEmail: recipient.email,
recipientName: recipient.name,
recipientId: recipient.id,
},
}),
});
},
{ timeout: 30_000 },
);
};

View File

@ -0,0 +1,37 @@
import { generateHOTP } from 'oslo/otp';
import { generateTwoFactorCredentialsFromEmail } from './generate-2fa-credentials-from-email';
export type ValidateTwoFactorTokenFromEmailOptions = {
documentId: number;
email: string;
code: string;
period?: number;
window?: number;
};
export const validateTwoFactorTokenFromEmail = async ({
documentId,
email,
code,
period = 30_000,
window = 1,
}: ValidateTwoFactorTokenFromEmailOptions) => {
const { secret } = generateTwoFactorCredentialsFromEmail({ email, documentId });
let now = Date.now();
for (let i = 0; i < window; i++) {
const counter = Math.floor(now / period);
const hotp = await generateHOTP(secret, counter);
if (code === hotp) {
return true;
}
now -= period;
}
return false;
};

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,28 @@
import * as fs from 'node:fs';
import { env } from '@documenso/lib/utils/env';
export const getCertificateStatus = () => {
if (env('NEXT_PRIVATE_SIGNING_TRANSPORT') !== 'local') {
return { isAvailable: true };
}
if (env('NEXT_PRIVATE_SIGNING_LOCAL_FILE_CONTENTS')) {
return { isAvailable: true };
}
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

@ -18,7 +18,8 @@ import { prisma } from '@documenso/prisma';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { jobs } from '../../jobs/client';
import type { TRecipientActionAuth } from '../../types/document-auth';
import type { TRecipientAccessAuth, TRecipientActionAuth } from '../../types/document-auth';
import { DocumentAuth } from '../../types/document-auth';
import {
ZWebhookDocumentSchema,
mapDocumentToWebhookDocumentPayload,
@ -26,6 +27,7 @@ import {
import { extractDocumentAuthMethods } from '../../utils/document-auth';
import { getIsRecipientsTurnToSign } from '../recipient/get-is-recipient-turn';
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
import { isRecipientAuthorized } from './is-recipient-authorized';
import { sendPendingEmail } from './send-pending-email';
export type CompleteDocumentWithTokenOptions = {
@ -33,6 +35,7 @@ export type CompleteDocumentWithTokenOptions = {
documentId: number;
userId?: number;
authOptions?: TRecipientActionAuth;
accessAuthOptions?: TRecipientAccessAuth;
requestMetadata?: RequestMetadata;
nextSigner?: {
email: string;
@ -64,6 +67,8 @@ const getDocument = async ({ token, documentId }: CompleteDocumentWithTokenOptio
export const completeDocumentWithToken = async ({
token,
documentId,
userId,
accessAuthOptions,
requestMetadata,
nextSigner,
}: CompleteDocumentWithTokenOptions) => {
@ -111,24 +116,57 @@ export const completeDocumentWithToken = async ({
throw new Error(`Recipient ${recipient.id} has unsigned fields`);
}
// Document reauth for completing documents is currently not required.
// Check ACCESS AUTH 2FA validation during document completion
const { derivedRecipientAccessAuth } = extractDocumentAuthMethods({
documentAuth: document.authOptions,
recipientAuth: recipient.authOptions,
});
// const { derivedRecipientActionAuth } = extractDocumentAuthMethods({
// documentAuth: document.authOptions,
// recipientAuth: recipient.authOptions,
// });
if (derivedRecipientAccessAuth.includes(DocumentAuth.TWO_FACTOR_AUTH)) {
if (!accessAuthOptions) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'Access authentication required',
});
}
// const isValid = await isRecipientAuthorized({
// type: 'ACTION',
// document: document,
// recipient: recipient,
// userId,
// authOptions,
// });
const isValid = await isRecipientAuthorized({
type: 'ACCESS_2FA',
documentAuthOptions: document.authOptions,
recipient: recipient,
userId, // Can be undefined for non-account recipients
authOptions: accessAuthOptions,
});
// if (!isValid) {
// throw new AppError(AppErrorCode.UNAUTHORIZED, 'Invalid authentication values');
// }
if (!isValid) {
await prisma.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_ACCESS_AUTH_2FA_FAILED,
documentId: document.id,
data: {
recipientId: recipient.id,
recipientName: recipient.name,
recipientEmail: recipient.email,
},
}),
});
throw new AppError(AppErrorCode.TWO_FACTOR_AUTH_FAILED, {
message: 'Invalid 2FA authentication',
});
}
await prisma.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_ACCESS_AUTH_2FA_VALIDATED,
documentId: document.id,
data: {
recipientId: recipient.id,
recipientName: recipient.name,
recipientEmail: recipient.email,
},
}),
});
}
await prisma.$transaction(async (tx) => {
await tx.recipient.update({

View File

@ -15,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';
@ -45,7 +45,7 @@ export type CreateDocumentOptions = {
globalAccessAuth?: TDocumentAccessAuthTypes[];
globalActionAuth?: TDocumentActionAuthTypes[];
formValues?: TDocumentFormValues;
recipients: TCreateDocumentV2Request['recipients'];
recipients: TCreateDocumentTemporaryRequest['recipients'];
folderId?: string;
};
meta?: Partial<Omit<TemplateMeta, 'id' | 'templateId'>>;

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

@ -73,7 +73,13 @@ export const getDocumentAndSenderByToken = async ({
},
},
include: {
user: true,
user: {
select: {
id: true,
email: true,
name: true,
},
},
documentData: true,
documentMeta: true,
recipients: {
@ -85,14 +91,17 @@ export const getDocumentAndSenderByToken = async ({
select: {
name: true,
teamEmail: true,
teamGlobalSettings: {
select: {
brandingEnabled: true,
brandingLogo: true,
},
},
},
},
},
});
// 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.
@ -120,7 +129,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

@ -4,6 +4,7 @@ import { match } from 'ts-pattern';
import { prisma } from '@documenso/prisma';
import { validateTwoFactorTokenFromEmail } from '../2fa/email/validate-2fa-token-from-email';
import { verifyTwoFactorAuthenticationToken } from '../2fa/verify-2fa-token';
import { verifyPassword } from '../2fa/verify-password';
import { AppError, AppErrorCode } from '../../errors/app-error';
@ -14,9 +15,10 @@ import { getAuthenticatorOptions } from '../../utils/authenticator';
import { extractDocumentAuthMethods } from '../../utils/document-auth';
type IsRecipientAuthorizedOptions = {
type: 'ACCESS' | 'ACTION';
// !: Probably find a better name than 'ACCESS_2FA' if requirements change.
type: 'ACCESS' | 'ACCESS_2FA' | 'ACTION';
documentAuthOptions: Document['authOptions'];
recipient: Pick<Recipient, 'authOptions' | 'email'>;
recipient: Pick<Recipient, 'authOptions' | 'email' | 'documentId'>;
/**
* The ID of the user who initiated the request.
@ -61,8 +63,11 @@ export const isRecipientAuthorized = async ({
recipientAuth: recipient.authOptions,
});
const authMethods: TDocumentAuth[] =
type === 'ACCESS' ? derivedRecipientAccessAuth : derivedRecipientActionAuth;
const authMethods: TDocumentAuth[] = match(type)
.with('ACCESS', () => derivedRecipientAccessAuth)
.with('ACCESS_2FA', () => derivedRecipientAccessAuth)
.with('ACTION', () => derivedRecipientActionAuth)
.exhaustive();
// Early true return when auth is not required.
if (
@ -72,6 +77,11 @@ export const isRecipientAuthorized = async ({
return true;
}
// Early true return for ACCESS auth if all methods are 2FA since validation happens in ACCESS_2FA.
if (type === 'ACCESS' && authMethods.every((method) => method === DocumentAuth.TWO_FACTOR_AUTH)) {
return true;
}
// Create auth options when none are passed for account.
if (!authOptions && authMethods.some((method) => method === DocumentAuth.ACCOUNT)) {
authOptions = {
@ -80,12 +90,16 @@ export const isRecipientAuthorized = async ({
}
// Authentication required does not match provided method.
if (!authOptions || !authMethods.includes(authOptions.type) || !userId) {
if (!authOptions || !authMethods.includes(authOptions.type)) {
return false;
}
return await match(authOptions)
.with({ type: DocumentAuth.ACCOUNT }, async () => {
if (!userId) {
return false;
}
const recipientUser = await getUserByEmail(recipient.email);
if (!recipientUser) {
@ -95,13 +109,40 @@ export const isRecipientAuthorized = async ({
return recipientUser.id === userId;
})
.with({ type: DocumentAuth.PASSKEY }, async ({ authenticationResponse, tokenReference }) => {
if (!userId) {
return false;
}
return await isPasskeyAuthValid({
userId,
authenticationResponse,
tokenReference,
});
})
.with({ type: DocumentAuth.TWO_FACTOR_AUTH }, async ({ token }) => {
.with({ type: DocumentAuth.TWO_FACTOR_AUTH }, async ({ token, method }) => {
if (type === 'ACCESS') {
return true;
}
if (type === 'ACCESS_2FA' && method === 'email') {
if (!recipient.documentId) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Document ID is required for email 2FA verification',
});
}
return await validateTwoFactorTokenFromEmail({
documentId: recipient.documentId,
email: recipient.email,
code: token,
window: 10, // 5 minutes worth of tokens
});
}
if (!userId) {
return false;
}
const user = await prisma.user.findFirst({
where: {
id: userId,
@ -115,6 +156,7 @@ export const isRecipientAuthorized = async ({
});
}
// For ACTION auth or authenticator method, use TOTP
return await verifyTwoFactorAuthenticationToken({
user,
totpCode: token,
@ -122,6 +164,10 @@ export const isRecipientAuthorized = async ({
});
})
.with({ type: DocumentAuth.PASSWORD }, async ({ password }) => {
if (!userId) {
return false;
}
return await verifyPassword({
userId,
password,

View File

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

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,

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,
},
});

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

@ -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,
},
},
},
});

View File

@ -7,7 +7,7 @@ import { isRecipientAuthorized } from './is-recipient-authorized';
export type ValidateFieldAuthOptions = {
documentAuthOptions: Document['authOptions'];
recipient: Pick<Recipient, 'authOptions' | 'email'>;
recipient: Pick<Recipient, 'authOptions' | 'email' | 'documentId'>;
field: Field;
userId?: number;
authOptions?: TRecipientActionAuth;

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 {
@ -104,7 +106,12 @@ export const getEmailContext = async (
}
const replyToEmail = meta?.emailReplyTo || emailContext.settings.emailReplyTo || undefined;
const senderEmailId = meta?.emailId === null ? null : 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

@ -84,9 +84,7 @@ export const setFieldsForDocument = async ({
const linkedFields = fields.map((field) => {
const existing = existingFields.find((existingField) => existingField.id === field.id);
const recipient = document.recipients.find(
(recipient) => recipient.email.toLowerCase() === field.signerEmail.toLowerCase(),
);
const recipient = document.recipients.find((recipient) => recipient.id === field.recipientId);
// Each field MUST have a recipient associated with it.
if (!recipient) {
@ -226,10 +224,8 @@ export const setFieldsForDocument = async ({
},
recipient: {
connect: {
documentId_email: {
documentId,
email: fieldSignerEmail,
},
id: field.recipientId,
documentId,
},
},
},
@ -330,6 +326,7 @@ type FieldData = {
id?: number | null;
type: FieldType;
signerEmail: string;
recipientId: number;
pageNumber: number;
pageX: number;
pageY: number;

View File

@ -26,6 +26,7 @@ export type SetFieldsForTemplateOptions = {
id?: number | null;
type: FieldType;
signerEmail: string;
recipientId: number;
pageNumber: number;
pageX: number;
pageY: number;
@ -169,10 +170,8 @@ export const setFieldsForTemplate = async ({
},
recipient: {
connect: {
templateId_email: {
templateId,
email: field.signerEmail.toLowerCase(),
},
id: field.recipientId,
templateId,
},
},
},

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

@ -75,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
@ -87,6 +97,7 @@ export const createOrganisation = async ({
ownerUserId: userId,
organisationGlobalSettingsId: organisationSetting.id,
organisationClaimId: organisationClaim.id,
organisationAuthenticationPortalId: organisationAuthenticationPortal.id,
groups: {
create: ORGANISATION_INTERNAL_GROUPS.map((group) => ({
...group,

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

@ -85,20 +85,6 @@ export const createDocumentRecipients = async ({
email: recipient.email.toLowerCase(),
}));
const duplicateRecipients = normalizedRecipients.filter((newRecipient) => {
const existingRecipient = document.recipients.find(
(existingRecipient) => existingRecipient.email === newRecipient.email,
);
return existingRecipient !== undefined;
});
if (duplicateRecipients.length > 0) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: `Duplicate recipient(s) found for ${duplicateRecipients.map((recipient) => recipient.email).join(', ')}`,
});
}
const createdRecipients = await prisma.$transaction(async (tx) => {
return await Promise.all(
normalizedRecipients.map(async (recipient) => {

View File

@ -71,20 +71,6 @@ export const createTemplateRecipients = async ({
email: recipient.email.toLowerCase(),
}));
const duplicateRecipients = normalizedRecipients.filter((newRecipient) => {
const existingRecipient = template.recipients.find(
(existingRecipient) => existingRecipient.email === newRecipient.email,
);
return existingRecipient !== undefined;
});
if (duplicateRecipients.length > 0) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: `Duplicate recipient(s) found for ${duplicateRecipients.map((recipient) => recipient.email).join(', ')}`,
});
}
const createdRecipients = await prisma.$transaction(async (tx) => {
return await Promise.all(
normalizedRecipients.map(async (recipient) => {

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

@ -122,16 +122,12 @@ export const setDocumentRecipients = async ({
const removedRecipients = existingRecipients.filter(
(existingRecipient) =>
!normalizedRecipients.find(
(recipient) =>
recipient.id === existingRecipient.id || recipient.email === existingRecipient.email,
),
!normalizedRecipients.find((recipient) => recipient.id === existingRecipient.id),
);
const linkedRecipients = normalizedRecipients.map((recipient) => {
const existing = existingRecipients.find(
(existingRecipient) =>
existingRecipient.id === recipient.id || existingRecipient.email === recipient.email,
(existingRecipient) => existingRecipient.id === recipient.id,
);
const canPersistedRecipientBeModified =

View File

@ -94,10 +94,7 @@ export const setTemplateRecipients = async ({
const removedRecipients = existingRecipients.filter(
(existingRecipient) =>
!normalizedRecipients.find(
(recipient) =>
recipient.id === existingRecipient.id || recipient.email === existingRecipient.email,
),
!normalizedRecipients.find((recipient) => recipient.id === existingRecipient.id),
);
if (template.directLink !== null) {
@ -124,8 +121,7 @@ export const setTemplateRecipients = async ({
const linkedRecipients = normalizedRecipients.map((recipient) => {
const existing = existingRecipients.find(
(existingRecipient) =>
existingRecipient.id === recipient.id || existingRecipient.email === recipient.email,
(existingRecipient) => existingRecipient.id === recipient.id,
);
return {

View File

@ -91,17 +91,6 @@ export const updateDocumentRecipients = async ({
});
}
const duplicateRecipientWithSameEmail = document.recipients.find(
(existingRecipient) =>
existingRecipient.email === recipient.email && existingRecipient.id !== recipient.id,
);
if (duplicateRecipientWithSameEmail) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: `Duplicate recipient with the same email found: ${duplicateRecipientWithSameEmail.email}`,
});
}
if (!canRecipientBeModified(originalRecipient, document.fields)) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Cannot modify a recipient who has already interacted with the document',

View File

@ -80,17 +80,6 @@ export const updateTemplateRecipients = async ({
});
}
const duplicateRecipientWithSameEmail = template.recipients.find(
(existingRecipient) =>
existingRecipient.email === recipient.email && existingRecipient.id !== recipient.id,
);
if (duplicateRecipientWithSameEmail) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: `Duplicate recipient with the same email found: ${duplicateRecipientWithSameEmail.email}`,
});
}
return {
originalRecipient,
recipientUpdateData: recipient,

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,
},
},
},
});
@ -153,6 +159,7 @@ export const createDocumentFromDirectTemplate = async ({
// Ensure typesafety when we add more options.
const isAccessAuthValid = match(derivedRecipientAccessAuth.at(0))
.with(DocumentAccessAuth.ACCOUNT, () => user && user?.email === directRecipientEmail)
.with(DocumentAccessAuth.TWO_FACTOR_AUTH, () => false) // Not supported for direct templates
.with(undefined, () => true)
.exhaustive();
@ -199,6 +206,7 @@ export const createDocumentFromDirectTemplate = async ({
recipient: {
authOptions: directTemplateRecipient.authOptions,
email: directRecipientEmail,
documentId: template.id,
},
field: templateField,
userId: user?.id,

View File

@ -19,6 +19,8 @@ export type CreateDocumentFromTemplateLegacyOptions = {
}[];
};
// !TODO: Make this work
/**
* Legacy server function for /api/v1
*/
@ -58,6 +60,15 @@ export const createDocumentFromTemplateLegacy = async ({
},
});
const recipientsToCreate = template.recipients.map((recipient) => ({
id: recipient.id,
email: recipient.email,
name: recipient.name,
role: recipient.role,
signingOrder: recipient.signingOrder,
token: nanoid(),
}));
const document = await prisma.document.create({
data: {
qrToken: prefixedId('qr'),
@ -70,12 +81,12 @@ export const createDocumentFromTemplateLegacy = async ({
documentDataId: documentData.id,
useLegacyFieldInsertion: template.useLegacyFieldInsertion ?? false,
recipients: {
create: template.recipients.map((recipient) => ({
create: recipientsToCreate.map((recipient) => ({
email: recipient.email,
name: recipient.name,
role: recipient.role,
signingOrder: recipient.signingOrder,
token: nanoid(),
token: recipient.token,
})),
},
documentMeta: {
@ -95,9 +106,11 @@ export const createDocumentFromTemplateLegacy = async ({
await prisma.field.createMany({
data: template.fields.map((field) => {
const recipient = template.recipients.find((recipient) => recipient.id === field.recipientId);
const recipient = recipientsToCreate.find((recipient) => recipient.id === field.recipientId);
const documentRecipient = document.recipients.find((doc) => doc.email === recipient?.email);
const documentRecipient = document.recipients.find(
(documentRecipient) => documentRecipient.token === recipient?.token,
);
if (!documentRecipient) {
throw new Error('Recipient not found.');
@ -118,28 +131,32 @@ export const createDocumentFromTemplateLegacy = async ({
}),
});
// Replicate the old logic, get by index and create if we exceed the number of existing recipients.
if (recipients && recipients.length > 0) {
document.recipients = await Promise.all(
await Promise.all(
recipients.map(async (recipient, index) => {
const existingRecipient = document.recipients.at(index);
return await prisma.recipient.upsert({
where: {
documentId_email: {
if (existingRecipient) {
return await prisma.recipient.update({
where: {
id: existingRecipient.id,
documentId: document.id,
email: existingRecipient?.email ?? recipient.email,
},
},
update: {
name: recipient.name,
email: recipient.email,
role: recipient.role,
signingOrder: recipient.signingOrder,
},
create: {
data: {
name: recipient.name,
email: recipient.email,
role: recipient.role,
signingOrder: recipient.signingOrder,
},
});
}
return await prisma.recipient.create({
data: {
documentId: document.id,
email: recipient.email,
name: recipient.name,
email: recipient.email,
role: recipient.role,
signingOrder: recipient.signingOrder,
token: nanoid(),
@ -149,5 +166,18 @@ export const createDocumentFromTemplateLegacy = async ({
);
}
return document;
// Gross but we need to do the additional fetch since we mutate above.
const updatedRecipients = await prisma.recipient.findMany({
where: {
documentId: document.id,
},
orderBy: {
id: 'asc',
},
});
return {
...document,
recipients: updatedRecipients,
};
};

View File

@ -53,7 +53,7 @@ import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
type FinalRecipient = Pick<
Recipient,
'name' | 'email' | 'role' | 'authOptions' | 'signingOrder'
'name' | 'email' | 'role' | 'authOptions' | 'signingOrder' | 'token'
> & {
templateRecipientId: number;
fields: Field[];
@ -350,6 +350,7 @@ export const createDocumentFromTemplate = async ({
role: templateRecipient.role,
signingOrder: foundRecipient?.signingOrder ?? templateRecipient.signingOrder,
authOptions: templateRecipient.authOptions,
token: nanoid(),
};
});
@ -441,7 +442,7 @@ export const createDocumentFromTemplate = async ({
? SigningStatus.SIGNED
: SigningStatus.NOT_SIGNED,
signingOrder: recipient.signingOrder,
token: nanoid(),
token: recipient.token,
};
}),
},
@ -500,8 +501,8 @@ export const createDocumentFromTemplate = async ({
}
}
Object.values(finalRecipients).forEach(({ email, fields }) => {
const recipient = document.recipients.find((recipient) => recipient.email === email);
Object.values(finalRecipients).forEach(({ token, fields }) => {
const recipient = document.recipients.find((recipient) => recipient.token === token);
if (!recipient) {
throw new Error('Recipient not found.');

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

@ -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

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

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