mirror of
https://github.com/documenso/documenso.git
synced 2025-11-25 06:01:35 +10:00
chore: merge main
This commit is contained in:
@ -0,0 +1,293 @@
|
||||
import type { Page } from '@playwright/test';
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { getFieldsForDocument } from '@documenso/lib/server-only/field/get-fields-for-document';
|
||||
import { seedBlankDocument } from '@documenso/prisma/seed/documents';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
|
||||
import { apiSignin } from '../fixtures/authentication';
|
||||
|
||||
const setupDocumentAndNavigateToFieldsStep = async (page: Page) => {
|
||||
const { user, team } = await seedUser();
|
||||
const document = await seedBlankDocument(user, team.id);
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
redirectPath: `/documents/${document.id}/edit`,
|
||||
});
|
||||
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
|
||||
await page.getByPlaceholder('Email').fill('recipient1@documenso.com');
|
||||
await page.getByPlaceholder('Name').fill('Recipient 1');
|
||||
|
||||
await page.getByRole('button', { name: 'Add signer' }).click();
|
||||
|
||||
await page.getByPlaceholder('Email').nth(1).fill('recipient2@documenso.com');
|
||||
await page.getByPlaceholder('Name').nth(1).fill('Recipient 2');
|
||||
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
|
||||
return { user, team, document };
|
||||
};
|
||||
|
||||
const triggerAutosave = async (page: Page) => {
|
||||
await page.locator('#document-flow-form-container').click();
|
||||
await page.locator('#document-flow-form-container').blur();
|
||||
|
||||
await page.waitForTimeout(5000);
|
||||
};
|
||||
|
||||
test.describe('AutoSave Fields Step', () => {
|
||||
test('should autosave the fields without advanced settings', async ({ page }) => {
|
||||
const { user, document, team } = await setupDocumentAndNavigateToFieldsStep(page);
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator('canvas').click({
|
||||
position: {
|
||||
x: 100,
|
||||
y: 100,
|
||||
},
|
||||
});
|
||||
|
||||
await page.getByRole('button', { name: 'Text' }).click();
|
||||
await page.locator('canvas').click({
|
||||
position: {
|
||||
x: 100,
|
||||
y: 200,
|
||||
},
|
||||
});
|
||||
|
||||
await page.getByTestId('field-advanced-settings-footer').waitFor({ state: 'visible' });
|
||||
|
||||
await page
|
||||
.getByTestId('field-advanced-settings-footer')
|
||||
.getByRole('button', { name: 'Cancel' })
|
||||
.click();
|
||||
|
||||
await triggerAutosave(page);
|
||||
|
||||
await page.getByRole('combobox').click();
|
||||
await page.getByRole('option', { name: 'Recipient 2 (recipient2@documenso.com)' }).click();
|
||||
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator('canvas').click({
|
||||
position: {
|
||||
x: 100,
|
||||
y: 500,
|
||||
},
|
||||
});
|
||||
|
||||
await triggerAutosave(page);
|
||||
|
||||
await expect(async () => {
|
||||
const retrievedFields = await getFieldsForDocument({
|
||||
documentId: document.id,
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
});
|
||||
|
||||
expect(retrievedFields.length).toBe(3);
|
||||
expect(retrievedFields[0].type).toBe('SIGNATURE');
|
||||
expect(retrievedFields[1].type).toBe('TEXT');
|
||||
expect(retrievedFields[2].type).toBe('SIGNATURE');
|
||||
}).toPass();
|
||||
});
|
||||
|
||||
test('should autosave the field deletion', async ({ page }) => {
|
||||
const { user, document, team } = await setupDocumentAndNavigateToFieldsStep(page);
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator('canvas').click({
|
||||
position: {
|
||||
x: 100,
|
||||
y: 100,
|
||||
},
|
||||
});
|
||||
|
||||
await page.getByRole('button', { name: 'Text' }).click();
|
||||
await page.locator('canvas').click({
|
||||
position: {
|
||||
x: 100,
|
||||
y: 200,
|
||||
},
|
||||
});
|
||||
|
||||
await page.getByTestId('field-advanced-settings-footer').waitFor({ state: 'visible' });
|
||||
|
||||
await page
|
||||
.getByTestId('field-advanced-settings-footer')
|
||||
.getByRole('button', { name: 'Cancel' })
|
||||
.click();
|
||||
|
||||
await triggerAutosave(page);
|
||||
|
||||
await page.getByRole('combobox').click();
|
||||
await page.getByRole('option', { name: 'Recipient 2 (recipient2@documenso.com)' }).click();
|
||||
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator('canvas').click({
|
||||
position: {
|
||||
x: 100,
|
||||
y: 500,
|
||||
},
|
||||
});
|
||||
|
||||
await triggerAutosave(page);
|
||||
|
||||
await page.getByRole('combobox').click();
|
||||
await page.getByRole('option', { name: 'Recipient 1 (recipient1@documenso.com)' }).click();
|
||||
|
||||
await page.getByText('Text').nth(1).click();
|
||||
await page.getByRole('button', { name: 'Remove' }).click();
|
||||
|
||||
await triggerAutosave(page);
|
||||
|
||||
await expect(async () => {
|
||||
const retrievedFields = await getFieldsForDocument({
|
||||
documentId: document.id,
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
});
|
||||
|
||||
expect(retrievedFields.length).toBe(2);
|
||||
expect(retrievedFields[0].type).toBe('SIGNATURE');
|
||||
expect(retrievedFields[1].type).toBe('SIGNATURE');
|
||||
}).toPass();
|
||||
});
|
||||
|
||||
test('should autosave the field duplication', async ({ page }) => {
|
||||
const { user, document, team } = await setupDocumentAndNavigateToFieldsStep(page);
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator('canvas').click({
|
||||
position: {
|
||||
x: 100,
|
||||
y: 100,
|
||||
},
|
||||
});
|
||||
|
||||
await page.getByRole('button', { name: 'Text' }).click();
|
||||
await page.locator('canvas').click({
|
||||
position: {
|
||||
x: 100,
|
||||
y: 200,
|
||||
},
|
||||
});
|
||||
|
||||
await page.getByTestId('field-advanced-settings-footer').waitFor({ state: 'visible' });
|
||||
|
||||
await page
|
||||
.getByTestId('field-advanced-settings-footer')
|
||||
.getByRole('button', { name: 'Cancel' })
|
||||
.click();
|
||||
|
||||
await triggerAutosave(page);
|
||||
|
||||
await page.getByRole('combobox').click();
|
||||
await page.getByRole('option', { name: 'Recipient 2 (recipient2@documenso.com)' }).click();
|
||||
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator('canvas').click({
|
||||
position: {
|
||||
x: 100,
|
||||
y: 500,
|
||||
},
|
||||
});
|
||||
|
||||
await triggerAutosave(page);
|
||||
|
||||
await page.getByRole('combobox').click();
|
||||
await page.getByRole('option', { name: 'Recipient 1 (recipient1@documenso.com)' }).click();
|
||||
|
||||
await page.getByText('Signature').nth(1).click();
|
||||
await page.getByRole('button', { name: 'Duplicate', exact: true }).click();
|
||||
|
||||
await triggerAutosave(page);
|
||||
|
||||
await expect(async () => {
|
||||
const retrievedFields = await getFieldsForDocument({
|
||||
documentId: document.id,
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
});
|
||||
|
||||
expect(retrievedFields.length).toBe(4);
|
||||
expect(retrievedFields[0].type).toBe('SIGNATURE');
|
||||
expect(retrievedFields[1].type).toBe('TEXT');
|
||||
expect(retrievedFields[2].type).toBe('SIGNATURE');
|
||||
expect(retrievedFields[3].type).toBe('SIGNATURE');
|
||||
}).toPass();
|
||||
});
|
||||
|
||||
test('should autosave the fields with advanced settings', async ({ page }) => {
|
||||
const { user, document, team } = await setupDocumentAndNavigateToFieldsStep(page);
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator('canvas').click({
|
||||
position: {
|
||||
x: 100,
|
||||
y: 100,
|
||||
},
|
||||
});
|
||||
|
||||
await page.getByRole('button', { name: 'Text' }).click();
|
||||
await page.locator('canvas').click({
|
||||
position: {
|
||||
x: 100,
|
||||
y: 200,
|
||||
},
|
||||
});
|
||||
|
||||
await page.getByRole('textbox', { name: 'Field label' }).fill('Test Field');
|
||||
await page.getByRole('textbox', { name: 'Field placeholder' }).fill('Test Placeholder');
|
||||
await page.getByRole('textbox', { name: 'Add text to the field' }).fill('Test Text');
|
||||
|
||||
await page
|
||||
.getByTestId('field-advanced-settings-footer')
|
||||
.getByRole('button', { name: 'Save' })
|
||||
.click();
|
||||
|
||||
await triggerAutosave(page);
|
||||
|
||||
await expect(async () => {
|
||||
const retrievedFields = await getFieldsForDocument({
|
||||
documentId: document.id,
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
});
|
||||
|
||||
expect(retrievedFields.length).toBe(2);
|
||||
expect(retrievedFields[0].type).toBe('SIGNATURE');
|
||||
expect(retrievedFields[1].type).toBe('TEXT');
|
||||
|
||||
const textField = retrievedFields[1];
|
||||
expect(textField.fieldMeta).toBeDefined();
|
||||
|
||||
if (
|
||||
textField.fieldMeta &&
|
||||
typeof textField.fieldMeta === 'object' &&
|
||||
'type' in textField.fieldMeta
|
||||
) {
|
||||
expect(textField.fieldMeta.type).toBe('text');
|
||||
expect(textField.fieldMeta.label).toBe('Test Field');
|
||||
expect(textField.fieldMeta.placeholder).toBe('Test Placeholder');
|
||||
|
||||
if (textField.fieldMeta.type === 'text') {
|
||||
expect(textField.fieldMeta.text).toBe('Test Text');
|
||||
}
|
||||
} else {
|
||||
throw new Error('fieldMeta should be defined and contain advanced settings');
|
||||
}
|
||||
}).toPass();
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,243 @@
|
||||
import type { Page } from '@playwright/test';
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id';
|
||||
import { seedBlankDocument } from '@documenso/prisma/seed/documents';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
|
||||
import { apiSignin } from '../fixtures/authentication';
|
||||
|
||||
test.describe.configure({ mode: 'parallel', timeout: 60000 });
|
||||
|
||||
const setupDocument = async (page: Page) => {
|
||||
const { user, team } = await seedUser();
|
||||
|
||||
const document = await seedBlankDocument(user, team.id);
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
redirectPath: `/documents/${document.id}/edit`,
|
||||
});
|
||||
|
||||
return { user, team, document };
|
||||
};
|
||||
|
||||
const triggerAutosave = async (page: Page) => {
|
||||
await page.locator('#document-flow-form-container').click();
|
||||
await page.locator('#document-flow-form-container').blur();
|
||||
|
||||
await page.waitForTimeout(5000);
|
||||
};
|
||||
|
||||
test.describe('AutoSave Settings Step', () => {
|
||||
test('should autosave the title change', async ({ page }) => {
|
||||
const { user, document, team } = await setupDocument(page);
|
||||
|
||||
const newDocumentTitle = 'New Document Title';
|
||||
|
||||
await page.getByRole('textbox', { name: 'Title *' }).fill(newDocumentTitle);
|
||||
|
||||
await triggerAutosave(page);
|
||||
|
||||
await expect(async () => {
|
||||
const retrieved = await getDocumentById({
|
||||
documentId: document.id,
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
});
|
||||
|
||||
await expect(page.getByRole('textbox', { name: 'Title *' })).toHaveValue(retrieved.title);
|
||||
}).toPass();
|
||||
});
|
||||
|
||||
test('should autosave the language change', async ({ page }) => {
|
||||
const { user, document, team } = await setupDocument(page);
|
||||
|
||||
const newDocumentLanguage = 'French';
|
||||
const expectedLanguageCode = 'fr';
|
||||
|
||||
await page.getByRole('combobox').first().click();
|
||||
await page.getByRole('option', { name: newDocumentLanguage }).click();
|
||||
|
||||
await triggerAutosave(page);
|
||||
|
||||
await expect(async () => {
|
||||
const retrieved = await getDocumentById({
|
||||
documentId: document.id,
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
});
|
||||
|
||||
expect(retrieved.documentMeta?.language).toBe(expectedLanguageCode);
|
||||
}).toPass();
|
||||
});
|
||||
|
||||
test('should autosave the document access change', async ({ page }) => {
|
||||
const { user, document, team } = await setupDocument(page);
|
||||
|
||||
const access = 'Require account';
|
||||
const accessValue = 'ACCOUNT';
|
||||
|
||||
await page.getByRole('combobox').nth(1).click();
|
||||
await page.getByRole('option', { name: access }).click();
|
||||
|
||||
await triggerAutosave(page);
|
||||
|
||||
await expect(async () => {
|
||||
const retrieved = await getDocumentById({
|
||||
documentId: document.id,
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
});
|
||||
|
||||
expect(retrieved.authOptions?.globalAccessAuth).toContain(accessValue);
|
||||
}).toPass();
|
||||
});
|
||||
|
||||
test('should autosave the external ID change', async ({ page }) => {
|
||||
const { user, document, team } = await setupDocument(page);
|
||||
|
||||
const newExternalId = '1234567890';
|
||||
|
||||
await page.getByRole('button', { name: 'Advanced Options' }).click();
|
||||
|
||||
await page.getByRole('textbox', { name: 'External ID' }).fill(newExternalId);
|
||||
|
||||
await triggerAutosave(page);
|
||||
|
||||
await expect(async () => {
|
||||
const retrieved = await getDocumentById({
|
||||
documentId: document.id,
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
});
|
||||
|
||||
expect(retrieved.externalId).toBe(newExternalId);
|
||||
}).toPass();
|
||||
});
|
||||
|
||||
test('should autosave the allowed signature types change', async ({ page }) => {
|
||||
const { user, document, team } = await setupDocument(page);
|
||||
|
||||
await page.getByRole('button', { name: 'Advanced Options' }).click();
|
||||
|
||||
await page.getByRole('combobox').nth(3).click();
|
||||
await page.getByRole('option', { name: 'Draw' }).click();
|
||||
await page.getByRole('option', { name: 'Type' }).click();
|
||||
|
||||
await triggerAutosave(page);
|
||||
|
||||
await expect(async () => {
|
||||
const retrieved = await getDocumentById({
|
||||
documentId: document.id,
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
});
|
||||
|
||||
expect(retrieved.documentMeta?.drawSignatureEnabled).toBe(false);
|
||||
expect(retrieved.documentMeta?.typedSignatureEnabled).toBe(false);
|
||||
expect(retrieved.documentMeta?.uploadSignatureEnabled).toBe(true);
|
||||
}).toPass();
|
||||
});
|
||||
|
||||
test('should autosave the date format change', async ({ page }) => {
|
||||
const { user, document, team } = await setupDocument(page);
|
||||
|
||||
await page.getByRole('button', { name: 'Advanced Options' }).click();
|
||||
|
||||
await page.getByRole('combobox').nth(4).click();
|
||||
await page.getByRole('option', { name: 'ISO 8601', exact: true }).click();
|
||||
|
||||
await triggerAutosave(page);
|
||||
|
||||
await expect(async () => {
|
||||
const retrieved = await getDocumentById({
|
||||
documentId: document.id,
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
});
|
||||
|
||||
expect(retrieved.documentMeta?.dateFormat).toBe("yyyy-MM-dd'T'HH:mm:ss.SSSXXX");
|
||||
}).toPass();
|
||||
});
|
||||
|
||||
test('should autosave the timezone change', async ({ page }) => {
|
||||
const { user, document, team } = await setupDocument(page);
|
||||
|
||||
await page.getByRole('button', { name: 'Advanced Options' }).click();
|
||||
|
||||
await page.getByRole('combobox').nth(5).click();
|
||||
await page.getByRole('option', { name: 'Europe/London' }).click();
|
||||
|
||||
await triggerAutosave(page);
|
||||
|
||||
await expect(async () => {
|
||||
const retrieved = await getDocumentById({
|
||||
documentId: document.id,
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
});
|
||||
|
||||
expect(retrieved.documentMeta?.timezone).toBe('Europe/London');
|
||||
}).toPass();
|
||||
});
|
||||
|
||||
test('should autosave the redirect URL change', async ({ page }) => {
|
||||
const { user, document, team } = await setupDocument(page);
|
||||
|
||||
const newRedirectUrl = 'https://documenso.com/test/';
|
||||
|
||||
await page.getByRole('button', { name: 'Advanced Options' }).click();
|
||||
|
||||
await page.getByRole('textbox', { name: 'Redirect URL' }).fill(newRedirectUrl);
|
||||
|
||||
await triggerAutosave(page);
|
||||
|
||||
await expect(async () => {
|
||||
const retrieved = await getDocumentById({
|
||||
documentId: document.id,
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
});
|
||||
|
||||
expect(retrieved.documentMeta?.redirectUrl).toBe(newRedirectUrl);
|
||||
}).toPass();
|
||||
});
|
||||
|
||||
test('should autosave multiple field changes together', async ({ page }) => {
|
||||
const { user, document, team } = await setupDocument(page);
|
||||
|
||||
const newTitle = 'Updated Document Title';
|
||||
await page.getByRole('textbox', { name: 'Title *' }).fill(newTitle);
|
||||
|
||||
await page.getByRole('combobox').first().click();
|
||||
await page.getByRole('option', { name: 'German' }).click();
|
||||
|
||||
await page.getByRole('combobox').nth(1).click();
|
||||
await page.getByRole('option', { name: 'Require account' }).click();
|
||||
|
||||
await page.getByRole('button', { name: 'Advanced Options' }).click();
|
||||
const newExternalId = 'MULTI-TEST-123';
|
||||
await page.getByRole('textbox', { name: 'External ID' }).fill(newExternalId);
|
||||
|
||||
await page.getByRole('combobox').nth(5).click();
|
||||
await page.getByRole('option', { name: 'Europe/Berlin' }).click();
|
||||
|
||||
await triggerAutosave(page);
|
||||
|
||||
await expect(async () => {
|
||||
const retrieved = await getDocumentById({
|
||||
documentId: document.id,
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
});
|
||||
|
||||
expect(retrieved.title).toBe(newTitle);
|
||||
expect(retrieved.documentMeta?.language).toBe('de');
|
||||
expect(retrieved.authOptions?.globalAccessAuth).toContain('ACCOUNT');
|
||||
expect(retrieved.externalId).toBe(newExternalId);
|
||||
expect(retrieved.documentMeta?.timezone).toBe('Europe/Berlin');
|
||||
}).toPass();
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,168 @@
|
||||
import type { Page } from '@playwright/test';
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id';
|
||||
import { getRecipientsForDocument } from '@documenso/lib/server-only/recipient/get-recipients-for-document';
|
||||
import { seedBlankDocument } from '@documenso/prisma/seed/documents';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
|
||||
import { apiSignin } from '../fixtures/authentication';
|
||||
|
||||
test.describe.configure({ mode: 'parallel', timeout: 60000 });
|
||||
|
||||
const setupDocumentAndNavigateToSignersStep = async (page: Page) => {
|
||||
const { user, team } = await seedUser();
|
||||
const document = await seedBlankDocument(user, team.id);
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
redirectPath: `/documents/${document.id}/edit`,
|
||||
});
|
||||
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
|
||||
return { user, team, document };
|
||||
};
|
||||
|
||||
const triggerAutosave = async (page: Page) => {
|
||||
await page.locator('#document-flow-form-container').click();
|
||||
await page.locator('#document-flow-form-container').blur();
|
||||
|
||||
await page.waitForTimeout(5000);
|
||||
};
|
||||
|
||||
const addSignerAndSave = async (page: Page) => {
|
||||
await page.getByPlaceholder('Email').fill('recipient1@documenso.com');
|
||||
await page.getByPlaceholder('Name').fill('Recipient 1');
|
||||
|
||||
await triggerAutosave(page);
|
||||
};
|
||||
|
||||
test.describe('AutoSave Signers Step', () => {
|
||||
test('should autosave the signers addition', async ({ page }) => {
|
||||
const { user, document, team } = await setupDocumentAndNavigateToSignersStep(page);
|
||||
|
||||
await addSignerAndSave(page);
|
||||
|
||||
await expect(async () => {
|
||||
const retrievedRecipients = await getRecipientsForDocument({
|
||||
documentId: document.id,
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
});
|
||||
|
||||
expect(retrievedRecipients.length).toBe(1);
|
||||
expect(retrievedRecipients[0].email).toBe('recipient1@documenso.com');
|
||||
expect(retrievedRecipients[0].name).toBe('Recipient 1');
|
||||
}).toPass();
|
||||
});
|
||||
|
||||
test('should autosave the signer deletion', async ({ page }) => {
|
||||
const { user, document, team } = await setupDocumentAndNavigateToSignersStep(page);
|
||||
|
||||
await addSignerAndSave(page);
|
||||
|
||||
await page.getByRole('button', { name: 'Add myself' }).click();
|
||||
await triggerAutosave(page);
|
||||
|
||||
await page.getByTestId('remove-signer-button').first().click();
|
||||
await triggerAutosave(page);
|
||||
|
||||
await expect(async () => {
|
||||
const retrievedRecipients = await getRecipientsForDocument({
|
||||
documentId: document.id,
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
});
|
||||
|
||||
expect(retrievedRecipients.length).toBe(1);
|
||||
expect(retrievedRecipients[0].email).toBe(user.email);
|
||||
expect(retrievedRecipients[0].name).toBe(user.name);
|
||||
}).toPass();
|
||||
});
|
||||
|
||||
test('should autosave the signer update', async ({ page }) => {
|
||||
const { user, document, team } = await setupDocumentAndNavigateToSignersStep(page);
|
||||
|
||||
await addSignerAndSave(page);
|
||||
|
||||
await page.getByPlaceholder('Name').fill('Documenso Manager');
|
||||
await page.getByPlaceholder('Email').fill('manager@documenso.com');
|
||||
|
||||
await triggerAutosave(page);
|
||||
|
||||
await page.getByRole('combobox').click();
|
||||
await page.getByRole('option', { name: 'Receives copy' }).click();
|
||||
|
||||
await triggerAutosave(page);
|
||||
|
||||
await expect(async () => {
|
||||
const retrievedRecipients = await getRecipientsForDocument({
|
||||
documentId: document.id,
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
});
|
||||
|
||||
expect(retrievedRecipients.length).toBe(1);
|
||||
expect(retrievedRecipients[0].email).toBe('manager@documenso.com');
|
||||
expect(retrievedRecipients[0].name).toBe('Documenso Manager');
|
||||
expect(retrievedRecipients[0].role).toBe('CC');
|
||||
}).toPass();
|
||||
});
|
||||
|
||||
test('should autosave the signing order change', async ({ page }) => {
|
||||
const { user, document, team } = await setupDocumentAndNavigateToSignersStep(page);
|
||||
|
||||
await addSignerAndSave(page);
|
||||
|
||||
await page.getByRole('button', { name: 'Add signer' }).click();
|
||||
|
||||
await page.getByTestId('signer-email-input').nth(1).fill('recipient2@documenso.com');
|
||||
await page.getByLabel('Name').nth(1).fill('Recipient 2');
|
||||
|
||||
await page.getByRole('button', { name: 'Add Signer' }).click();
|
||||
|
||||
await page.getByTestId('signer-email-input').nth(2).fill('recipient3@documenso.com');
|
||||
await page.getByLabel('Name').nth(2).fill('Recipient 3');
|
||||
|
||||
await triggerAutosave(page);
|
||||
|
||||
await page.getByLabel('Enable signing order').check();
|
||||
await page.getByLabel('Allow signers to dictate next signer').check();
|
||||
await triggerAutosave(page);
|
||||
|
||||
await page.getByTestId('signing-order-input').nth(0).fill('3');
|
||||
await page.getByTestId('signing-order-input').nth(0).blur();
|
||||
await triggerAutosave(page);
|
||||
|
||||
await page.getByTestId('signing-order-input').nth(1).fill('1');
|
||||
await page.getByTestId('signing-order-input').nth(1).blur();
|
||||
await triggerAutosave(page);
|
||||
|
||||
await page.getByTestId('signing-order-input').nth(2).fill('2');
|
||||
await page.getByTestId('signing-order-input').nth(2).blur();
|
||||
await triggerAutosave(page);
|
||||
|
||||
await expect(async () => {
|
||||
const retrievedDocumentData = await getDocumentById({
|
||||
documentId: document.id,
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
});
|
||||
|
||||
const retrievedRecipients = await getRecipientsForDocument({
|
||||
documentId: document.id,
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
});
|
||||
|
||||
expect(retrievedDocumentData.documentMeta?.signingOrder).toBe('SEQUENTIAL');
|
||||
expect(retrievedDocumentData.documentMeta?.allowDictateNextSigner).toBe(true);
|
||||
expect(retrievedRecipients.length).toBe(3);
|
||||
expect(retrievedRecipients[0].signingOrder).toBe(2);
|
||||
expect(retrievedRecipients[1].signingOrder).toBe(3);
|
||||
expect(retrievedRecipients[2].signingOrder).toBe(1);
|
||||
}).toPass();
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,200 @@
|
||||
import type { Page } from '@playwright/test';
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id';
|
||||
import { seedBlankDocument } from '@documenso/prisma/seed/documents';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
|
||||
import { apiSignin } from '../fixtures/authentication';
|
||||
|
||||
test.describe.configure({ mode: 'parallel', timeout: 60000 });
|
||||
|
||||
export const setupDocumentAndNavigateToSubjectStep = async (page: Page) => {
|
||||
const { user, team } = await seedUser();
|
||||
const document = await seedBlankDocument(user, team.id);
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
redirectPath: `/documents/${document.id}/edit`,
|
||||
});
|
||||
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
|
||||
await page.getByPlaceholder('Email').fill('recipient1@documenso.com');
|
||||
await page.getByPlaceholder('Name').fill('Recipient 1');
|
||||
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator('canvas').click({
|
||||
position: {
|
||||
x: 100,
|
||||
y: 100,
|
||||
},
|
||||
});
|
||||
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Distribute Document' })).toBeVisible();
|
||||
|
||||
return { user, team, document };
|
||||
};
|
||||
|
||||
export const triggerAutosave = async (page: Page) => {
|
||||
await page.locator('#document-flow-form-container').click();
|
||||
await page.locator('#document-flow-form-container').blur();
|
||||
|
||||
await page.waitForTimeout(5000);
|
||||
};
|
||||
|
||||
test.describe('AutoSave Subject Step', () => {
|
||||
test('should autosave the subject field', async ({ page }) => {
|
||||
const { user, document, team } = await setupDocumentAndNavigateToSubjectStep(page);
|
||||
|
||||
const subject = 'Hello world!';
|
||||
|
||||
await page.getByRole('textbox', { name: 'Subject (Optional)' }).fill(subject);
|
||||
|
||||
await triggerAutosave(page);
|
||||
|
||||
await expect(async () => {
|
||||
const retrievedDocumentData = await getDocumentById({
|
||||
documentId: document.id,
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
});
|
||||
|
||||
await expect(page.getByRole('textbox', { name: 'Subject (Optional)' })).toHaveValue(
|
||||
retrievedDocumentData.documentMeta?.subject ?? '',
|
||||
);
|
||||
}).toPass();
|
||||
});
|
||||
|
||||
test('should autosave the message field', async ({ page }) => {
|
||||
const { user, document, team } = await setupDocumentAndNavigateToSubjectStep(page);
|
||||
|
||||
const message = 'Please review and sign this important document. Thank you!';
|
||||
|
||||
await page.getByRole('textbox', { name: 'Message (Optional)' }).fill(message);
|
||||
|
||||
await triggerAutosave(page);
|
||||
|
||||
await expect(async () => {
|
||||
const retrievedDocumentData = await getDocumentById({
|
||||
documentId: document.id,
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
});
|
||||
|
||||
await expect(page.getByRole('textbox', { name: 'Message (Optional)' })).toHaveValue(
|
||||
retrievedDocumentData.documentMeta?.message ?? '',
|
||||
);
|
||||
}).toPass();
|
||||
});
|
||||
|
||||
test('should autosave the email settings checkboxes', async ({ page }) => {
|
||||
const { user, document, team } = await setupDocumentAndNavigateToSubjectStep(page);
|
||||
|
||||
// Toggle some email settings checkboxes (randomly - some checked, some unchecked)
|
||||
await page.getByText('Send recipient signed email').click();
|
||||
await page.getByText('Send recipient removed email').click();
|
||||
await page.getByText('Send document completed email', { exact: true }).click();
|
||||
await page.getByText('Send document deleted email').click();
|
||||
|
||||
await triggerAutosave(page);
|
||||
|
||||
await expect(async () => {
|
||||
const retrievedDocumentData = await getDocumentById({
|
||||
documentId: document.id,
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
});
|
||||
|
||||
const emailSettings = retrievedDocumentData.documentMeta?.emailSettings;
|
||||
|
||||
await expect(page.getByText('Send recipient signed email')).toBeChecked({
|
||||
checked: emailSettings?.recipientSigned,
|
||||
});
|
||||
await expect(page.getByText('Send recipient removed email')).toBeChecked({
|
||||
checked: emailSettings?.recipientRemoved,
|
||||
});
|
||||
await expect(page.getByText('Send document completed email', { exact: true })).toBeChecked({
|
||||
checked: emailSettings?.documentCompleted,
|
||||
});
|
||||
await expect(page.getByText('Send document deleted email')).toBeChecked({
|
||||
checked: emailSettings?.documentDeleted,
|
||||
});
|
||||
|
||||
await expect(page.getByText('Send recipient signing request email')).toBeChecked({
|
||||
checked: emailSettings?.recipientSigningRequest,
|
||||
});
|
||||
await expect(page.getByText('Send document pending email')).toBeChecked({
|
||||
checked: emailSettings?.documentPending,
|
||||
});
|
||||
await expect(page.getByText('Send document completed email to the owner')).toBeChecked({
|
||||
checked: emailSettings?.ownerDocumentCompleted,
|
||||
});
|
||||
}).toPass();
|
||||
});
|
||||
|
||||
test('should autosave all fields and settings together', async ({ page }) => {
|
||||
const { user, document, team } = await setupDocumentAndNavigateToSubjectStep(page);
|
||||
|
||||
const subject = 'Combined Test Subject - Please Sign';
|
||||
const message =
|
||||
'This is a comprehensive test message for autosave functionality. Please review and sign at your earliest convenience.';
|
||||
|
||||
await page.getByRole('textbox', { name: 'Subject (Optional)' }).fill(subject);
|
||||
await page.getByRole('textbox', { name: 'Message (Optional)' }).fill(message);
|
||||
|
||||
await page.getByText('Send recipient signed email').click();
|
||||
await page.getByText('Send recipient removed email').click();
|
||||
await page.getByText('Send document completed email', { exact: true }).click();
|
||||
await page.getByText('Send document deleted email').click();
|
||||
|
||||
await triggerAutosave(page);
|
||||
|
||||
await expect(async () => {
|
||||
const retrievedDocumentData = await getDocumentById({
|
||||
documentId: document.id,
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
});
|
||||
|
||||
expect(retrievedDocumentData.documentMeta?.subject).toBe(subject);
|
||||
expect(retrievedDocumentData.documentMeta?.message).toBe(message);
|
||||
expect(retrievedDocumentData.documentMeta?.emailSettings).toBeDefined();
|
||||
|
||||
await expect(page.getByRole('textbox', { name: 'Subject (Optional)' })).toHaveValue(
|
||||
retrievedDocumentData.documentMeta?.subject ?? '',
|
||||
);
|
||||
await expect(page.getByRole('textbox', { name: 'Message (Optional)' })).toHaveValue(
|
||||
retrievedDocumentData.documentMeta?.message ?? '',
|
||||
);
|
||||
|
||||
await expect(page.getByText('Send recipient signed email')).toBeChecked({
|
||||
checked: retrievedDocumentData.documentMeta?.emailSettings?.recipientSigned,
|
||||
});
|
||||
await expect(page.getByText('Send recipient removed email')).toBeChecked({
|
||||
checked: retrievedDocumentData.documentMeta?.emailSettings?.recipientRemoved,
|
||||
});
|
||||
await expect(page.getByText('Send document completed email', { exact: true })).toBeChecked({
|
||||
checked: retrievedDocumentData.documentMeta?.emailSettings?.documentCompleted,
|
||||
});
|
||||
await expect(page.getByText('Send document deleted email')).toBeChecked({
|
||||
checked: retrievedDocumentData.documentMeta?.emailSettings?.documentDeleted,
|
||||
});
|
||||
|
||||
await expect(page.getByText('Send recipient signing request email')).toBeChecked({
|
||||
checked: retrievedDocumentData.documentMeta?.emailSettings?.recipientSigningRequest,
|
||||
});
|
||||
await expect(page.getByText('Send document pending email')).toBeChecked({
|
||||
checked: retrievedDocumentData.documentMeta?.emailSettings?.documentPending,
|
||||
});
|
||||
await expect(page.getByText('Send document completed email to the owner')).toBeChecked({
|
||||
checked: retrievedDocumentData.documentMeta?.emailSettings?.ownerDocumentCompleted,
|
||||
});
|
||||
}).toPass();
|
||||
});
|
||||
});
|
||||
@ -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();
|
||||
|
||||
@ -0,0 +1,304 @@
|
||||
import type { Page } from '@playwright/test';
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { getTemplateById } from '@documenso/lib/server-only/template/get-template-by-id';
|
||||
import { seedBlankTemplate } from '@documenso/prisma/seed/templates';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
|
||||
import { apiSignin } from '../fixtures/authentication';
|
||||
|
||||
const setupTemplateAndNavigateToFieldsStep = async (page: Page) => {
|
||||
const { user, team } = await seedUser();
|
||||
const template = await seedBlankTemplate(user, team.id);
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
redirectPath: `/templates/${template.id}/edit`,
|
||||
});
|
||||
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
|
||||
await page.getByPlaceholder('Email').fill('recipient1@documenso.com');
|
||||
await page.getByPlaceholder('Name').fill('Recipient 1');
|
||||
|
||||
await page.getByRole('button', { name: 'Add Placeholder Recipient' }).click();
|
||||
|
||||
await page.getByPlaceholder('Email').nth(1).fill('recipient2@documenso.com');
|
||||
await page.getByPlaceholder('Name').nth(1).fill('Recipient 2');
|
||||
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
|
||||
return { user, team, template };
|
||||
};
|
||||
|
||||
const triggerAutosave = async (page: Page) => {
|
||||
await page.locator('#document-flow-form-container').click();
|
||||
await page.locator('#document-flow-form-container').blur();
|
||||
|
||||
await page.waitForTimeout(5000);
|
||||
};
|
||||
|
||||
test.describe('AutoSave Fields Step', () => {
|
||||
test('should autosave the fields without advanced settings', async ({ page }) => {
|
||||
const { user, template, team } = await setupTemplateAndNavigateToFieldsStep(page);
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator('canvas').click({
|
||||
position: {
|
||||
x: 100,
|
||||
y: 100,
|
||||
},
|
||||
});
|
||||
|
||||
await page.getByRole('button', { name: 'Text' }).click();
|
||||
await page.locator('canvas').click({
|
||||
position: {
|
||||
x: 100,
|
||||
y: 200,
|
||||
},
|
||||
});
|
||||
|
||||
await page.getByTestId('field-advanced-settings-footer').waitFor({ state: 'visible' });
|
||||
|
||||
await page
|
||||
.getByTestId('field-advanced-settings-footer')
|
||||
.getByRole('button', { name: 'Cancel' })
|
||||
.click();
|
||||
|
||||
await triggerAutosave(page);
|
||||
|
||||
await page.getByRole('combobox').click();
|
||||
await page.getByRole('option', { name: 'Recipient 2 (recipient2@documenso.com)' }).click();
|
||||
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator('canvas').click({
|
||||
position: {
|
||||
x: 100,
|
||||
y: 500,
|
||||
},
|
||||
});
|
||||
|
||||
await triggerAutosave(page);
|
||||
|
||||
await expect(async () => {
|
||||
const retrievedFields = await getTemplateById({
|
||||
id: template.id,
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
});
|
||||
|
||||
const fields = retrievedFields.fields;
|
||||
|
||||
expect(fields.length).toBe(3);
|
||||
expect(fields[0].type).toBe('SIGNATURE');
|
||||
expect(fields[1].type).toBe('TEXT');
|
||||
expect(fields[2].type).toBe('SIGNATURE');
|
||||
}).toPass();
|
||||
});
|
||||
|
||||
test('should autosave the field deletion', async ({ page }) => {
|
||||
const { user, template, team } = await setupTemplateAndNavigateToFieldsStep(page);
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator('canvas').click({
|
||||
position: {
|
||||
x: 100,
|
||||
y: 100,
|
||||
},
|
||||
});
|
||||
|
||||
await page.getByRole('button', { name: 'Text' }).click();
|
||||
await page.locator('canvas').click({
|
||||
position: {
|
||||
x: 100,
|
||||
y: 200,
|
||||
},
|
||||
});
|
||||
|
||||
await page.getByTestId('field-advanced-settings-footer').waitFor({ state: 'visible' });
|
||||
|
||||
await page
|
||||
.getByTestId('field-advanced-settings-footer')
|
||||
.getByRole('button', { name: 'Cancel' })
|
||||
.click();
|
||||
|
||||
await triggerAutosave(page);
|
||||
|
||||
await page.getByRole('combobox').click();
|
||||
await page.getByRole('option', { name: 'Recipient 2 (recipient2@documenso.com)' }).click();
|
||||
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator('canvas').click({
|
||||
position: {
|
||||
x: 100,
|
||||
y: 500,
|
||||
},
|
||||
});
|
||||
|
||||
await triggerAutosave(page);
|
||||
|
||||
await page.getByRole('combobox').click();
|
||||
await page.getByRole('option', { name: 'Recipient 1 (recipient1@documenso.com)' }).click();
|
||||
|
||||
await page.getByText('Text').nth(1).click();
|
||||
await page.getByRole('button', { name: 'Remove' }).click();
|
||||
|
||||
await triggerAutosave(page);
|
||||
|
||||
await expect(async () => {
|
||||
const retrievedFields = await getTemplateById({
|
||||
id: template.id,
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
});
|
||||
|
||||
const fields = retrievedFields.fields;
|
||||
|
||||
expect(fields.length).toBe(2);
|
||||
expect(fields[0].type).toBe('SIGNATURE');
|
||||
expect(fields[1].type).toBe('SIGNATURE');
|
||||
}).toPass();
|
||||
});
|
||||
|
||||
test('should autosave the field duplication', async ({ page }) => {
|
||||
const { user, template, team } = await setupTemplateAndNavigateToFieldsStep(page);
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator('canvas').click({
|
||||
position: {
|
||||
x: 100,
|
||||
y: 100,
|
||||
},
|
||||
});
|
||||
|
||||
await page.getByRole('button', { name: 'Text' }).click();
|
||||
await page.locator('canvas').click({
|
||||
position: {
|
||||
x: 100,
|
||||
y: 200,
|
||||
},
|
||||
});
|
||||
|
||||
await page.getByTestId('field-advanced-settings-footer').waitFor({ state: 'visible' });
|
||||
|
||||
await page
|
||||
.getByTestId('field-advanced-settings-footer')
|
||||
.getByRole('button', { name: 'Cancel' })
|
||||
.click();
|
||||
|
||||
await triggerAutosave(page);
|
||||
|
||||
await page.getByRole('combobox').click();
|
||||
await page.getByRole('option', { name: 'Recipient 2 (recipient2@documenso.com)' }).click();
|
||||
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator('canvas').click({
|
||||
position: {
|
||||
x: 100,
|
||||
y: 500,
|
||||
},
|
||||
});
|
||||
|
||||
await triggerAutosave(page);
|
||||
|
||||
await page.getByRole('combobox').click();
|
||||
await page.getByRole('option', { name: 'Recipient 1 (recipient1@documenso.com)' }).click();
|
||||
|
||||
await page.getByText('Signature').nth(1).click();
|
||||
await page.getByRole('button', { name: 'Duplicate', exact: true }).click();
|
||||
|
||||
await triggerAutosave(page);
|
||||
|
||||
await expect(async () => {
|
||||
const retrievedFields = await getTemplateById({
|
||||
id: template.id,
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
});
|
||||
|
||||
const fields = retrievedFields.fields;
|
||||
|
||||
expect(fields.length).toBe(4);
|
||||
expect(fields[0].type).toBe('SIGNATURE');
|
||||
expect(fields[1].type).toBe('TEXT');
|
||||
expect(fields[2].type).toBe('SIGNATURE');
|
||||
expect(fields[3].type).toBe('SIGNATURE');
|
||||
}).toPass();
|
||||
});
|
||||
|
||||
test('should autosave the fields with advanced settings', async ({ page }) => {
|
||||
const { user, template, team } = await setupTemplateAndNavigateToFieldsStep(page);
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator('canvas').click({
|
||||
position: {
|
||||
x: 100,
|
||||
y: 100,
|
||||
},
|
||||
});
|
||||
|
||||
await page.getByRole('button', { name: 'Text' }).click();
|
||||
await page.locator('canvas').click({
|
||||
position: {
|
||||
x: 100,
|
||||
y: 200,
|
||||
},
|
||||
});
|
||||
|
||||
await page.getByRole('textbox', { name: 'Field label' }).fill('Test Field');
|
||||
await page.getByRole('textbox', { name: 'Field placeholder' }).fill('Test Placeholder');
|
||||
await page.getByRole('textbox', { name: 'Add text to the field' }).fill('Test Text');
|
||||
|
||||
await page.getByTestId('field-advanced-settings-footer').waitFor({ state: 'visible' });
|
||||
|
||||
await page
|
||||
.getByTestId('field-advanced-settings-footer')
|
||||
.getByRole('button', { name: 'Save' })
|
||||
.click();
|
||||
|
||||
await page.waitForTimeout(2500);
|
||||
await triggerAutosave(page);
|
||||
|
||||
await expect(async () => {
|
||||
const retrievedTemplate = await getTemplateById({
|
||||
id: template.id,
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
});
|
||||
|
||||
const fields = retrievedTemplate.fields;
|
||||
|
||||
expect(fields.length).toBe(2);
|
||||
expect(fields[0].type).toBe('SIGNATURE');
|
||||
expect(fields[1].type).toBe('TEXT');
|
||||
|
||||
const textField = fields[1];
|
||||
expect(textField.fieldMeta).toBeDefined();
|
||||
|
||||
if (
|
||||
textField.fieldMeta &&
|
||||
typeof textField.fieldMeta === 'object' &&
|
||||
'type' in textField.fieldMeta
|
||||
) {
|
||||
expect(textField.fieldMeta.type).toBe('text');
|
||||
expect(textField.fieldMeta.label).toBe('Test Field');
|
||||
expect(textField.fieldMeta.placeholder).toBe('Test Placeholder');
|
||||
|
||||
if (textField.fieldMeta.type === 'text') {
|
||||
expect(textField.fieldMeta.text).toBe('Test Text');
|
||||
}
|
||||
} else {
|
||||
throw new Error('fieldMeta should be defined and contain advanced settings');
|
||||
}
|
||||
}).toPass();
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,244 @@
|
||||
import type { Page } from '@playwright/test';
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { getTemplateById } from '@documenso/lib/server-only/template/get-template-by-id';
|
||||
import { seedBlankTemplate } from '@documenso/prisma/seed/templates';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
|
||||
import { apiSignin } from '../fixtures/authentication';
|
||||
|
||||
test.describe.configure({ mode: 'parallel', timeout: 60000 });
|
||||
|
||||
const setupTemplate = async (page: Page) => {
|
||||
const { user, team } = await seedUser();
|
||||
const template = await seedBlankTemplate(user, team.id);
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
redirectPath: `/templates/${template.id}/edit`,
|
||||
});
|
||||
|
||||
return { user, team, template };
|
||||
};
|
||||
|
||||
const triggerAutosave = async (page: Page) => {
|
||||
await page.locator('#document-flow-form-container').click();
|
||||
await page.locator('#document-flow-form-container').blur();
|
||||
|
||||
await page.waitForTimeout(5000);
|
||||
};
|
||||
|
||||
test.describe('AutoSave Settings Step - Templates', () => {
|
||||
test('should autosave the title change', async ({ page }) => {
|
||||
const { user, template, team } = await setupTemplate(page);
|
||||
|
||||
const newTemplateTitle = 'New Template Title';
|
||||
|
||||
await page.getByRole('textbox', { name: 'Title *' }).fill(newTemplateTitle);
|
||||
|
||||
await triggerAutosave(page);
|
||||
|
||||
await expect(async () => {
|
||||
const retrievedTemplate = await getTemplateById({
|
||||
id: template.id,
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
});
|
||||
|
||||
await expect(page.getByRole('textbox', { name: 'Title *' })).toHaveValue(
|
||||
retrievedTemplate.title,
|
||||
);
|
||||
}).toPass();
|
||||
});
|
||||
|
||||
test('should autosave the language change', async ({ page }) => {
|
||||
const { user, template, team } = await setupTemplate(page);
|
||||
|
||||
const newTemplateLanguage = 'French';
|
||||
const expectedLanguageCode = 'fr';
|
||||
|
||||
await page.getByRole('combobox').first().click();
|
||||
await page.getByRole('option', { name: newTemplateLanguage }).click();
|
||||
|
||||
await triggerAutosave(page);
|
||||
|
||||
await expect(async () => {
|
||||
const retrievedTemplate = await getTemplateById({
|
||||
id: template.id,
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
});
|
||||
|
||||
expect(retrievedTemplate.templateMeta?.language).toBe(expectedLanguageCode);
|
||||
}).toPass();
|
||||
});
|
||||
|
||||
test('should autosave the template access change', async ({ page }) => {
|
||||
const { user, template, team } = await setupTemplate(page);
|
||||
|
||||
const access = 'Require account';
|
||||
const accessValue = 'ACCOUNT';
|
||||
|
||||
await page.getByRole('combobox').nth(1).click();
|
||||
await page.getByRole('option', { name: access }).click();
|
||||
|
||||
await triggerAutosave(page);
|
||||
|
||||
await expect(async () => {
|
||||
const retrievedTemplate = await getTemplateById({
|
||||
id: template.id,
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
});
|
||||
|
||||
expect(retrievedTemplate.authOptions?.globalAccessAuth).toContain(accessValue);
|
||||
}).toPass();
|
||||
});
|
||||
|
||||
test('should autosave the external ID change', async ({ page }) => {
|
||||
const { user, template, team } = await setupTemplate(page);
|
||||
|
||||
const newExternalId = '1234567890';
|
||||
|
||||
await page.getByRole('button', { name: 'Advanced Options' }).click();
|
||||
|
||||
await page.getByRole('textbox', { name: 'External ID' }).fill(newExternalId);
|
||||
|
||||
await triggerAutosave(page);
|
||||
|
||||
await expect(async () => {
|
||||
const retrievedTemplate = await getTemplateById({
|
||||
id: template.id,
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
});
|
||||
|
||||
expect(retrievedTemplate.externalId).toBe(newExternalId);
|
||||
}).toPass();
|
||||
});
|
||||
|
||||
test('should autosave the allowed signature types change', async ({ page }) => {
|
||||
const { user, template, team } = await setupTemplate(page);
|
||||
|
||||
await page.getByRole('button', { name: 'Advanced Options' }).click();
|
||||
|
||||
await page.getByRole('combobox').nth(4).click();
|
||||
await page.getByRole('option', { name: 'Draw' }).click();
|
||||
await page.getByRole('option', { name: 'Type' }).click();
|
||||
|
||||
await triggerAutosave(page);
|
||||
|
||||
await expect(async () => {
|
||||
const retrievedTemplate = await getTemplateById({
|
||||
id: template.id,
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
});
|
||||
|
||||
expect(retrievedTemplate.templateMeta?.drawSignatureEnabled).toBe(false);
|
||||
expect(retrievedTemplate.templateMeta?.typedSignatureEnabled).toBe(false);
|
||||
expect(retrievedTemplate.templateMeta?.uploadSignatureEnabled).toBe(true);
|
||||
}).toPass();
|
||||
});
|
||||
|
||||
test('should autosave the date format change', async ({ page }) => {
|
||||
const { user, template, team } = await setupTemplate(page);
|
||||
|
||||
await page.getByRole('button', { name: 'Advanced Options' }).click();
|
||||
|
||||
await page.getByRole('combobox').nth(5).click();
|
||||
await page.getByRole('option', { name: 'ISO 8601', exact: true }).click();
|
||||
|
||||
await triggerAutosave(page);
|
||||
|
||||
await expect(async () => {
|
||||
const retrievedTemplate = await getTemplateById({
|
||||
id: template.id,
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
});
|
||||
|
||||
expect(retrievedTemplate.templateMeta?.dateFormat).toBe("yyyy-MM-dd'T'HH:mm:ss.SSSXXX");
|
||||
}).toPass();
|
||||
});
|
||||
|
||||
test('should autosave the timezone change', async ({ page }) => {
|
||||
const { user, template, team } = await setupTemplate(page);
|
||||
|
||||
await page.getByRole('button', { name: 'Advanced Options' }).click();
|
||||
|
||||
await page.getByRole('combobox').nth(6).click();
|
||||
await page.getByRole('option', { name: 'Europe/London' }).click();
|
||||
|
||||
await triggerAutosave(page);
|
||||
|
||||
await expect(async () => {
|
||||
const retrievedTemplate = await getTemplateById({
|
||||
id: template.id,
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
});
|
||||
|
||||
expect(retrievedTemplate.templateMeta?.timezone).toBe('Europe/London');
|
||||
}).toPass();
|
||||
});
|
||||
|
||||
test('should autosave the redirect URL change', async ({ page }) => {
|
||||
const { user, template, team } = await setupTemplate(page);
|
||||
|
||||
const newRedirectUrl = 'https://documenso.com/test/';
|
||||
|
||||
await page.getByRole('button', { name: 'Advanced Options' }).click();
|
||||
|
||||
await page.getByRole('textbox', { name: 'Redirect URL' }).fill(newRedirectUrl);
|
||||
|
||||
await triggerAutosave(page);
|
||||
|
||||
await expect(async () => {
|
||||
const retrievedTemplate = await getTemplateById({
|
||||
id: template.id,
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
});
|
||||
|
||||
expect(retrievedTemplate.templateMeta?.redirectUrl).toBe(newRedirectUrl);
|
||||
}).toPass();
|
||||
});
|
||||
|
||||
test('should autosave multiple field changes together', async ({ page }) => {
|
||||
const { user, template, team } = await setupTemplate(page);
|
||||
|
||||
const newTitle = 'Updated Template Title';
|
||||
await page.getByRole('textbox', { name: 'Title *' }).fill(newTitle);
|
||||
|
||||
await page.getByRole('combobox').first().click();
|
||||
await page.getByRole('option', { name: 'German' }).click();
|
||||
|
||||
await page.getByRole('combobox').nth(1).click();
|
||||
await page.getByRole('option', { name: 'Require account' }).click();
|
||||
|
||||
await page.getByRole('button', { name: 'Advanced Options' }).click();
|
||||
const newExternalId = 'MULTI-TEST-123';
|
||||
await page.getByRole('textbox', { name: 'External ID' }).fill(newExternalId);
|
||||
|
||||
await page.getByRole('combobox').nth(6).click();
|
||||
await page.getByRole('option', { name: 'Europe/Berlin' }).click();
|
||||
|
||||
await triggerAutosave(page);
|
||||
|
||||
await expect(async () => {
|
||||
const retrievedTemplate = await getTemplateById({
|
||||
id: template.id,
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
});
|
||||
|
||||
expect(retrievedTemplate.title).toBe(newTitle);
|
||||
expect(retrievedTemplate.templateMeta?.language).toBe('de');
|
||||
expect(retrievedTemplate.authOptions?.globalAccessAuth).toContain('ACCOUNT');
|
||||
expect(retrievedTemplate.externalId).toBe(newExternalId);
|
||||
expect(retrievedTemplate.templateMeta?.timezone).toBe('Europe/Berlin');
|
||||
}).toPass();
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,174 @@
|
||||
import type { Page } from '@playwright/test';
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { getRecipientsForTemplate } from '@documenso/lib/server-only/recipient/get-recipients-for-template';
|
||||
import { getTemplateById } from '@documenso/lib/server-only/template/get-template-by-id';
|
||||
import { seedBlankTemplate } from '@documenso/prisma/seed/templates';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
|
||||
import { apiSignin } from '../fixtures/authentication';
|
||||
|
||||
test.describe.configure({ mode: 'parallel', timeout: 60000 });
|
||||
|
||||
const setupTemplateAndNavigateToSignersStep = async (page: Page) => {
|
||||
const { user, team } = await seedUser();
|
||||
const template = await seedBlankTemplate(user, team.id);
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
redirectPath: `/templates/${template.id}/edit`,
|
||||
});
|
||||
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
|
||||
return { user, team, template };
|
||||
};
|
||||
|
||||
const triggerAutosave = async (page: Page) => {
|
||||
await page.locator('#document-flow-form-container').click();
|
||||
await page.locator('#document-flow-form-container').blur();
|
||||
|
||||
await page.waitForTimeout(5000);
|
||||
};
|
||||
|
||||
const addSignerAndSave = async (page: Page) => {
|
||||
await page.getByPlaceholder('Email').fill('recipient1@documenso.com');
|
||||
await page.getByPlaceholder('Name').fill('Recipient 1');
|
||||
|
||||
await triggerAutosave(page);
|
||||
};
|
||||
|
||||
test.describe('AutoSave Signers Step - Templates', () => {
|
||||
test('should autosave the signers addition', async ({ page }) => {
|
||||
const { user, template, team } = await setupTemplateAndNavigateToSignersStep(page);
|
||||
|
||||
await addSignerAndSave(page);
|
||||
|
||||
await expect(async () => {
|
||||
const retrievedRecipients = await getRecipientsForTemplate({
|
||||
templateId: template.id,
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
});
|
||||
|
||||
expect(retrievedRecipients.length).toBe(1);
|
||||
expect(retrievedRecipients[0].email).toBe('recipient1@documenso.com');
|
||||
expect(retrievedRecipients[0].name).toBe('Recipient 1');
|
||||
}).toPass();
|
||||
});
|
||||
|
||||
test('should autosave the signer deletion', async ({ page }) => {
|
||||
const { user, template, team } = await setupTemplateAndNavigateToSignersStep(page);
|
||||
|
||||
await addSignerAndSave(page);
|
||||
|
||||
await page.getByRole('button', { name: 'Add myself' }).click();
|
||||
await triggerAutosave(page);
|
||||
|
||||
await page.getByTestId('remove-placeholder-recipient-button').first().click();
|
||||
await triggerAutosave(page);
|
||||
|
||||
await expect(async () => {
|
||||
const retrievedRecipients = await getRecipientsForTemplate({
|
||||
templateId: template.id,
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
});
|
||||
|
||||
expect(retrievedRecipients.length).toBe(1);
|
||||
expect(retrievedRecipients[0].email).toBe(user.email);
|
||||
expect(retrievedRecipients[0].name).toBe(user.name);
|
||||
}).toPass();
|
||||
});
|
||||
|
||||
test('should autosave the signer update', async ({ page }) => {
|
||||
const { user, template, team } = await setupTemplateAndNavigateToSignersStep(page);
|
||||
|
||||
await addSignerAndSave(page);
|
||||
|
||||
await page.getByPlaceholder('Name').fill('Documenso Manager');
|
||||
await page.getByPlaceholder('Email').fill('manager@documenso.com');
|
||||
|
||||
await triggerAutosave(page);
|
||||
|
||||
await page.getByRole('combobox').click();
|
||||
await page.getByRole('option', { name: 'Receives copy' }).click();
|
||||
|
||||
await triggerAutosave(page);
|
||||
|
||||
await expect(async () => {
|
||||
const retrievedRecipients = await getRecipientsForTemplate({
|
||||
templateId: template.id,
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
});
|
||||
|
||||
expect(retrievedRecipients.length).toBe(1);
|
||||
expect(retrievedRecipients[0].email).toBe('manager@documenso.com');
|
||||
expect(retrievedRecipients[0].name).toBe('Documenso Manager');
|
||||
expect(retrievedRecipients[0].role).toBe('CC');
|
||||
}).toPass();
|
||||
});
|
||||
|
||||
test('should autosave the signing order change', async ({ page }) => {
|
||||
const { user, template, team } = await setupTemplateAndNavigateToSignersStep(page);
|
||||
|
||||
await addSignerAndSave(page);
|
||||
|
||||
await page.getByRole('button', { name: 'Add placeholder recipient' }).click();
|
||||
|
||||
await page
|
||||
.getByTestId('placeholder-recipient-email-input')
|
||||
.nth(1)
|
||||
.fill('recipient2@documenso.com');
|
||||
await page.getByTestId('placeholder-recipient-name-input').nth(1).fill('Recipient 2');
|
||||
|
||||
await page.getByRole('button', { name: 'Add placeholder recipient' }).click();
|
||||
|
||||
await page
|
||||
.getByTestId('placeholder-recipient-email-input')
|
||||
.nth(2)
|
||||
.fill('recipient3@documenso.com');
|
||||
await page.getByTestId('placeholder-recipient-name-input').nth(2).fill('Recipient 3');
|
||||
|
||||
await triggerAutosave(page);
|
||||
|
||||
await page.getByLabel('Enable signing order').check();
|
||||
await page.getByLabel('Allow signers to dictate next signer').check();
|
||||
await triggerAutosave(page);
|
||||
|
||||
await page.getByTestId('placeholder-recipient-signing-order-input').nth(0).fill('3');
|
||||
await page.getByTestId('placeholder-recipient-signing-order-input').nth(0).blur();
|
||||
await triggerAutosave(page);
|
||||
|
||||
await page.getByTestId('placeholder-recipient-signing-order-input').nth(1).fill('1');
|
||||
await page.getByTestId('placeholder-recipient-signing-order-input').nth(1).blur();
|
||||
await triggerAutosave(page);
|
||||
|
||||
await page.getByTestId('placeholder-recipient-signing-order-input').nth(2).fill('2');
|
||||
await page.getByTestId('placeholder-recipient-signing-order-input').nth(2).blur();
|
||||
await triggerAutosave(page);
|
||||
|
||||
await expect(async () => {
|
||||
const retrievedTemplate = await getTemplateById({
|
||||
id: template.id,
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
});
|
||||
|
||||
const retrievedRecipients = await getRecipientsForTemplate({
|
||||
templateId: template.id,
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
});
|
||||
|
||||
expect(retrievedTemplate.templateMeta?.signingOrder).toBe('SEQUENTIAL');
|
||||
expect(retrievedTemplate.templateMeta?.allowDictateNextSigner).toBe(true);
|
||||
expect(retrievedRecipients.length).toBe(3);
|
||||
expect(retrievedRecipients[0].signingOrder).toBe(2);
|
||||
expect(retrievedRecipients[1].signingOrder).toBe(3);
|
||||
expect(retrievedRecipients[2].signingOrder).toBe(1);
|
||||
}).toPass();
|
||||
});
|
||||
});
|
||||
@ -17,7 +17,7 @@ export default defineConfig({
|
||||
testDir: './e2e',
|
||||
/* Run tests in files in parallel */
|
||||
fullyParallel: false,
|
||||
workers: 1,
|
||||
workers: 4,
|
||||
maxFailures: process.env.CI ? 1 : undefined,
|
||||
/* Fail the build on CI if you accidentally left test.only in the source code. */
|
||||
forbidOnly: !!process.env.CI,
|
||||
|
||||
@ -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;
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
37
packages/auth/server/lib/utils/delete-account-provider.ts
Normal file
37
packages/auth/server/lib/utils/delete-account-provider.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
});
|
||||
};
|
||||
32
packages/auth/server/lib/utils/get-accounts.ts
Normal file
32
packages/auth/server/lib/utils/get-accounts.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
};
|
||||
@ -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({
|
||||
@ -199,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,
|
||||
};
|
||||
};
|
||||
|
||||
@ -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);
|
||||
};
|
||||
94
packages/auth/server/lib/utils/organisation-portal.ts
Normal file
94
packages/auth/server/lib/utils/organisation-portal.ts
Normal 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,
|
||||
},
|
||||
};
|
||||
};
|
||||
25
packages/auth/server/routes/account.ts
Normal file
25
packages/auth/server/routes/account.ts
Normal 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 });
|
||||
});
|
||||
@ -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.
|
||||
*/
|
||||
|
||||
@ -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,
|
||||
});
|
||||
|
||||
|
||||
@ -4,6 +4,7 @@ import { z } from 'zod';
|
||||
|
||||
import { GoogleAuthOptions, OidcAuthOptions } from '../config';
|
||||
import { handleOAuthAuthorizeUrl } from '../lib/utils/handle-oauth-authorize-url';
|
||||
import { getOrganisationAuthenticationPortalOptions } from '../lib/utils/organisation-portal';
|
||||
import type { HonoAuthContext } from '../types/context';
|
||||
|
||||
const ZOAuthAuthorizeSchema = z.object({
|
||||
@ -34,4 +35,20 @@ export const oauthRoute = new Hono<HonoAuthContext>()
|
||||
clientOptions: OidcAuthOptions,
|
||||
redirectPath,
|
||||
});
|
||||
})
|
||||
/**
|
||||
* Organisation OIDC authorize endpoint.
|
||||
*/
|
||||
.post('/authorize/oidc/org/:orgUrl', async (c) => {
|
||||
const orgUrl = c.req.param('orgUrl');
|
||||
|
||||
const { clientOptions } = await getOrganisationAuthenticationPortalOptions({
|
||||
type: 'url',
|
||||
organisationUrl: orgUrl,
|
||||
});
|
||||
|
||||
return await handleOAuthAuthorizeUrl({
|
||||
c,
|
||||
clientOptions,
|
||||
});
|
||||
});
|
||||
|
||||
163
packages/ee/server-only/lib/link-organisation-account.ts
Normal file
163
packages/ee/server-only/lib/link-organisation-account.ts
Normal 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 },
|
||||
);
|
||||
};
|
||||
@ -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,
|
||||
});
|
||||
};
|
||||
BIN
packages/email/static/building-2.png
Normal file
BIN
packages/email/static/building-2.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 297 B |
@ -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;
|
||||
31
packages/lib/client-only/hooks/use-autosave.ts
Normal file
31
packages/lib/client-only/hooks/use-autosave.ts
Normal file
@ -0,0 +1,31 @@
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
|
||||
export const useAutoSave = <T>(onSave: (data: T) => Promise<void>) => {
|
||||
const saveTimeoutRef = useRef<NodeJS.Timeout>();
|
||||
|
||||
const saveFormData = async (data: T) => {
|
||||
try {
|
||||
await onSave(data);
|
||||
} catch (error) {
|
||||
console.error('Auto-save failed:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const scheduleSave = useCallback((data: T) => {
|
||||
if (saveTimeoutRef.current) {
|
||||
clearTimeout(saveTimeoutRef.current);
|
||||
}
|
||||
|
||||
saveTimeoutRef.current = setTimeout(() => void saveFormData(data), 2000);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (saveTimeoutRef.current) {
|
||||
clearTimeout(saveTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return { scheduleSave };
|
||||
};
|
||||
@ -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',
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -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,
|
||||
|
||||
21
packages/lib/server-only/cert/cert-status.ts
Normal file
21
packages/lib/server-only/cert/cert-status.ts
Normal file
@ -0,0 +1,21 @@
|
||||
import * as fs from 'node:fs';
|
||||
|
||||
import { env } from '@documenso/lib/utils/env';
|
||||
|
||||
export type CertificateStatus = {
|
||||
isAvailable: boolean;
|
||||
};
|
||||
|
||||
export const getCertificateStatus = (): CertificateStatus => {
|
||||
const defaultPath =
|
||||
env('NODE_ENV') === 'production' ? '/opt/documenso/cert.p12' : './example/cert.p12';
|
||||
const filePath = env('NEXT_PRIVATE_SIGNING_LOCAL_FILE_PATH') || defaultPath;
|
||||
|
||||
try {
|
||||
fs.accessSync(filePath, fs.constants.F_OK | fs.constants.R_OK);
|
||||
const stats = fs.statSync(filePath);
|
||||
return { isAvailable: stats.size > 0 };
|
||||
} catch {
|
||||
return { isAvailable: false };
|
||||
}
|
||||
};
|
||||
@ -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,
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
@ -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,
|
||||
|
||||
108
packages/lib/server-only/recipient/get-recipient-suggestions.ts
Normal file
108
packages/lib/server-only/recipient/get-recipient-suggestions.ts
Normal 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;
|
||||
};
|
||||
@ -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',
|
||||
|
||||
@ -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 });
|
||||
};
|
||||
@ -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',
|
||||
},
|
||||
});
|
||||
};
|
||||
@ -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',
|
||||
},
|
||||
});
|
||||
};
|
||||
@ -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: {
|
||||
|
||||
@ -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 = {
|
||||
@ -22,6 +25,7 @@ export const verifyEmail = async ({ token }: VerifyEmailProps) => {
|
||||
},
|
||||
where: {
|
||||
token,
|
||||
identifier: USER_SIGNUP_VERIFICATION_TOKEN_IDENTIFIER,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import type { z } from 'zod';
|
||||
import { z } from 'zod';
|
||||
|
||||
import OrganisationClaimSchema from '@documenso/prisma/generated/zod/modelSchema/OrganisationClaimSchema';
|
||||
import { OrganisationSchema } from '@documenso/prisma/generated/zod/modelSchema/OrganisationSchema';
|
||||
@ -43,3 +43,19 @@ export const ZOrganisationLiteSchema = OrganisationSchema.pick({
|
||||
* A version of the organisation response schema when returning multiple organisations at once from a single API endpoint.
|
||||
*/
|
||||
export const ZOrganisationManySchema = ZOrganisationLiteSchema;
|
||||
|
||||
export const ZOrganisationAccountLinkMetadataSchema = z.object({
|
||||
type: z.enum(['link', 'create']),
|
||||
userId: z.number(),
|
||||
organisationId: z.string(),
|
||||
oauthConfig: z.object({
|
||||
providerAccountId: z.string(),
|
||||
accessToken: z.string(),
|
||||
expiresAt: z.number(),
|
||||
idToken: z.string(),
|
||||
}),
|
||||
});
|
||||
|
||||
export type TOrganisationAccountLinkMetadata = z.infer<
|
||||
typeof ZOrganisationAccountLinkMetadataSchema
|
||||
>;
|
||||
|
||||
@ -28,6 +28,8 @@ export const ZClaimFlagsSchema = z.object({
|
||||
embedSigningWhiteLabel: z.boolean().optional(),
|
||||
|
||||
cfr21: z.boolean().optional(),
|
||||
|
||||
authenticationPortal: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export type TClaimFlags = z.infer<typeof ZClaimFlagsSchema>;
|
||||
@ -76,6 +78,10 @@ export const SUBSCRIPTION_CLAIM_FEATURE_FLAGS: Record<
|
||||
key: 'cfr21',
|
||||
label: '21 CFR',
|
||||
},
|
||||
authenticationPortal: {
|
||||
key: 'authenticationPortal',
|
||||
label: 'Authentication portal',
|
||||
},
|
||||
};
|
||||
|
||||
export enum INTERNAL_CLAIM_ID {
|
||||
@ -157,6 +163,7 @@ export const internalClaims: InternalClaims = {
|
||||
embedSigning: true,
|
||||
embedSigningWhiteLabel: true,
|
||||
cfr21: true,
|
||||
authenticationPortal: true,
|
||||
},
|
||||
},
|
||||
[INTERNAL_CLAIM_ID.EARLY_ADOPTER]: {
|
||||
|
||||
@ -16,6 +16,7 @@ type DatabaseIdPrefix =
|
||||
| 'org_email'
|
||||
| 'org_claim'
|
||||
| 'org_group'
|
||||
| 'org_sso'
|
||||
| 'org_setting'
|
||||
| 'member'
|
||||
| 'member_invite'
|
||||
|
||||
13
packages/lib/utils/organisation-authentication-portal.ts
Normal file
13
packages/lib/utils/organisation-authentication-portal.ts
Normal file
@ -0,0 +1,13 @@
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '../constants/app';
|
||||
|
||||
export const formatOrganisationLoginUrl = (organisationUrl: string) => {
|
||||
return NEXT_PUBLIC_WEBAPP_URL() + formatOrganisationLoginPath(organisationUrl);
|
||||
};
|
||||
|
||||
export const formatOrganisationLoginPath = (organisationUrl: string) => {
|
||||
return `/o/${organisationUrl}/signin`;
|
||||
};
|
||||
|
||||
export const formatOrganisationCallbackUrl = (organisationUrl: string) => {
|
||||
return `${NEXT_PUBLIC_WEBAPP_URL()}/api/auth/callback/oidc/org/${organisationUrl}`;
|
||||
};
|
||||
@ -0,0 +1,75 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- A unique constraint covering the columns `[organisationAuthenticationPortalId]` on the table `Organisation` will be added. If there are existing duplicate values, this will fail.
|
||||
- Added the required column `organisationAuthenticationPortalId` to the `Organisation` table without a default value. This is not possible if the table is not empty.
|
||||
|
||||
*/
|
||||
-- AlterEnum
|
||||
-- This migration adds more than one value to an enum.
|
||||
-- With PostgreSQL versions 11 and earlier, this is not possible
|
||||
-- in a single migration. This can be worked around by creating
|
||||
-- multiple migrations, each migration adding only one value to
|
||||
-- the enum.
|
||||
|
||||
|
||||
ALTER TYPE "UserSecurityAuditLogType" ADD VALUE 'ACCOUNT_SSO_UNLINK';
|
||||
ALTER TYPE "UserSecurityAuditLogType" ADD VALUE 'ORGANISATION_SSO_LINK';
|
||||
ALTER TYPE "UserSecurityAuditLogType" ADD VALUE 'ORGANISATION_SSO_UNLINK';
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "Account" ADD COLUMN "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP;
|
||||
|
||||
-- [CUSTOM_CHANGE] This is supposed to be NOT NULL but we reapply it at the end.
|
||||
ALTER TABLE "Organisation" ADD COLUMN "organisationAuthenticationPortalId" TEXT;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "VerificationToken" ADD COLUMN "metadata" JSONB;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "OrganisationAuthenticationPortal" (
|
||||
"id" TEXT NOT NULL,
|
||||
"enabled" BOOLEAN NOT NULL DEFAULT false,
|
||||
"clientId" TEXT NOT NULL DEFAULT '',
|
||||
"clientSecret" TEXT NOT NULL DEFAULT '',
|
||||
"wellKnownUrl" TEXT NOT NULL DEFAULT '',
|
||||
"defaultOrganisationRole" "OrganisationMemberRole" NOT NULL DEFAULT 'MEMBER',
|
||||
"autoProvisionUsers" BOOLEAN NOT NULL DEFAULT true,
|
||||
"allowedDomains" TEXT[] DEFAULT ARRAY[]::TEXT[],
|
||||
"organisationId" TEXT, -- [CUSTOM_CHANGE] This is a temporary column for migration purposes.
|
||||
|
||||
CONSTRAINT "OrganisationAuthenticationPortal_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- [CUSTOM_CHANGE] Create default OrganisationAuthenticationPortal for all organisations
|
||||
INSERT INTO "OrganisationAuthenticationPortal" ("id", "enabled", "clientId", "clientSecret", "wellKnownUrl", "defaultOrganisationRole", "autoProvisionUsers", "allowedDomains", "organisationId")
|
||||
SELECT
|
||||
generate_prefix_id('org_sso'),
|
||||
false,
|
||||
'',
|
||||
'',
|
||||
'',
|
||||
'MEMBER',
|
||||
true,
|
||||
ARRAY[]::TEXT[],
|
||||
o."id"
|
||||
FROM "Organisation" o
|
||||
WHERE o."organisationAuthenticationPortalId" IS NULL;
|
||||
|
||||
-- [CUSTOM_CHANGE] Update organisations with their corresponding organisationAuthenticationPortalId
|
||||
UPDATE "Organisation" o
|
||||
SET "organisationAuthenticationPortalId" = oap."id"
|
||||
FROM "OrganisationAuthenticationPortal" oap
|
||||
WHERE oap."organisationId" = o."id" AND o."organisationAuthenticationPortalId" IS NULL;
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Organisation_organisationAuthenticationPortalId_key" ON "Organisation"("organisationAuthenticationPortalId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Organisation" ADD CONSTRAINT "Organisation_organisationAuthenticationPortalId_fkey" FOREIGN KEY ("organisationAuthenticationPortalId") REFERENCES "OrganisationAuthenticationPortal"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- [CUSTOM_CHANGE] Reapply NOT NULL constraint.
|
||||
ALTER TABLE "Organisation" ALTER COLUMN "organisationAuthenticationPortalId" SET NOT NULL;
|
||||
|
||||
-- [CUSTOM_CHANGE] Drop temporary column.
|
||||
ALTER TABLE "OrganisationAuthenticationPortal" DROP COLUMN "organisationId";
|
||||
@ -90,6 +90,9 @@ model TeamProfile {
|
||||
enum UserSecurityAuditLogType {
|
||||
ACCOUNT_PROFILE_UPDATE
|
||||
ACCOUNT_SSO_LINK
|
||||
ACCOUNT_SSO_UNLINK
|
||||
ORGANISATION_SSO_LINK
|
||||
ORGANISATION_SSO_UNLINK
|
||||
AUTH_2FA_DISABLE
|
||||
AUTH_2FA_ENABLE
|
||||
PASSKEY_CREATED
|
||||
@ -157,6 +160,7 @@ model VerificationToken {
|
||||
completed Boolean @default(false)
|
||||
expires DateTime
|
||||
createdAt DateTime @default(now())
|
||||
metadata Json?
|
||||
userId Int
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
}
|
||||
@ -277,13 +281,15 @@ model OrganisationClaim {
|
||||
}
|
||||
|
||||
model Account {
|
||||
id String @id @default(cuid())
|
||||
id String @id @default(cuid())
|
||||
// When this record was created, unrelated to anything passed back by the provider.
|
||||
createdAt DateTime @default(now())
|
||||
userId Int
|
||||
type String
|
||||
provider String
|
||||
providerAccountId String
|
||||
refresh_token String? @db.Text
|
||||
access_token String? @db.Text
|
||||
refresh_token String? @db.Text
|
||||
access_token String? @db.Text
|
||||
expires_at Int?
|
||||
// Some providers return created_at so we need to make it optional
|
||||
created_at Int?
|
||||
@ -291,7 +297,7 @@ model Account {
|
||||
ext_expires_in Int?
|
||||
token_type String?
|
||||
scope String?
|
||||
id_token String? @db.Text
|
||||
id_token String? @db.Text
|
||||
session_state String?
|
||||
password String?
|
||||
|
||||
@ -632,6 +638,9 @@ model Organisation {
|
||||
|
||||
organisationGlobalSettingsId String @unique
|
||||
organisationGlobalSettings OrganisationGlobalSettings @relation(fields: [organisationGlobalSettingsId], references: [id])
|
||||
|
||||
organisationAuthenticationPortalId String @unique
|
||||
organisationAuthenticationPortal OrganisationAuthenticationPortal @relation(fields: [organisationAuthenticationPortalId], references: [id])
|
||||
}
|
||||
|
||||
model OrganisationMember {
|
||||
@ -1026,3 +1035,18 @@ model OrganisationEmail {
|
||||
organisationGlobalSettings OrganisationGlobalSettings[]
|
||||
teamGlobalSettings TeamGlobalSettings[]
|
||||
}
|
||||
|
||||
model OrganisationAuthenticationPortal {
|
||||
id String @id
|
||||
organisation Organisation?
|
||||
|
||||
enabled Boolean @default(false)
|
||||
|
||||
clientId String @default("")
|
||||
clientSecret String @default("")
|
||||
wellKnownUrl String @default("")
|
||||
|
||||
defaultOrganisationRole OrganisationMemberRole @default(MEMBER)
|
||||
autoProvisionUsers Boolean @default(true)
|
||||
allowedDomains String[] @default([])
|
||||
}
|
||||
|
||||
@ -1,10 +1,8 @@
|
||||
import type { OrganisationMemberRole, OrganisationType } from '@prisma/client';
|
||||
import { OrganisationMemberInviteStatus, type User } from '@prisma/client';
|
||||
import { nanoid } from 'nanoid';
|
||||
import { OrganisationGroupType, type User } from '@prisma/client';
|
||||
|
||||
import { hashSync } from '@documenso/lib/server-only/auth/hash';
|
||||
import { acceptOrganisationInvitation } from '@documenso/lib/server-only/organisation/accept-organisation-invitation';
|
||||
import { prefixedId } from '@documenso/lib/universal/id';
|
||||
import { addUserToOrganisation } from '@documenso/lib/server-only/organisation/accept-organisation-invitation';
|
||||
|
||||
import { prisma } from '..';
|
||||
import { seedTestEmail } from './users';
|
||||
@ -27,6 +25,13 @@ export const seedOrganisationMembers = async ({
|
||||
|
||||
const createdMembers: User[] = [];
|
||||
|
||||
const organisationGroups = await prisma.organisationGroup.findMany({
|
||||
where: {
|
||||
organisationId,
|
||||
type: OrganisationGroupType.INTERNAL_ORGANISATION,
|
||||
},
|
||||
});
|
||||
|
||||
for (const member of members) {
|
||||
const email = member.email ?? seedTestEmail();
|
||||
|
||||
@ -53,33 +58,15 @@ export const seedOrganisationMembers = async ({
|
||||
email: newUser.email,
|
||||
organisationRole: member.organisationRole,
|
||||
});
|
||||
|
||||
await addUserToOrganisation({
|
||||
userId: newUser.id,
|
||||
organisationId,
|
||||
organisationGroups,
|
||||
organisationMemberRole: member.organisationRole,
|
||||
});
|
||||
}
|
||||
|
||||
await prisma.organisationMemberInvite.createMany({
|
||||
data: membersToInvite.map((invite) => ({
|
||||
id: prefixedId('member_invite'),
|
||||
email: invite.email,
|
||||
organisationId,
|
||||
organisationRole: invite.organisationRole,
|
||||
token: nanoid(32),
|
||||
})),
|
||||
});
|
||||
|
||||
const invites = await prisma.organisationMemberInvite.findMany({
|
||||
where: {
|
||||
organisationId,
|
||||
status: OrganisationMemberInviteStatus.PENDING,
|
||||
},
|
||||
});
|
||||
|
||||
await Promise.all(
|
||||
invites.map(async (invite) => {
|
||||
await acceptOrganisationInvitation({
|
||||
token: invite.token,
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
return createdMembers;
|
||||
};
|
||||
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import fs from 'node:fs';
|
||||
import * as fs from 'node:fs';
|
||||
|
||||
import { getCertificateStatus } from '@documenso/lib/server-only/cert/cert-status';
|
||||
import { env } from '@documenso/lib/utils/env';
|
||||
import { signWithP12 } from '@documenso/pdf-sign';
|
||||
|
||||
@ -22,12 +23,23 @@ export const signWithLocalCert = async ({ pdf }: SignWithLocalCertOptions) => {
|
||||
|
||||
const signatureLength = byteRange[2] - byteRange[1];
|
||||
|
||||
const certStatus = getCertificateStatus();
|
||||
|
||||
if (!certStatus.isAvailable) {
|
||||
console.error('Certificate error: Certificate not available for document signing');
|
||||
throw new Error('Document signing failed: Certificate not available');
|
||||
}
|
||||
|
||||
let cert: Buffer | null = null;
|
||||
|
||||
const localFileContents = env('NEXT_PRIVATE_SIGNING_LOCAL_FILE_CONTENTS');
|
||||
|
||||
if (localFileContents) {
|
||||
cert = Buffer.from(localFileContents, 'base64');
|
||||
try {
|
||||
cert = Buffer.from(localFileContents, 'base64');
|
||||
} catch {
|
||||
throw new Error('Failed to decode certificate contents');
|
||||
}
|
||||
}
|
||||
|
||||
if (!cert) {
|
||||
@ -42,7 +54,12 @@ export const signWithLocalCert = async ({ pdf }: SignWithLocalCertOptions) => {
|
||||
certPath = env('NEXT_PRIVATE_SIGNING_LOCAL_FILE_PATH') || './example/cert.p12';
|
||||
}
|
||||
|
||||
cert = Buffer.from(fs.readFileSync(certPath));
|
||||
try {
|
||||
cert = Buffer.from(fs.readFileSync(certPath));
|
||||
} catch {
|
||||
console.error('Certificate error: Failed to read certificate file');
|
||||
throw new Error('Document signing failed: Certificate file not accessible');
|
||||
}
|
||||
}
|
||||
|
||||
const signature = signWithP12({
|
||||
|
||||
@ -28,6 +28,7 @@ export const ZDocumentTitleSchema = z
|
||||
export const ZDocumentExternalIdSchema = z
|
||||
.string()
|
||||
.trim()
|
||||
.max(255)
|
||||
.describe('The external ID of the document.');
|
||||
|
||||
export const ZDocumentVisibilitySchema = z
|
||||
@ -65,10 +66,12 @@ export const ZDocumentMetaLanguageSchema = z
|
||||
|
||||
export const ZDocumentMetaSubjectSchema = z
|
||||
.string()
|
||||
.max(254)
|
||||
.describe('The subject of the email that will be sent to the recipients.');
|
||||
|
||||
export const ZDocumentMetaMessageSchema = z
|
||||
.string()
|
||||
.max(5000)
|
||||
.describe('The message of the email that will be sent to the recipients.');
|
||||
|
||||
export const ZDocumentMetaDistributionMethodSchema = z
|
||||
|
||||
@ -2,7 +2,7 @@ import { z } from 'zod';
|
||||
|
||||
import { ZEmailDomainSchema } from '@documenso/lib/types/email-domain';
|
||||
|
||||
const domainRegex =
|
||||
export const domainRegex =
|
||||
/^(?!https?:\/\/)(?!www\.)([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$/;
|
||||
|
||||
export const ZDomainSchema = z
|
||||
|
||||
@ -0,0 +1,25 @@
|
||||
import { ORGANISATION_ACCOUNT_LINK_VERIFICATION_TOKEN_IDENTIFIER } from '@documenso/lib/constants/organisations';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { procedure } from '../trpc';
|
||||
import {
|
||||
ZDeclineLinkOrganisationAccountRequestSchema,
|
||||
ZDeclineLinkOrganisationAccountResponseSchema,
|
||||
} from './decline-link-organisation-account.types';
|
||||
|
||||
/**
|
||||
* Unauthenicated procedure, do not copy paste.
|
||||
*/
|
||||
export const declineLinkOrganisationAccountRoute = procedure
|
||||
.input(ZDeclineLinkOrganisationAccountRequestSchema)
|
||||
.output(ZDeclineLinkOrganisationAccountResponseSchema)
|
||||
.mutation(async ({ input }) => {
|
||||
const { token } = input;
|
||||
|
||||
await prisma.verificationToken.delete({
|
||||
where: {
|
||||
token,
|
||||
identifier: ORGANISATION_ACCOUNT_LINK_VERIFICATION_TOKEN_IDENTIFIER,
|
||||
},
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,11 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const ZDeclineLinkOrganisationAccountRequestSchema = z.object({
|
||||
token: z.string(),
|
||||
});
|
||||
|
||||
export const ZDeclineLinkOrganisationAccountResponseSchema = z.void();
|
||||
|
||||
export type TDeclineLinkOrganisationAccountRequest = z.infer<
|
||||
typeof ZDeclineLinkOrganisationAccountRequestSchema
|
||||
>;
|
||||
@ -0,0 +1,84 @@
|
||||
import { ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/organisations';
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { buildOrganisationWhereQuery } from '@documenso/lib/utils/organisations';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { authenticatedProcedure } from '../trpc';
|
||||
import {
|
||||
ZGetOrganisationAuthenticationPortalRequestSchema,
|
||||
ZGetOrganisationAuthenticationPortalResponseSchema,
|
||||
} from './get-organisation-authentication-portal.types';
|
||||
|
||||
export const getOrganisationAuthenticationPortalRoute = authenticatedProcedure
|
||||
.input(ZGetOrganisationAuthenticationPortalRequestSchema)
|
||||
.output(ZGetOrganisationAuthenticationPortalResponseSchema)
|
||||
.query(async ({ input, ctx }) => {
|
||||
const { organisationId } = input;
|
||||
|
||||
ctx.logger.info({
|
||||
input: {
|
||||
organisationId,
|
||||
},
|
||||
});
|
||||
|
||||
return await getOrganisationAuthenticationPortal({
|
||||
userId: ctx.user.id,
|
||||
organisationId,
|
||||
});
|
||||
});
|
||||
|
||||
type GetOrganisationAuthenticationPortalOptions = {
|
||||
userId: number;
|
||||
organisationId: string;
|
||||
};
|
||||
|
||||
export const getOrganisationAuthenticationPortal = async ({
|
||||
userId,
|
||||
organisationId,
|
||||
}: GetOrganisationAuthenticationPortalOptions) => {
|
||||
const organisation = await prisma.organisation.findFirst({
|
||||
where: buildOrganisationWhereQuery({
|
||||
organisationId,
|
||||
userId,
|
||||
roles: ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_ORGANISATION'],
|
||||
}),
|
||||
include: {
|
||||
organisationClaim: true,
|
||||
organisationAuthenticationPortal: {
|
||||
select: {
|
||||
defaultOrganisationRole: true,
|
||||
enabled: true,
|
||||
clientId: true,
|
||||
wellKnownUrl: true,
|
||||
autoProvisionUsers: true,
|
||||
allowedDomains: true,
|
||||
clientSecret: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!organisation) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'Organisation not found',
|
||||
});
|
||||
}
|
||||
|
||||
if (!organisation.organisationClaim.flags.authenticationPortal) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'Authentication portal not found',
|
||||
});
|
||||
}
|
||||
|
||||
const portal = organisation.organisationAuthenticationPortal;
|
||||
|
||||
return {
|
||||
defaultOrganisationRole: portal.defaultOrganisationRole,
|
||||
enabled: portal.enabled,
|
||||
clientId: portal.clientId,
|
||||
wellKnownUrl: portal.wellKnownUrl,
|
||||
autoProvisionUsers: portal.autoProvisionUsers,
|
||||
allowedDomains: portal.allowedDomains,
|
||||
clientSecretProvided: Boolean(portal.clientSecret),
|
||||
};
|
||||
};
|
||||
@ -0,0 +1,28 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { OrganisationAuthenticationPortalSchema } from '@documenso/prisma/generated/zod/modelSchema/OrganisationAuthenticationPortalSchema';
|
||||
|
||||
export const ZGetOrganisationAuthenticationPortalRequestSchema = z.object({
|
||||
organisationId: z.string(),
|
||||
});
|
||||
|
||||
export const ZGetOrganisationAuthenticationPortalResponseSchema =
|
||||
OrganisationAuthenticationPortalSchema.pick({
|
||||
defaultOrganisationRole: true,
|
||||
enabled: true,
|
||||
clientId: true,
|
||||
wellKnownUrl: true,
|
||||
autoProvisionUsers: true,
|
||||
allowedDomains: true,
|
||||
}).extend({
|
||||
/**
|
||||
* Whether we have the client secret in the database.
|
||||
*
|
||||
* Do not expose the actual client secret.
|
||||
*/
|
||||
clientSecretProvided: z.boolean(),
|
||||
});
|
||||
|
||||
export type TGetOrganisationAuthenticationPortalResponse = z.infer<
|
||||
typeof ZGetOrganisationAuthenticationPortalResponseSchema
|
||||
>;
|
||||
@ -0,0 +1,22 @@
|
||||
import { linkOrganisationAccount } from '@documenso/ee/server-only/lib/link-organisation-account';
|
||||
|
||||
import { procedure } from '../trpc';
|
||||
import {
|
||||
ZLinkOrganisationAccountRequestSchema,
|
||||
ZLinkOrganisationAccountResponseSchema,
|
||||
} from './link-organisation-account.types';
|
||||
|
||||
/**
|
||||
* Unauthenicated procedure, do not copy paste.
|
||||
*/
|
||||
export const linkOrganisationAccountRoute = procedure
|
||||
.input(ZLinkOrganisationAccountRequestSchema)
|
||||
.output(ZLinkOrganisationAccountResponseSchema)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const { token } = input;
|
||||
|
||||
await linkOrganisationAccount({
|
||||
token,
|
||||
requestMeta: ctx.metadata.requestMetadata,
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,9 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const ZLinkOrganisationAccountRequestSchema = z.object({
|
||||
token: z.string(),
|
||||
});
|
||||
|
||||
export const ZLinkOrganisationAccountResponseSchema = z.void();
|
||||
|
||||
export type TLinkOrganisationAccountRequest = z.infer<typeof ZLinkOrganisationAccountRequestSchema>;
|
||||
@ -2,15 +2,19 @@ import { router } from '../trpc';
|
||||
import { createOrganisationEmailRoute } from './create-organisation-email';
|
||||
import { createOrganisationEmailDomainRoute } from './create-organisation-email-domain';
|
||||
import { createSubscriptionRoute } from './create-subscription';
|
||||
import { declineLinkOrganisationAccountRoute } from './decline-link-organisation-account';
|
||||
import { deleteOrganisationEmailRoute } from './delete-organisation-email';
|
||||
import { deleteOrganisationEmailDomainRoute } from './delete-organisation-email-domain';
|
||||
import { findOrganisationEmailDomainsRoute } from './find-organisation-email-domain';
|
||||
import { findOrganisationEmailsRoute } from './find-organisation-emails';
|
||||
import { getInvoicesRoute } from './get-invoices';
|
||||
import { getOrganisationAuthenticationPortalRoute } from './get-organisation-authentication-portal';
|
||||
import { getOrganisationEmailDomainRoute } from './get-organisation-email-domain';
|
||||
import { getPlansRoute } from './get-plans';
|
||||
import { getSubscriptionRoute } from './get-subscription';
|
||||
import { linkOrganisationAccountRoute } from './link-organisation-account';
|
||||
import { manageSubscriptionRoute } from './manage-subscription';
|
||||
import { updateOrganisationAuthenticationPortalRoute } from './update-organisation-authentication-portal';
|
||||
import { updateOrganisationEmailRoute } from './update-organisation-email';
|
||||
import { verifyOrganisationEmailDomainRoute } from './verify-organisation-email-domain';
|
||||
|
||||
@ -29,6 +33,12 @@ export const enterpriseRouter = router({
|
||||
delete: deleteOrganisationEmailDomainRoute,
|
||||
verify: verifyOrganisationEmailDomainRoute,
|
||||
},
|
||||
authenticationPortal: {
|
||||
get: getOrganisationAuthenticationPortalRoute,
|
||||
update: updateOrganisationAuthenticationPortalRoute,
|
||||
linkAccount: linkOrganisationAccountRoute,
|
||||
declineLinkAccount: declineLinkOrganisationAccountRoute,
|
||||
},
|
||||
},
|
||||
billing: {
|
||||
plans: {
|
||||
|
||||
@ -0,0 +1,109 @@
|
||||
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
|
||||
import { DOCUMENSO_ENCRYPTION_KEY } from '@documenso/lib/constants/crypto';
|
||||
import { ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/organisations';
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { symmetricEncrypt } from '@documenso/lib/universal/crypto';
|
||||
import { buildOrganisationWhereQuery } from '@documenso/lib/utils/organisations';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { authenticatedProcedure } from '../trpc';
|
||||
import {
|
||||
ZUpdateOrganisationAuthenticationPortalRequestSchema,
|
||||
ZUpdateOrganisationAuthenticationPortalResponseSchema,
|
||||
} from './update-organisation-authentication-portal.types';
|
||||
|
||||
export const updateOrganisationAuthenticationPortalRoute = authenticatedProcedure
|
||||
.input(ZUpdateOrganisationAuthenticationPortalRequestSchema)
|
||||
.output(ZUpdateOrganisationAuthenticationPortalResponseSchema)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const { organisationId, data } = input;
|
||||
const { user } = ctx;
|
||||
|
||||
ctx.logger.info({
|
||||
input: {
|
||||
organisationId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!IS_BILLING_ENABLED()) {
|
||||
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||
message: 'Billing is not enabled',
|
||||
});
|
||||
}
|
||||
|
||||
const organisation = await prisma.organisation.findFirst({
|
||||
where: buildOrganisationWhereQuery({
|
||||
organisationId,
|
||||
userId: user.id,
|
||||
roles: ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_ORGANISATION'],
|
||||
}),
|
||||
include: {
|
||||
organisationAuthenticationPortal: true,
|
||||
organisationClaim: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!organisation) {
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED);
|
||||
}
|
||||
|
||||
if (!organisation.organisationClaim.flags.authenticationPortal) {
|
||||
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||
message: 'Authentication portal is not allowed for this organisation',
|
||||
});
|
||||
}
|
||||
|
||||
const {
|
||||
defaultOrganisationRole,
|
||||
enabled,
|
||||
clientId,
|
||||
clientSecret,
|
||||
wellKnownUrl,
|
||||
autoProvisionUsers,
|
||||
allowedDomains,
|
||||
} = data;
|
||||
|
||||
if (
|
||||
enabled &&
|
||||
(!wellKnownUrl ||
|
||||
!clientId ||
|
||||
(!clientSecret && !organisation.organisationAuthenticationPortal.clientSecret))
|
||||
) {
|
||||
throw new AppError(AppErrorCode.INVALID_BODY, {
|
||||
message:
|
||||
'Client ID, client secret, and well known URL are required when authentication portal is enabled',
|
||||
});
|
||||
}
|
||||
|
||||
// Allow empty string to be passed in to remove the client secret from the database.
|
||||
let encryptedClientSecret: string | undefined = clientSecret;
|
||||
|
||||
// Encrypt the secret if it is provided.
|
||||
if (clientSecret) {
|
||||
const encryptionKey = DOCUMENSO_ENCRYPTION_KEY;
|
||||
|
||||
if (!encryptionKey) {
|
||||
throw new Error('Missing DOCUMENSO_ENCRYPTION_KEY');
|
||||
}
|
||||
|
||||
encryptedClientSecret = symmetricEncrypt({
|
||||
key: encryptionKey,
|
||||
data: clientSecret,
|
||||
});
|
||||
}
|
||||
|
||||
await prisma.organisationAuthenticationPortal.update({
|
||||
where: {
|
||||
id: organisation.organisationAuthenticationPortal.id,
|
||||
},
|
||||
data: {
|
||||
defaultOrganisationRole,
|
||||
enabled,
|
||||
clientId,
|
||||
clientSecret: encryptedClientSecret,
|
||||
wellKnownUrl,
|
||||
autoProvisionUsers,
|
||||
allowedDomains,
|
||||
},
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,24 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import OrganisationMemberRoleSchema from '@documenso/prisma/generated/zod/inputTypeSchemas/OrganisationMemberRoleSchema';
|
||||
|
||||
import { domainRegex } from './create-organisation-email-domain.types';
|
||||
|
||||
export const ZUpdateOrganisationAuthenticationPortalRequestSchema = z.object({
|
||||
organisationId: z.string(),
|
||||
data: z.object({
|
||||
defaultOrganisationRole: OrganisationMemberRoleSchema,
|
||||
enabled: z.boolean(),
|
||||
clientId: z.string(),
|
||||
clientSecret: z.string().optional(),
|
||||
wellKnownUrl: z.union([z.string().url(), z.literal('')]),
|
||||
autoProvisionUsers: z.boolean(),
|
||||
allowedDomains: z.array(z.string().regex(domainRegex)),
|
||||
}),
|
||||
});
|
||||
|
||||
export const ZUpdateOrganisationAuthenticationPortalResponseSchema = z.void();
|
||||
|
||||
export type TUpdateOrganisationAuthenticationPortalRequest = z.infer<
|
||||
typeof ZUpdateOrganisationAuthenticationPortalRequestSchema
|
||||
>;
|
||||
@ -1,4 +1,7 @@
|
||||
import { ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/organisations';
|
||||
import {
|
||||
ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP,
|
||||
ORGANISATION_USER_ACCOUNT_TYPE,
|
||||
} from '@documenso/lib/constants/organisations';
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { handleDocumentOwnershipOnDeletion } from '@documenso/lib/server-only/document/handle-document-ownership-on-deletion';
|
||||
import { buildOrganisationWhereQuery } from '@documenso/lib/utils/organisations';
|
||||
@ -65,9 +68,18 @@ export const deleteOrganisationRoute = authenticatedProcedure
|
||||
});
|
||||
}
|
||||
|
||||
await prisma.organisation.delete({
|
||||
where: {
|
||||
id: organisation.id,
|
||||
},
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await tx.account.deleteMany({
|
||||
where: {
|
||||
type: ORGANISATION_USER_ACCOUNT_TYPE,
|
||||
provider: organisation.id,
|
||||
},
|
||||
});
|
||||
|
||||
await tx.organisation.delete({
|
||||
where: {
|
||||
id: organisation.id,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -0,0 +1,34 @@
|
||||
import { getRecipientSuggestions } from '@documenso/lib/server-only/recipient/get-recipient-suggestions';
|
||||
|
||||
import { authenticatedProcedure } from '../trpc';
|
||||
import {
|
||||
ZGetRecipientSuggestionsRequestSchema,
|
||||
ZGetRecipientSuggestionsResponseSchema,
|
||||
} from './find-recipient-suggestions.types';
|
||||
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
export const findRecipientSuggestionsRoute = authenticatedProcedure
|
||||
.input(ZGetRecipientSuggestionsRequestSchema)
|
||||
.output(ZGetRecipientSuggestionsResponseSchema)
|
||||
.query(async ({ input, ctx }) => {
|
||||
const { teamId, user } = ctx;
|
||||
const { query } = input;
|
||||
|
||||
ctx.logger.info({
|
||||
input: {
|
||||
query,
|
||||
},
|
||||
});
|
||||
|
||||
const suggestions = await getRecipientSuggestions({
|
||||
userId: user.id,
|
||||
teamId,
|
||||
query,
|
||||
});
|
||||
|
||||
return {
|
||||
results: suggestions,
|
||||
};
|
||||
});
|
||||
@ -0,0 +1,22 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const ZGetRecipientSuggestionsRequestSchema = z.object({
|
||||
query: z.string().default(''),
|
||||
});
|
||||
|
||||
export const ZGetRecipientSuggestionsResponseSchema = z.object({
|
||||
results: z.array(
|
||||
z.object({
|
||||
name: z.string().nullable(),
|
||||
email: z.string().email(),
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
export type TGetRecipientSuggestionsRequestSchema = z.infer<
|
||||
typeof ZGetRecipientSuggestionsRequestSchema
|
||||
>;
|
||||
|
||||
export type TGetRecipientSuggestionsResponseSchema = z.infer<
|
||||
typeof ZGetRecipientSuggestionsResponseSchema
|
||||
>;
|
||||
@ -12,6 +12,7 @@ import { updateTemplateRecipients } from '@documenso/lib/server-only/recipient/u
|
||||
|
||||
import { ZGenericSuccessResponse, ZSuccessResponseSchema } from '../document-router/schema';
|
||||
import { authenticatedProcedure, procedure, router } from '../trpc';
|
||||
import { findRecipientSuggestionsRoute } from './find-recipient-suggestions';
|
||||
import {
|
||||
ZCompleteDocumentWithTokenMutationSchema,
|
||||
ZCreateDocumentRecipientRequestSchema,
|
||||
@ -42,6 +43,10 @@ import {
|
||||
} from './schema';
|
||||
|
||||
export const recipientRouter = router({
|
||||
suggestions: {
|
||||
find: findRecipientSuggestionsRoute,
|
||||
},
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
|
||||
@ -23,8 +23,8 @@ export const ZGetRecipientResponseSchema = ZRecipientSchema;
|
||||
* pass along required details.
|
||||
*/
|
||||
export const ZCreateRecipientSchema = z.object({
|
||||
email: z.string().toLowerCase().email().min(1),
|
||||
name: z.string(),
|
||||
email: z.string().toLowerCase().email().min(1).max(254),
|
||||
name: z.string().max(255),
|
||||
role: z.nativeEnum(RecipientRole),
|
||||
signingOrder: z.number().optional(),
|
||||
accessAuth: z.array(ZRecipientAccessAuthTypesSchema).optional().default([]),
|
||||
@ -33,8 +33,8 @@ export const ZCreateRecipientSchema = z.object({
|
||||
|
||||
export const ZUpdateRecipientSchema = z.object({
|
||||
id: z.number().describe('The ID of the recipient to update.'),
|
||||
email: z.string().toLowerCase().email().min(1).optional(),
|
||||
name: z.string().optional(),
|
||||
email: z.string().toLowerCase().email().min(1).max(254).optional(),
|
||||
name: z.string().max(255).optional(),
|
||||
role: z.nativeEnum(RecipientRole).optional(),
|
||||
signingOrder: z.number().optional(),
|
||||
accessAuth: z.array(ZRecipientAccessAuthTypesSchema).optional().default([]),
|
||||
@ -103,8 +103,8 @@ export const ZSetDocumentRecipientsRequestSchema = z
|
||||
recipients: z.array(
|
||||
z.object({
|
||||
nativeId: z.number().optional(),
|
||||
email: z.string().toLowerCase().email().min(1),
|
||||
name: z.string(),
|
||||
email: z.string().toLowerCase().email().min(1).max(254),
|
||||
name: z.string().max(255),
|
||||
role: z.nativeEnum(RecipientRole),
|
||||
signingOrder: z.number().optional(),
|
||||
actionAuth: z.array(ZRecipientActionAuthTypesSchema).optional().default([]),
|
||||
@ -229,8 +229,8 @@ export const ZCompleteDocumentWithTokenMutationSchema = z.object({
|
||||
authOptions: ZRecipientActionAuthSchema.optional(),
|
||||
nextSigner: z
|
||||
.object({
|
||||
email: z.string().email(),
|
||||
name: z.string().min(1),
|
||||
email: z.string().email().max(254),
|
||||
name: z.string().min(1).max(255),
|
||||
})
|
||||
.optional(),
|
||||
});
|
||||
|
||||
@ -83,8 +83,8 @@ export const ZCreateTemplateMutationSchema = z.object({
|
||||
});
|
||||
|
||||
export const ZCreateDocumentFromDirectTemplateRequestSchema = z.object({
|
||||
directRecipientName: z.string().optional(),
|
||||
directRecipientEmail: z.string().email(),
|
||||
directRecipientName: z.string().max(255).optional(),
|
||||
directRecipientEmail: z.string().email().max(254),
|
||||
directTemplateToken: z.string().min(1),
|
||||
directTemplateExternalId: z.string().optional(),
|
||||
signedFieldValues: z.array(ZSignFieldWithTokenMutationSchema),
|
||||
@ -97,8 +97,8 @@ export const ZCreateDocumentFromTemplateRequestSchema = z.object({
|
||||
.array(
|
||||
z.object({
|
||||
id: z.number().describe('The ID of the recipient in the template.'),
|
||||
email: z.string().email(),
|
||||
name: z.string().optional(),
|
||||
email: z.string().email().max(254),
|
||||
name: z.string().max(255).optional(),
|
||||
}),
|
||||
)
|
||||
.describe('The information of the recipients to create the document with.')
|
||||
|
||||
@ -0,0 +1,106 @@
|
||||
import React, { useRef, useState } from 'react';
|
||||
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { PopoverAnchor } from '@radix-ui/react-popover';
|
||||
|
||||
import { Popover, PopoverContent } from '@documenso/ui/primitives/popover';
|
||||
|
||||
import { Command, CommandGroup, CommandItem } from '../../primitives/command';
|
||||
import { Input } from '../../primitives/input';
|
||||
|
||||
export type RecipientAutoCompleteOption = {
|
||||
email: string;
|
||||
name: string | null;
|
||||
};
|
||||
|
||||
type RecipientAutoCompleteInputProps = {
|
||||
type: 'email' | 'text';
|
||||
value: string;
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
loading?: boolean;
|
||||
options: RecipientAutoCompleteOption[];
|
||||
onSelect: (option: RecipientAutoCompleteOption) => void;
|
||||
onSearchQueryChange: (query: string) => void;
|
||||
};
|
||||
|
||||
type CombinedProps = RecipientAutoCompleteInputProps &
|
||||
Omit<React.InputHTMLAttributes<HTMLInputElement>, keyof RecipientAutoCompleteInputProps>;
|
||||
|
||||
export const RecipientAutoCompleteInput = ({
|
||||
value,
|
||||
placeholder,
|
||||
disabled,
|
||||
loading,
|
||||
onSearchQueryChange,
|
||||
onSelect,
|
||||
options = [],
|
||||
onChange: _onChange,
|
||||
...props
|
||||
}: CombinedProps) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const onValueChange = (value: string) => {
|
||||
setIsOpen(!!value.length);
|
||||
onSearchQueryChange(value);
|
||||
};
|
||||
|
||||
const handleSelectItem = (option: RecipientAutoCompleteOption) => {
|
||||
setIsOpen(false);
|
||||
onSelect(option);
|
||||
};
|
||||
|
||||
return (
|
||||
<Command>
|
||||
<Popover open={isOpen} onOpenChange={setIsOpen}>
|
||||
<PopoverAnchor asChild>
|
||||
<Input
|
||||
ref={inputRef}
|
||||
className="w-full"
|
||||
placeholder={placeholder}
|
||||
value={value}
|
||||
disabled={disabled}
|
||||
onChange={(e) => onValueChange(e.target.value)}
|
||||
{...props}
|
||||
/>
|
||||
</PopoverAnchor>
|
||||
|
||||
<PopoverContent
|
||||
align="start"
|
||||
className="w-full p-0"
|
||||
onOpenAutoFocus={(e) => {
|
||||
e.preventDefault();
|
||||
}}
|
||||
>
|
||||
{/* Not using <CommandEmpty /> here due to some weird behaviour */}
|
||||
{options.length === 0 && (
|
||||
<div className="px-2 py-1.5 text-sm">
|
||||
{loading ? (
|
||||
<Trans>Loading suggestions...</Trans>
|
||||
) : (
|
||||
<Trans>No suggestions found</Trans>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{options.length > 0 && (
|
||||
<CommandGroup className="max-h-[250px] overflow-y-auto">
|
||||
{options.map((option, index) => (
|
||||
<CommandItem
|
||||
key={`${index}-${option.email}`}
|
||||
value={`${option.email}`}
|
||||
className="cursor-pointer"
|
||||
onSelect={() => handleSelectItem(option)}
|
||||
>
|
||||
{option.name} ({option.email})
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</Command>
|
||||
);
|
||||
};
|
||||
@ -65,6 +65,27 @@ const CommandInput = React.forwardRef<
|
||||
|
||||
CommandInput.displayName = CommandPrimitive.Input.displayName;
|
||||
|
||||
const CommandTextInput = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.Input>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div cmdk-input-wrapper="">
|
||||
<CommandPrimitive.Input
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'bg-background border-input ring-offset-background placeholder:text-muted-foreground/40 focus-visible:ring-ring flex h-10 w-full rounded-md border px-3 py-2 text-sm file:border-0 file:bg-transparent file:text-sm file:font-medium focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className,
|
||||
{
|
||||
'ring-2 !ring-red-500 transition-all': props['aria-invalid'],
|
||||
},
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
));
|
||||
|
||||
CommandTextInput.displayName = CommandPrimitive.Input.displayName;
|
||||
|
||||
const CommandList = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.List>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
|
||||
@ -147,6 +168,7 @@ export {
|
||||
Command,
|
||||
CommandDialog,
|
||||
CommandInput,
|
||||
CommandTextInput,
|
||||
CommandList,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
|
||||
@ -21,6 +21,7 @@ import { useHotkeys } from 'react-hotkeys-hook';
|
||||
import { prop, sortBy } from 'remeda';
|
||||
|
||||
import { getBoundingClientRect } from '@documenso/lib/client-only/get-bounding-client-rect';
|
||||
import { useAutoSave } from '@documenso/lib/client-only/hooks/use-autosave';
|
||||
import { useDocumentElement } from '@documenso/lib/client-only/hooks/use-document-element';
|
||||
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
|
||||
import {
|
||||
@ -83,6 +84,7 @@ export type AddFieldsFormProps = {
|
||||
recipients: Recipient[];
|
||||
fields: Field[];
|
||||
onSubmit: (_data: TAddFieldsFormSchema) => void;
|
||||
onAutoSave: (_data: TAddFieldsFormSchema) => Promise<void>;
|
||||
canGoBack?: boolean;
|
||||
isDocumentPdfLoaded: boolean;
|
||||
teamId: number;
|
||||
@ -94,6 +96,7 @@ export const AddFieldsFormPartial = ({
|
||||
recipients,
|
||||
fields,
|
||||
onSubmit,
|
||||
onAutoSave,
|
||||
canGoBack = false,
|
||||
isDocumentPdfLoaded,
|
||||
teamId,
|
||||
@ -590,6 +593,20 @@ export const AddFieldsFormPartial = ({
|
||||
}
|
||||
};
|
||||
|
||||
const { scheduleSave } = useAutoSave(onAutoSave);
|
||||
|
||||
const handleAutoSave = async () => {
|
||||
const isFormValid = await form.trigger();
|
||||
|
||||
if (!isFormValid) {
|
||||
return;
|
||||
}
|
||||
|
||||
const formData = form.getValues();
|
||||
|
||||
scheduleSave(formData);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{showAdvancedSettings && currentField ? (
|
||||
@ -603,7 +620,14 @@ export const AddFieldsFormPartial = ({
|
||||
fields={localFields}
|
||||
onAdvancedSettings={handleAdvancedSettings}
|
||||
isDocumentPdfLoaded={isDocumentPdfLoaded}
|
||||
onSave={handleSavedFieldSettings}
|
||||
onSave={(fieldState) => {
|
||||
handleSavedFieldSettings(fieldState);
|
||||
void handleAutoSave();
|
||||
}}
|
||||
onAutoSave={async (fieldState) => {
|
||||
handleSavedFieldSettings(fieldState);
|
||||
await handleAutoSave();
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
@ -660,14 +684,26 @@ export const AddFieldsFormPartial = ({
|
||||
defaultWidth={DEFAULT_WIDTH_PX}
|
||||
passive={isFieldWithinBounds && !!selectedField}
|
||||
onFocus={() => setLastActiveField(field)}
|
||||
onBlur={() => setLastActiveField(null)}
|
||||
onBlur={() => {
|
||||
setLastActiveField(null);
|
||||
void handleAutoSave();
|
||||
}}
|
||||
onMouseEnter={() => setLastActiveField(field)}
|
||||
onMouseLeave={() => setLastActiveField(null)}
|
||||
onResize={(options) => onFieldResize(options, index)}
|
||||
onMove={(options) => onFieldMove(options, index)}
|
||||
onRemove={() => remove(index)}
|
||||
onDuplicate={() => onFieldCopy(null, { duplicate: true })}
|
||||
onDuplicateAllPages={() => onFieldCopy(null, { duplicateAll: true })}
|
||||
onRemove={() => {
|
||||
remove(index);
|
||||
void handleAutoSave();
|
||||
}}
|
||||
onDuplicate={() => {
|
||||
onFieldCopy(null, { duplicate: true });
|
||||
void handleAutoSave();
|
||||
}}
|
||||
onDuplicateAllPages={() => {
|
||||
onFieldCopy(null, { duplicateAll: true });
|
||||
void handleAutoSave();
|
||||
}}
|
||||
onAdvancedSettings={() => {
|
||||
setCurrentField(field);
|
||||
handleAdvancedSettings();
|
||||
|
||||
@ -14,6 +14,7 @@ import { InfoIcon } from 'lucide-react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { useAutoSave } from '@documenso/lib/client-only/hooks/use-autosave';
|
||||
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||
import { DATE_FORMATS, DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats';
|
||||
import { DOCUMENT_SIGNATURE_TYPES } from '@documenso/lib/constants/document';
|
||||
@ -79,6 +80,7 @@ export type AddSettingsFormProps = {
|
||||
document: TDocument;
|
||||
currentTeamMemberRole?: TeamMemberRole;
|
||||
onSubmit: (_data: TAddSettingsFormSchema) => void;
|
||||
onAutoSave: (_data: TAddSettingsFormSchema) => Promise<void>;
|
||||
};
|
||||
|
||||
export const AddSettingsFormPartial = ({
|
||||
@ -89,6 +91,7 @@ export const AddSettingsFormPartial = ({
|
||||
document,
|
||||
currentTeamMemberRole,
|
||||
onSubmit,
|
||||
onAutoSave,
|
||||
}: AddSettingsFormProps) => {
|
||||
const { t } = useLingui();
|
||||
|
||||
@ -161,6 +164,28 @@ export const AddSettingsFormPartial = ({
|
||||
document.documentMeta?.timezone,
|
||||
]);
|
||||
|
||||
const { scheduleSave } = useAutoSave(onAutoSave);
|
||||
|
||||
const handleAutoSave = async () => {
|
||||
const isFormValid = await form.trigger();
|
||||
|
||||
if (!isFormValid) {
|
||||
return;
|
||||
}
|
||||
|
||||
const formData = form.getValues();
|
||||
|
||||
/*
|
||||
* Parse the form data through the Zod schema to handle transformations
|
||||
* (like -1 -> undefined for the Document Global Auth Access)
|
||||
*/
|
||||
const parseResult = ZAddSettingsFormSchema.safeParse(formData);
|
||||
|
||||
if (parseResult.success) {
|
||||
scheduleSave(parseResult.data);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<DocumentFlowFormContainerHeader
|
||||
@ -196,6 +221,8 @@ export const AddSettingsFormPartial = ({
|
||||
className="bg-background"
|
||||
{...field}
|
||||
disabled={document.status !== DocumentStatus.DRAFT || field.disabled}
|
||||
maxLength={255}
|
||||
onBlur={handleAutoSave}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
@ -227,9 +254,13 @@ export const AddSettingsFormPartial = ({
|
||||
|
||||
<FormControl>
|
||||
<Select
|
||||
{...field}
|
||||
onValueChange={(value) => {
|
||||
field.onChange(value);
|
||||
void handleAutoSave();
|
||||
}}
|
||||
value={field.value}
|
||||
disabled={field.disabled}
|
||||
onValueChange={field.onChange}
|
||||
>
|
||||
<SelectTrigger className="bg-background">
|
||||
<SelectValue />
|
||||
@ -261,9 +292,13 @@ export const AddSettingsFormPartial = ({
|
||||
|
||||
<FormControl>
|
||||
<DocumentGlobalAuthAccessSelect
|
||||
{...field}
|
||||
onValueChange={(value) => {
|
||||
field.onChange(value);
|
||||
void handleAutoSave();
|
||||
}}
|
||||
value={field.value}
|
||||
disabled={field.disabled}
|
||||
onValueChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
@ -286,7 +321,10 @@ export const AddSettingsFormPartial = ({
|
||||
canUpdateVisibility={canUpdateVisibility}
|
||||
currentTeamMemberRole={currentTeamMemberRole}
|
||||
{...field}
|
||||
onValueChange={field.onChange}
|
||||
onValueChange={(value) => {
|
||||
field.onChange(value);
|
||||
void handleAutoSave();
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
@ -307,9 +345,13 @@ export const AddSettingsFormPartial = ({
|
||||
|
||||
<FormControl>
|
||||
<DocumentGlobalAuthActionSelect
|
||||
{...field}
|
||||
onValueChange={(value) => {
|
||||
field.onChange(value);
|
||||
void handleAutoSave();
|
||||
}}
|
||||
value={field.value}
|
||||
disabled={field.disabled}
|
||||
onValueChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
@ -347,7 +389,7 @@ export const AddSettingsFormPartial = ({
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<Input className="bg-background" {...field} />
|
||||
<Input className="bg-background" {...field} onBlur={handleAutoSave} />
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
@ -372,7 +414,10 @@ export const AddSettingsFormPartial = ({
|
||||
value: option.value,
|
||||
}))}
|
||||
selectedValues={field.value}
|
||||
onChange={field.onChange}
|
||||
onChange={(value) => {
|
||||
field.onChange(value);
|
||||
void handleAutoSave();
|
||||
}}
|
||||
className="bg-background w-full"
|
||||
emptySelectionPlaceholder="Select signature types"
|
||||
/>
|
||||
@ -394,8 +439,12 @@ export const AddSettingsFormPartial = ({
|
||||
|
||||
<FormControl>
|
||||
<Select
|
||||
{...field}
|
||||
onValueChange={(value) => {
|
||||
field.onChange(value);
|
||||
void handleAutoSave();
|
||||
}}
|
||||
value={field.value}
|
||||
onValueChange={field.onChange}
|
||||
disabled={documentHasBeenSent}
|
||||
>
|
||||
<SelectTrigger className="bg-background">
|
||||
@ -430,8 +479,12 @@ export const AddSettingsFormPartial = ({
|
||||
<Combobox
|
||||
className="bg-background"
|
||||
options={TIME_ZONES}
|
||||
{...field}
|
||||
onChange={(value) => {
|
||||
value && field.onChange(value);
|
||||
void handleAutoSave();
|
||||
}}
|
||||
value={field.value}
|
||||
onChange={(value) => value && field.onChange(value)}
|
||||
disabled={documentHasBeenSent}
|
||||
/>
|
||||
</FormControl>
|
||||
@ -462,7 +515,7 @@ export const AddSettingsFormPartial = ({
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<Input className="bg-background" {...field} />
|
||||
<Input className="bg-background" {...field} onBlur={handleAutoSave} />
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import React, { useCallback, useId, useMemo, useRef, useState } from 'react';
|
||||
import { useCallback, useId, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import type { DropResult, SensorAPI } from '@hello-pangea/dnd';
|
||||
import { DragDropContext, Draggable, Droppable } from '@hello-pangea/dnd';
|
||||
@ -14,11 +14,14 @@ import { useFieldArray, useForm } from 'react-hook-form';
|
||||
import { prop, sortBy } from 'remeda';
|
||||
|
||||
import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
|
||||
import { useAutoSave } from '@documenso/lib/client-only/hooks/use-autosave';
|
||||
import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value';
|
||||
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||
import { ZRecipientAuthOptionsSchema } from '@documenso/lib/types/document-auth';
|
||||
import { nanoid } from '@documenso/lib/universal/id';
|
||||
import { canRecipientBeModified as utilCanRecipientBeModified } from '@documenso/lib/utils/recipients';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animate-generic-fade-in-out';
|
||||
import { RecipientActionAuthSelect } from '@documenso/ui/components/recipient/recipient-action-auth-select';
|
||||
import { RecipientRoleSelect } from '@documenso/ui/components/recipient/recipient-role-select';
|
||||
@ -28,6 +31,8 @@ import {
|
||||
DocumentReadOnlyFields,
|
||||
mapFieldsWithRecipients,
|
||||
} from '../../components/document/document-read-only-fields';
|
||||
import type { RecipientAutoCompleteOption } from '../../components/recipient/recipient-autocomplete-input';
|
||||
import { RecipientAutoCompleteInput } from '../../components/recipient/recipient-autocomplete-input';
|
||||
import { Button } from '../button';
|
||||
import { Checkbox } from '../checkbox';
|
||||
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '../form/form';
|
||||
@ -55,6 +60,7 @@ export type AddSignersFormProps = {
|
||||
signingOrder?: DocumentSigningOrder | null;
|
||||
allowDictateNextSigner?: boolean;
|
||||
onSubmit: (_data: TAddSignersFormSchema) => void;
|
||||
onAutoSave: (_data: TAddSignersFormSchema) => Promise<void>;
|
||||
isDocumentPdfLoaded: boolean;
|
||||
};
|
||||
|
||||
@ -65,6 +71,7 @@ export const AddSignersFormPartial = ({
|
||||
signingOrder,
|
||||
allowDictateNextSigner,
|
||||
onSubmit,
|
||||
onAutoSave,
|
||||
isDocumentPdfLoaded,
|
||||
}: AddSignersFormProps) => {
|
||||
const { _ } = useLingui();
|
||||
@ -72,6 +79,10 @@ export const AddSignersFormPartial = ({
|
||||
const { remaining } = useLimits();
|
||||
const { user } = useSession();
|
||||
|
||||
const [recipientSearchQuery, setRecipientSearchQuery] = useState('');
|
||||
|
||||
const debouncedRecipientSearchQuery = useDebouncedValue(recipientSearchQuery, 500);
|
||||
|
||||
const initialId = useId();
|
||||
const $sensorApi = useRef<SensorAPI | null>(null);
|
||||
|
||||
@ -79,6 +90,17 @@ export const AddSignersFormPartial = ({
|
||||
|
||||
const organisation = useCurrentOrganisation();
|
||||
|
||||
const { data: recipientSuggestionsData, isLoading } = trpc.recipient.suggestions.find.useQuery(
|
||||
{
|
||||
query: debouncedRecipientSearchQuery,
|
||||
},
|
||||
{
|
||||
enabled: debouncedRecipientSearchQuery.length > 1,
|
||||
},
|
||||
);
|
||||
|
||||
const recipientSuggestions = recipientSuggestionsData?.results || [];
|
||||
|
||||
const defaultRecipients = [
|
||||
{
|
||||
formId: initialId,
|
||||
@ -166,6 +188,29 @@ export const AddSignersFormPartial = ({
|
||||
name: 'signers',
|
||||
});
|
||||
|
||||
const emptySigners = useCallback(
|
||||
() => form.getValues('signers').filter((signer) => signer.email === ''),
|
||||
[form],
|
||||
);
|
||||
|
||||
const { scheduleSave } = useAutoSave(onAutoSave);
|
||||
|
||||
const handleAutoSave = async () => {
|
||||
if (emptySigners().length > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isFormValid = await form.trigger();
|
||||
|
||||
if (!isFormValid) {
|
||||
return;
|
||||
}
|
||||
|
||||
const formData = form.getValues();
|
||||
|
||||
scheduleSave(formData);
|
||||
};
|
||||
|
||||
const emptySignerIndex = watchedSigners.findIndex((signer) => !signer.name && !signer.email);
|
||||
const isUserAlreadyARecipient = watchedSigners.some(
|
||||
(signer) => signer.email.toLowerCase() === user?.email?.toLowerCase(),
|
||||
@ -216,31 +261,56 @@ export const AddSignersFormPartial = ({
|
||||
const formStateIndex = form.getValues('signers').findIndex((s) => s.formId === signer.formId);
|
||||
if (formStateIndex !== -1) {
|
||||
removeSigner(formStateIndex);
|
||||
|
||||
const updatedSigners = form.getValues('signers').filter((s) => s.formId !== signer.formId);
|
||||
form.setValue('signers', normalizeSigningOrders(updatedSigners));
|
||||
|
||||
form.setValue('signers', normalizeSigningOrders(updatedSigners), {
|
||||
shouldValidate: true,
|
||||
shouldDirty: true,
|
||||
});
|
||||
|
||||
void handleAutoSave();
|
||||
}
|
||||
};
|
||||
|
||||
const onAddSelfSigner = () => {
|
||||
if (emptySignerIndex !== -1) {
|
||||
setValue(`signers.${emptySignerIndex}.name`, user?.name ?? '');
|
||||
setValue(`signers.${emptySignerIndex}.email`, user?.email ?? '');
|
||||
} else {
|
||||
appendSigner({
|
||||
formId: nanoid(12),
|
||||
name: user?.name ?? '',
|
||||
email: user?.email ?? '',
|
||||
role: RecipientRole.SIGNER,
|
||||
actionAuth: [],
|
||||
signingOrder: signers.length > 0 ? (signers[signers.length - 1]?.signingOrder ?? 0) + 1 : 1,
|
||||
setValue(`signers.${emptySignerIndex}.name`, user?.name ?? '', {
|
||||
shouldValidate: true,
|
||||
shouldDirty: true,
|
||||
});
|
||||
setValue(`signers.${emptySignerIndex}.email`, user?.email ?? '', {
|
||||
shouldValidate: true,
|
||||
shouldDirty: true,
|
||||
});
|
||||
|
||||
form.setFocus(`signers.${emptySignerIndex}.email`);
|
||||
} else {
|
||||
appendSigner(
|
||||
{
|
||||
formId: nanoid(12),
|
||||
name: user?.name ?? '',
|
||||
email: user?.email ?? '',
|
||||
role: RecipientRole.SIGNER,
|
||||
actionAuth: [],
|
||||
signingOrder:
|
||||
signers.length > 0 ? (signers[signers.length - 1]?.signingOrder ?? 0) + 1 : 1,
|
||||
},
|
||||
{
|
||||
shouldFocus: true,
|
||||
},
|
||||
);
|
||||
|
||||
void form.trigger('signers');
|
||||
}
|
||||
};
|
||||
|
||||
const onKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.key === 'Enter' && event.target instanceof HTMLInputElement) {
|
||||
onAddSigner();
|
||||
}
|
||||
const handleRecipientAutoCompleteSelect = (
|
||||
index: number,
|
||||
suggestion: RecipientAutoCompleteOption,
|
||||
) => {
|
||||
setValue(`signers.${index}.email`, suggestion.email);
|
||||
setValue(`signers.${index}.name`, suggestion.name || '');
|
||||
};
|
||||
|
||||
const onDragEnd = useCallback(
|
||||
@ -263,7 +333,10 @@ export const AddSignersFormPartial = ({
|
||||
signingOrder: !canRecipientBeModified(signer.nativeId) ? signer.signingOrder : index + 1,
|
||||
}));
|
||||
|
||||
form.setValue('signers', updatedSigners);
|
||||
form.setValue('signers', updatedSigners, {
|
||||
shouldValidate: true,
|
||||
shouldDirty: true,
|
||||
});
|
||||
|
||||
const lastSigner = updatedSigners[updatedSigners.length - 1];
|
||||
if (lastSigner.role === RecipientRole.ASSISTANT) {
|
||||
@ -276,8 +349,10 @@ export const AddSignersFormPartial = ({
|
||||
}
|
||||
|
||||
await form.trigger('signers');
|
||||
|
||||
void handleAutoSave();
|
||||
},
|
||||
[form, canRecipientBeModified, watchedSigners, toast],
|
||||
[form, canRecipientBeModified, watchedSigners, handleAutoSave, toast],
|
||||
);
|
||||
|
||||
const handleRoleChange = useCallback(
|
||||
@ -287,7 +362,10 @@ export const AddSignersFormPartial = ({
|
||||
|
||||
// Handle parallel to sequential conversion for assistants
|
||||
if (role === RecipientRole.ASSISTANT && signingOrder === DocumentSigningOrder.PARALLEL) {
|
||||
form.setValue('signingOrder', DocumentSigningOrder.SEQUENTIAL);
|
||||
form.setValue('signingOrder', DocumentSigningOrder.SEQUENTIAL, {
|
||||
shouldValidate: true,
|
||||
shouldDirty: true,
|
||||
});
|
||||
toast({
|
||||
title: _(msg`Signing order is enabled.`),
|
||||
description: _(msg`You cannot add assistants when signing order is disabled.`),
|
||||
@ -302,7 +380,10 @@ export const AddSignersFormPartial = ({
|
||||
signingOrder: !canRecipientBeModified(signer.nativeId) ? signer.signingOrder : idx + 1,
|
||||
}));
|
||||
|
||||
form.setValue('signers', updatedSigners);
|
||||
form.setValue('signers', updatedSigners, {
|
||||
shouldValidate: true,
|
||||
shouldDirty: true,
|
||||
});
|
||||
|
||||
if (role === RecipientRole.ASSISTANT && index === updatedSigners.length - 1) {
|
||||
toast({
|
||||
@ -341,7 +422,10 @@ export const AddSignersFormPartial = ({
|
||||
signingOrder: !canRecipientBeModified(s.nativeId) ? s.signingOrder : idx + 1,
|
||||
}));
|
||||
|
||||
form.setValue('signers', updatedSigners);
|
||||
form.setValue('signers', updatedSigners, {
|
||||
shouldValidate: true,
|
||||
shouldDirty: true,
|
||||
});
|
||||
|
||||
if (signer.role === RecipientRole.ASSISTANT && newPosition === remainingSigners.length - 1) {
|
||||
toast({
|
||||
@ -364,9 +448,20 @@ export const AddSignersFormPartial = ({
|
||||
role: signer.role === RecipientRole.ASSISTANT ? RecipientRole.SIGNER : signer.role,
|
||||
}));
|
||||
|
||||
form.setValue('signers', updatedSigners);
|
||||
form.setValue('signingOrder', DocumentSigningOrder.PARALLEL);
|
||||
form.setValue('allowDictateNextSigner', false);
|
||||
form.setValue('signers', updatedSigners, {
|
||||
shouldValidate: true,
|
||||
shouldDirty: true,
|
||||
});
|
||||
form.setValue('signingOrder', DocumentSigningOrder.PARALLEL, {
|
||||
shouldValidate: true,
|
||||
shouldDirty: true,
|
||||
});
|
||||
form.setValue('allowDictateNextSigner', false, {
|
||||
shouldValidate: true,
|
||||
shouldDirty: true,
|
||||
});
|
||||
|
||||
void handleAutoSave();
|
||||
}, [form]);
|
||||
|
||||
return (
|
||||
@ -408,19 +503,39 @@ export const AddSignersFormPartial = ({
|
||||
|
||||
// If sequential signing is turned off, disable dictate next signer
|
||||
if (!checked) {
|
||||
form.setValue('allowDictateNextSigner', false);
|
||||
form.setValue('allowDictateNextSigner', false, {
|
||||
shouldValidate: true,
|
||||
shouldDirty: true,
|
||||
});
|
||||
}
|
||||
|
||||
void handleAutoSave();
|
||||
}}
|
||||
disabled={isSubmitting || hasDocumentBeenSent}
|
||||
disabled={isSubmitting || hasDocumentBeenSent || emptySigners().length !== 0}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormLabel
|
||||
htmlFor="signingOrder"
|
||||
className="text-sm leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
>
|
||||
<Trans>Enable signing order</Trans>
|
||||
</FormLabel>
|
||||
<div className="flex items-center text-sm leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">
|
||||
<FormLabel
|
||||
htmlFor="signingOrder"
|
||||
className="text-sm leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
>
|
||||
<Trans>Enable signing order</Trans>
|
||||
</FormLabel>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="text-muted-foreground ml-1 cursor-help">
|
||||
<HelpCircle className="h-3.5 w-3.5" />
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-80 p-4">
|
||||
<p>
|
||||
<Trans>Add 2 or more signers to enable signing order.</Trans>
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
@ -435,12 +550,15 @@ export const AddSignersFormPartial = ({
|
||||
{...field}
|
||||
id="allowDictateNextSigner"
|
||||
checked={value}
|
||||
onCheckedChange={field.onChange}
|
||||
onCheckedChange={(checked) => {
|
||||
field.onChange(checked);
|
||||
void handleAutoSave();
|
||||
}}
|
||||
disabled={isSubmitting || hasDocumentBeenSent || !isSigningOrderSequential}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<div className="flex items-center">
|
||||
<div className="flex items-center text-sm leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">
|
||||
<FormLabel
|
||||
htmlFor="allowDictateNextSigner"
|
||||
className="text-sm leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
@ -533,6 +651,7 @@ export const AddSignersFormPartial = ({
|
||||
<Input
|
||||
type="number"
|
||||
max={signers.length}
|
||||
data-testid="signing-order-input"
|
||||
className={cn(
|
||||
'w-full text-center',
|
||||
'[appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none',
|
||||
@ -541,10 +660,12 @@ export const AddSignersFormPartial = ({
|
||||
onChange={(e) => {
|
||||
field.onChange(e);
|
||||
handleSigningOrderChange(index, e.target.value);
|
||||
void handleAutoSave();
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
field.onBlur();
|
||||
handleSigningOrderChange(index, e.target.value);
|
||||
void handleAutoSave();
|
||||
}}
|
||||
disabled={
|
||||
snapshot.isDragging ||
|
||||
@ -579,16 +700,28 @@ export const AddSignersFormPartial = ({
|
||||
)}
|
||||
|
||||
<FormControl>
|
||||
<Input
|
||||
<RecipientAutoCompleteInput
|
||||
type="email"
|
||||
placeholder={_(msg`Email`)}
|
||||
{...field}
|
||||
value={field.value}
|
||||
disabled={
|
||||
snapshot.isDragging ||
|
||||
isSubmitting ||
|
||||
!canRecipientBeModified(signer.nativeId)
|
||||
}
|
||||
onKeyDown={onKeyDown}
|
||||
options={recipientSuggestions}
|
||||
onSelect={(suggestion) =>
|
||||
handleRecipientAutoCompleteSelect(index, suggestion)
|
||||
}
|
||||
onSearchQueryChange={(query) => {
|
||||
console.log('onSearchQueryChange', query);
|
||||
field.onChange(query);
|
||||
setRecipientSearchQuery(query);
|
||||
}}
|
||||
loading={isLoading}
|
||||
data-testid="signer-email-input"
|
||||
maxLength={254}
|
||||
onBlur={handleAutoSave}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
@ -617,7 +750,8 @@ export const AddSignersFormPartial = ({
|
||||
)}
|
||||
|
||||
<FormControl>
|
||||
<Input
|
||||
<RecipientAutoCompleteInput
|
||||
type="text"
|
||||
placeholder={_(msg`Name`)}
|
||||
{...field}
|
||||
disabled={
|
||||
@ -625,7 +759,17 @@ export const AddSignersFormPartial = ({
|
||||
isSubmitting ||
|
||||
!canRecipientBeModified(signer.nativeId)
|
||||
}
|
||||
onKeyDown={onKeyDown}
|
||||
options={recipientSuggestions}
|
||||
onSelect={(suggestion) =>
|
||||
handleRecipientAutoCompleteSelect(index, suggestion)
|
||||
}
|
||||
onSearchQueryChange={(query) => {
|
||||
field.onChange(query);
|
||||
setRecipientSearchQuery(query);
|
||||
}}
|
||||
loading={isLoading}
|
||||
maxLength={255}
|
||||
onBlur={handleAutoSave}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
@ -668,6 +812,7 @@ export const AddSignersFormPartial = ({
|
||||
|
||||
<div className="col-span-2 flex gap-x-2">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`signers.${index}.role`}
|
||||
render={({ field }) => (
|
||||
<FormItem
|
||||
@ -681,10 +826,11 @@ export const AddSignersFormPartial = ({
|
||||
<RecipientRoleSelect
|
||||
{...field}
|
||||
isAssistantEnabled={isSigningOrderSequential}
|
||||
onValueChange={(value) =>
|
||||
onValueChange={(value) => {
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
handleRoleChange(index, value as RecipientRole)
|
||||
}
|
||||
handleRoleChange(index, value as RecipientRole);
|
||||
void handleAutoSave();
|
||||
}}
|
||||
disabled={
|
||||
snapshot.isDragging ||
|
||||
isSubmitting ||
|
||||
@ -706,6 +852,7 @@ export const AddSignersFormPartial = ({
|
||||
'mb-6': form.formState.errors.signers?.[index],
|
||||
},
|
||||
)}
|
||||
data-testid="remove-signer-button"
|
||||
disabled={
|
||||
snapshot.isDragging ||
|
||||
isSubmitting ||
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
@ -8,6 +10,7 @@ import { AnimatePresence, motion } from 'framer-motion';
|
||||
import { InfoIcon } from 'lucide-react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
|
||||
import { useAutoSave } from '@documenso/lib/client-only/hooks/use-autosave';
|
||||
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
|
||||
import type { TDocument } from '@documenso/lib/types/document';
|
||||
@ -60,6 +63,7 @@ export type AddSubjectFormProps = {
|
||||
fields: Field[];
|
||||
document: TDocument;
|
||||
onSubmit: (_data: TAddSubjectFormSchema) => void;
|
||||
onAutoSave: (_data: TAddSubjectFormSchema) => Promise<void>;
|
||||
isDocumentPdfLoaded: boolean;
|
||||
};
|
||||
|
||||
@ -69,6 +73,7 @@ export const AddSubjectFormPartial = ({
|
||||
fields: fields,
|
||||
document,
|
||||
onSubmit,
|
||||
onAutoSave,
|
||||
isDocumentPdfLoaded,
|
||||
}: AddSubjectFormProps) => {
|
||||
const { _ } = useLingui();
|
||||
@ -95,6 +100,8 @@ export const AddSubjectFormPartial = ({
|
||||
handleSubmit,
|
||||
setValue,
|
||||
watch,
|
||||
trigger,
|
||||
getValues,
|
||||
formState: { isSubmitting },
|
||||
} = form;
|
||||
|
||||
@ -129,6 +136,35 @@ export const AddSubjectFormPartial = ({
|
||||
const onFormSubmit = handleSubmit(onSubmit);
|
||||
const { currentStep, totalSteps, previousStep } = useStep();
|
||||
|
||||
const { scheduleSave } = useAutoSave(onAutoSave);
|
||||
|
||||
const handleAutoSave = async () => {
|
||||
const isFormValid = await trigger();
|
||||
|
||||
if (!isFormValid) {
|
||||
return;
|
||||
}
|
||||
|
||||
const formData = getValues();
|
||||
|
||||
scheduleSave(formData);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const container = window.document.getElementById('document-flow-form-container');
|
||||
|
||||
const handleBlur = () => {
|
||||
void handleAutoSave();
|
||||
};
|
||||
|
||||
if (container) {
|
||||
container.addEventListener('blur', handleBlur, true);
|
||||
return () => {
|
||||
container.removeEventListener('blur', handleBlur, true);
|
||||
};
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<DocumentFlowFormContainerHeader
|
||||
@ -185,7 +221,6 @@ export const AddSubjectFormPartial = ({
|
||||
<FormLabel>
|
||||
<Trans>Email Sender</Trans>
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<Select
|
||||
{...field}
|
||||
@ -227,7 +262,7 @@ export const AddSubjectFormPartial = ({
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
<Input {...field} maxLength={254} />
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
@ -265,7 +300,7 @@ export const AddSubjectFormPartial = ({
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
<Input {...field} maxLength={255} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
@ -291,7 +326,11 @@ export const AddSubjectFormPartial = ({
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<Textarea className="bg-background mt-2 h-16 resize-none" {...field} />
|
||||
<Textarea
|
||||
className="bg-background mt-2 h-16 resize-none"
|
||||
{...field}
|
||||
maxLength={5000}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
|
||||
@ -6,6 +6,7 @@ import { useLingui } from '@lingui/react';
|
||||
import { FieldType } from '@prisma/client';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { useAutoSave } from '@documenso/lib/client-only/hooks/use-autosave';
|
||||
import {
|
||||
type TBaseFieldMeta as BaseFieldMeta,
|
||||
type TCheckboxFieldMeta as CheckboxFieldMeta,
|
||||
@ -48,6 +49,7 @@ export type FieldAdvancedSettingsProps = {
|
||||
onAdvancedSettings?: () => void;
|
||||
isDocumentPdfLoaded?: boolean;
|
||||
onSave?: (fieldState: FieldMeta) => void;
|
||||
onAutoSave?: (fieldState: FieldMeta) => Promise<void>;
|
||||
};
|
||||
|
||||
export type FieldMetaKeys =
|
||||
@ -146,7 +148,16 @@ const getDefaultState = (fieldType: FieldType): FieldMeta => {
|
||||
|
||||
export const FieldAdvancedSettings = forwardRef<HTMLDivElement, FieldAdvancedSettingsProps>(
|
||||
(
|
||||
{ title, description, field, fields, onAdvancedSettings, isDocumentPdfLoaded = true, onSave },
|
||||
{
|
||||
title,
|
||||
description,
|
||||
field,
|
||||
fields,
|
||||
onAdvancedSettings,
|
||||
isDocumentPdfLoaded = true,
|
||||
onSave,
|
||||
onAutoSave,
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const { _ } = useLingui();
|
||||
@ -177,6 +188,24 @@ export const FieldAdvancedSettings = forwardRef<HTMLDivElement, FieldAdvancedSet
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [fieldMeta]);
|
||||
|
||||
const { scheduleSave } = useAutoSave(onAutoSave || (async () => {}));
|
||||
|
||||
const handleAutoSave = () => {
|
||||
if (errors.length === 0) {
|
||||
scheduleSave(fieldState);
|
||||
}
|
||||
};
|
||||
|
||||
// Auto-save to localStorage and schedule remote save when fieldState changes
|
||||
useEffect(() => {
|
||||
try {
|
||||
localStorage.setItem(localStorageKey, JSON.stringify(fieldState));
|
||||
handleAutoSave();
|
||||
} catch (error) {
|
||||
console.error('Failed to save to localStorage:', error);
|
||||
}
|
||||
}, [fieldState, localStorageKey, handleAutoSave]);
|
||||
|
||||
const handleFieldChange = (
|
||||
key: FieldMetaKeys,
|
||||
value:
|
||||
@ -325,7 +354,10 @@ export const FieldAdvancedSettings = forwardRef<HTMLDivElement, FieldAdvancedSet
|
||||
)}
|
||||
</DocumentFlowFormContainerContent>
|
||||
|
||||
<DocumentFlowFormContainerFooter className="mt-auto">
|
||||
<DocumentFlowFormContainerFooter
|
||||
className="mt-auto"
|
||||
data-testid="field-advanced-settings-footer"
|
||||
>
|
||||
<DocumentFlowFormContainerActions
|
||||
goNextLabel={msg`Save`}
|
||||
goBackLabel={msg`Cancel`}
|
||||
|
||||
@ -8,6 +8,8 @@ const Popover = PopoverPrimitive.Root;
|
||||
|
||||
const PopoverTrigger = PopoverPrimitive.Trigger;
|
||||
|
||||
const PopoverAnchor = PopoverPrimitive.Anchor;
|
||||
|
||||
const PopoverContent = React.forwardRef<
|
||||
React.ElementRef<typeof PopoverPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
|
||||
@ -91,4 +93,4 @@ const PopoverHover = ({ trigger, children, contentProps, side = 'top' }: Popover
|
||||
);
|
||||
};
|
||||
|
||||
export { Popover, PopoverTrigger, PopoverContent, PopoverHover };
|
||||
export { Popover, PopoverTrigger, PopoverAnchor, PopoverContent, PopoverHover };
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import type { HTMLAttributes } from 'react';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { KeyboardIcon, UploadCloudIcon } from 'lucide-react';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
@ -146,21 +147,21 @@ export const SignaturePad = ({
|
||||
{drawSignatureEnabled && (
|
||||
<TabsTrigger value="draw">
|
||||
<SignatureIcon className="mr-2 size-4" />
|
||||
Draw
|
||||
<Trans>Draw</Trans>
|
||||
</TabsTrigger>
|
||||
)}
|
||||
|
||||
{typedSignatureEnabled && (
|
||||
<TabsTrigger value="text">
|
||||
<KeyboardIcon className="mr-2 size-4" />
|
||||
Type
|
||||
<Trans>Type</Trans>
|
||||
</TabsTrigger>
|
||||
)}
|
||||
|
||||
{uploadSignatureEnabled && (
|
||||
<TabsTrigger value="image">
|
||||
<UploadCloudIcon className="mr-2 size-4" />
|
||||
Upload
|
||||
<Trans>Upload</Trans>
|
||||
</TabsTrigger>
|
||||
)}
|
||||
</TabsList>
|
||||
|
||||
@ -21,6 +21,7 @@ import { useFieldArray, useForm } from 'react-hook-form';
|
||||
import { useHotkeys } from 'react-hotkeys-hook';
|
||||
|
||||
import { getBoundingClientRect } from '@documenso/lib/client-only/get-bounding-client-rect';
|
||||
import { useAutoSave } from '@documenso/lib/client-only/hooks/use-autosave';
|
||||
import { useDocumentElement } from '@documenso/lib/client-only/hooks/use-document-element';
|
||||
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
|
||||
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
|
||||
@ -73,6 +74,7 @@ export type AddTemplateFieldsFormProps = {
|
||||
recipients: Recipient[];
|
||||
fields: Field[];
|
||||
onSubmit: (_data: TAddTemplateFieldsFormSchema) => void;
|
||||
onAutoSave: (_data: TAddTemplateFieldsFormSchema) => Promise<void>;
|
||||
teamId: number;
|
||||
};
|
||||
|
||||
@ -81,6 +83,7 @@ export const AddTemplateFieldsFormPartial = ({
|
||||
recipients,
|
||||
fields,
|
||||
onSubmit,
|
||||
onAutoSave,
|
||||
teamId,
|
||||
}: AddTemplateFieldsFormProps) => {
|
||||
const { _ } = useLingui();
|
||||
@ -121,6 +124,20 @@ export const AddTemplateFieldsFormPartial = ({
|
||||
|
||||
const onFormSubmit = form.handleSubmit(onSubmit);
|
||||
|
||||
const { scheduleSave } = useAutoSave(onAutoSave);
|
||||
|
||||
const handleAutoSave = async () => {
|
||||
const isFormValid = await form.trigger();
|
||||
|
||||
if (!isFormValid) {
|
||||
return;
|
||||
}
|
||||
|
||||
const formData = form.getValues();
|
||||
|
||||
scheduleSave(formData);
|
||||
};
|
||||
|
||||
const {
|
||||
append,
|
||||
remove,
|
||||
@ -160,6 +177,7 @@ export const AddTemplateFieldsFormPartial = ({
|
||||
};
|
||||
|
||||
append(newField);
|
||||
void handleAutoSave();
|
||||
|
||||
return;
|
||||
}
|
||||
@ -187,6 +205,7 @@ export const AddTemplateFieldsFormPartial = ({
|
||||
append(newField);
|
||||
});
|
||||
|
||||
void handleAutoSave();
|
||||
return;
|
||||
}
|
||||
|
||||
@ -198,7 +217,15 @@ export const AddTemplateFieldsFormPartial = ({
|
||||
});
|
||||
}
|
||||
},
|
||||
[append, lastActiveField, selectedSigner?.email, selectedSigner?.id, toast],
|
||||
[
|
||||
append,
|
||||
lastActiveField,
|
||||
selectedSigner?.email,
|
||||
selectedSigner?.id,
|
||||
selectedSigner?.token,
|
||||
toast,
|
||||
handleAutoSave,
|
||||
],
|
||||
);
|
||||
|
||||
const onFieldPaste = useCallback(
|
||||
@ -218,9 +245,18 @@ export const AddTemplateFieldsFormPartial = ({
|
||||
pageX: copiedField.pageX + 3,
|
||||
pageY: copiedField.pageY + 3,
|
||||
});
|
||||
|
||||
void handleAutoSave();
|
||||
}
|
||||
},
|
||||
[append, fieldClipboard, selectedSigner?.email, selectedSigner?.id, selectedSigner?.token],
|
||||
[
|
||||
append,
|
||||
fieldClipboard,
|
||||
selectedSigner?.email,
|
||||
selectedSigner?.id,
|
||||
selectedSigner?.token,
|
||||
handleAutoSave,
|
||||
],
|
||||
);
|
||||
|
||||
useHotkeys(['ctrl+c', 'meta+c'], (evt) => onFieldCopy(evt));
|
||||
@ -378,8 +414,10 @@ export const AddTemplateFieldsFormPartial = ({
|
||||
pageWidth,
|
||||
pageHeight,
|
||||
});
|
||||
|
||||
void handleAutoSave();
|
||||
},
|
||||
[getFieldPosition, localFields, update],
|
||||
[getFieldPosition, localFields, update, handleAutoSave],
|
||||
);
|
||||
|
||||
const onFieldMove = useCallback(
|
||||
@ -401,8 +439,10 @@ export const AddTemplateFieldsFormPartial = ({
|
||||
pageX,
|
||||
pageY,
|
||||
});
|
||||
|
||||
void handleAutoSave();
|
||||
},
|
||||
[getFieldPosition, localFields, update],
|
||||
[getFieldPosition, localFields, update, handleAutoSave],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@ -504,6 +544,7 @@ export const AddTemplateFieldsFormPartial = ({
|
||||
});
|
||||
|
||||
form.setValue('fields', updatedFields);
|
||||
void handleAutoSave();
|
||||
};
|
||||
|
||||
return (
|
||||
@ -519,6 +560,10 @@ export const AddTemplateFieldsFormPartial = ({
|
||||
fields={localFields}
|
||||
onAdvancedSettings={handleAdvancedSettings}
|
||||
onSave={handleSavedFieldSettings}
|
||||
onAutoSave={async (fieldState) => {
|
||||
handleSavedFieldSettings(fieldState);
|
||||
await handleAutoSave();
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
@ -566,12 +611,22 @@ export const AddTemplateFieldsFormPartial = ({
|
||||
defaultWidth={DEFAULT_WIDTH_PX}
|
||||
passive={isFieldWithinBounds && !!selectedField}
|
||||
onFocus={() => setLastActiveField(field)}
|
||||
onBlur={() => setLastActiveField(null)}
|
||||
onBlur={() => {
|
||||
setLastActiveField(null);
|
||||
void handleAutoSave();
|
||||
}}
|
||||
onResize={(options) => onFieldResize(options, index)}
|
||||
onMove={(options) => onFieldMove(options, index)}
|
||||
onRemove={() => remove(index)}
|
||||
onDuplicate={() => onFieldCopy(null, { duplicate: true })}
|
||||
onDuplicateAllPages={() => onFieldCopy(null, { duplicateAll: true })}
|
||||
onRemove={() => {
|
||||
remove(index);
|
||||
void handleAutoSave();
|
||||
}}
|
||||
onDuplicate={() => {
|
||||
onFieldCopy(null, { duplicate: true });
|
||||
}}
|
||||
onDuplicateAllPages={() => {
|
||||
onFieldCopy(null, { duplicateAll: true });
|
||||
}}
|
||||
onAdvancedSettings={() => {
|
||||
setCurrentField(field);
|
||||
handleAdvancedSettings();
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react';
|
||||
import { useCallback, useId, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import type { DropResult, SensorAPI } from '@hello-pangea/dnd';
|
||||
import { DragDropContext, Draggable, Droppable } from '@hello-pangea/dnd';
|
||||
@ -12,6 +12,7 @@ import { motion } from 'framer-motion';
|
||||
import { GripVerticalIcon, HelpCircle, Link2Icon, Plus, Trash } from 'lucide-react';
|
||||
import { useFieldArray, useForm } from 'react-hook-form';
|
||||
|
||||
import { useAutoSave } from '@documenso/lib/client-only/hooks/use-autosave';
|
||||
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||
import { isTemplateRecipientEmailPlaceholder } from '@documenso/lib/constants/template';
|
||||
@ -55,6 +56,7 @@ export type AddTemplatePlaceholderRecipientsFormProps = {
|
||||
allowDictateNextSigner?: boolean;
|
||||
templateDirectLink?: TemplateDirectLink | null;
|
||||
onSubmit: (_data: TAddTemplatePlacholderRecipientsFormSchema) => void;
|
||||
onAutoSave: (_data: TAddTemplatePlacholderRecipientsFormSchema) => Promise<void>;
|
||||
isDocumentPdfLoaded: boolean;
|
||||
};
|
||||
|
||||
@ -67,6 +69,7 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
|
||||
allowDictateNextSigner,
|
||||
isDocumentPdfLoaded,
|
||||
onSubmit,
|
||||
onAutoSave,
|
||||
}: AddTemplatePlaceholderRecipientsFormProps) => {
|
||||
const initialId = useId();
|
||||
const $sensorApi = useRef<SensorAPI | null>(null);
|
||||
@ -123,15 +126,38 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
form.reset({
|
||||
signers: generateDefaultFormSigners(),
|
||||
signingOrder: signingOrder || DocumentSigningOrder.PARALLEL,
|
||||
allowDictateNextSigner: allowDictateNextSigner ?? false,
|
||||
});
|
||||
const emptySigners = useCallback(
|
||||
() => form.getValues('signers').filter((signer) => signer.email === ''),
|
||||
[form],
|
||||
);
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [recipients]);
|
||||
const { scheduleSave } = useAutoSave(onAutoSave);
|
||||
|
||||
const handleAutoSave = async () => {
|
||||
if (emptySigners().length > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isFormValid = await form.trigger();
|
||||
|
||||
if (!isFormValid) {
|
||||
return;
|
||||
}
|
||||
|
||||
const formData = form.getValues();
|
||||
|
||||
scheduleSave(formData);
|
||||
};
|
||||
|
||||
// useEffect(() => {
|
||||
// form.reset({
|
||||
// signers: generateDefaultFormSigners(),
|
||||
// signingOrder: signingOrder || DocumentSigningOrder.PARALLEL,
|
||||
// allowDictateNextSigner: allowDictateNextSigner ?? false,
|
||||
// });
|
||||
|
||||
// // eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
// }, [recipients]);
|
||||
|
||||
// Always show advanced settings if any recipient has auth options.
|
||||
const alwaysShowAdvancedSettings = useMemo(() => {
|
||||
@ -204,7 +230,12 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
|
||||
const onRemoveSigner = (index: number) => {
|
||||
removeSigner(index);
|
||||
const updatedSigners = signers.filter((_, idx) => idx !== index);
|
||||
form.setValue('signers', normalizeSigningOrders(updatedSigners));
|
||||
form.setValue('signers', normalizeSigningOrders(updatedSigners), {
|
||||
shouldValidate: true,
|
||||
shouldDirty: true,
|
||||
});
|
||||
|
||||
void handleAutoSave();
|
||||
};
|
||||
|
||||
const isSignerDirectRecipient = (
|
||||
@ -231,7 +262,10 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
|
||||
signingOrder: index + 1,
|
||||
}));
|
||||
|
||||
form.setValue('signers', updatedSigners);
|
||||
form.setValue('signers', updatedSigners, {
|
||||
shouldValidate: true,
|
||||
shouldDirty: true,
|
||||
});
|
||||
|
||||
const lastSigner = updatedSigners[updatedSigners.length - 1];
|
||||
if (lastSigner.role === RecipientRole.ASSISTANT) {
|
||||
@ -244,8 +278,10 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
|
||||
}
|
||||
|
||||
await form.trigger('signers');
|
||||
|
||||
void handleAutoSave();
|
||||
},
|
||||
[form, watchedSigners, toast],
|
||||
[form, watchedSigners, toast, handleAutoSave],
|
||||
);
|
||||
|
||||
const handleSigningOrderChange = useCallback(
|
||||
@ -273,7 +309,10 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
|
||||
signingOrder: idx + 1,
|
||||
}));
|
||||
|
||||
form.setValue('signers', updatedSigners);
|
||||
form.setValue('signers', updatedSigners, {
|
||||
shouldValidate: true,
|
||||
shouldDirty: true,
|
||||
});
|
||||
|
||||
if (signer.role === RecipientRole.ASSISTANT && newPosition === remainingSigners.length - 1) {
|
||||
toast({
|
||||
@ -283,8 +322,10 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
void handleAutoSave();
|
||||
},
|
||||
[form, toast],
|
||||
[form, toast, handleAutoSave],
|
||||
);
|
||||
|
||||
const handleRoleChange = useCallback(
|
||||
@ -294,7 +335,10 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
|
||||
|
||||
// Handle parallel to sequential conversion for assistants
|
||||
if (role === RecipientRole.ASSISTANT && signingOrder === DocumentSigningOrder.PARALLEL) {
|
||||
form.setValue('signingOrder', DocumentSigningOrder.SEQUENTIAL);
|
||||
form.setValue('signingOrder', DocumentSigningOrder.SEQUENTIAL, {
|
||||
shouldValidate: true,
|
||||
shouldDirty: true,
|
||||
});
|
||||
toast({
|
||||
title: _(msg`Signing order is enabled.`),
|
||||
description: _(msg`You cannot add assistants when signing order is disabled.`),
|
||||
@ -309,7 +353,10 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
|
||||
signingOrder: idx + 1,
|
||||
}));
|
||||
|
||||
form.setValue('signers', updatedSigners);
|
||||
form.setValue('signers', updatedSigners, {
|
||||
shouldValidate: true,
|
||||
shouldDirty: true,
|
||||
});
|
||||
|
||||
if (role === RecipientRole.ASSISTANT && index === updatedSigners.length - 1) {
|
||||
toast({
|
||||
@ -319,8 +366,10 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
void handleAutoSave();
|
||||
},
|
||||
[form, toast],
|
||||
[form, toast, handleAutoSave],
|
||||
);
|
||||
|
||||
const [showSigningOrderConfirmation, setShowSigningOrderConfirmation] = useState(false);
|
||||
@ -334,10 +383,21 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
|
||||
role: signer.role === RecipientRole.ASSISTANT ? RecipientRole.SIGNER : signer.role,
|
||||
}));
|
||||
|
||||
form.setValue('signers', updatedSigners);
|
||||
form.setValue('signingOrder', DocumentSigningOrder.PARALLEL);
|
||||
form.setValue('allowDictateNextSigner', false);
|
||||
}, [form]);
|
||||
form.setValue('signers', updatedSigners, {
|
||||
shouldValidate: true,
|
||||
shouldDirty: true,
|
||||
});
|
||||
form.setValue('signingOrder', DocumentSigningOrder.PARALLEL, {
|
||||
shouldValidate: true,
|
||||
shouldDirty: true,
|
||||
});
|
||||
form.setValue('allowDictateNextSigner', false, {
|
||||
shouldValidate: true,
|
||||
shouldDirty: true,
|
||||
});
|
||||
|
||||
void handleAutoSave();
|
||||
}, [form, handleAutoSave]);
|
||||
|
||||
return (
|
||||
<>
|
||||
@ -382,8 +442,13 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
|
||||
|
||||
// If sequential signing is turned off, disable dictate next signer
|
||||
if (!checked) {
|
||||
form.setValue('allowDictateNextSigner', false);
|
||||
form.setValue('allowDictateNextSigner', false, {
|
||||
shouldValidate: true,
|
||||
shouldDirty: true,
|
||||
});
|
||||
}
|
||||
|
||||
void handleAutoSave();
|
||||
}}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
@ -409,7 +474,10 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
|
||||
{...field}
|
||||
id="allowDictateNextSigner"
|
||||
checked={value}
|
||||
onCheckedChange={field.onChange}
|
||||
onCheckedChange={(checked) => {
|
||||
field.onChange(checked);
|
||||
void handleAutoSave();
|
||||
}}
|
||||
disabled={isSubmitting || !isSigningOrderSequential}
|
||||
/>
|
||||
</FormControl>
|
||||
@ -500,6 +568,7 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
|
||||
<Input
|
||||
type="number"
|
||||
max={signers.length}
|
||||
data-testid="placeholder-recipient-signing-order-input"
|
||||
className={cn(
|
||||
'w-full text-center',
|
||||
'[appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none',
|
||||
@ -558,6 +627,9 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
|
||||
signers[index].email === user?.email ||
|
||||
isSignerDirectRecipient(signer)
|
||||
}
|
||||
maxLength={254}
|
||||
onBlur={handleAutoSave}
|
||||
data-testid="placeholder-recipient-email-input"
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
@ -592,6 +664,9 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
|
||||
signers[index].email === user?.email ||
|
||||
isSignerDirectRecipient(signer)
|
||||
}
|
||||
maxLength={255}
|
||||
onBlur={handleAutoSave}
|
||||
data-testid="placeholder-recipient-name-input"
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
@ -633,10 +708,10 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
|
||||
<FormControl>
|
||||
<RecipientRoleSelect
|
||||
{...field}
|
||||
onValueChange={(value) =>
|
||||
onValueChange={(value) => {
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
handleRoleChange(index, value as RecipientRole)
|
||||
}
|
||||
handleRoleChange(index, value as RecipientRole);
|
||||
}}
|
||||
disabled={isSubmitting}
|
||||
hideCCRecipients={isSignerDirectRecipient(signer)}
|
||||
/>
|
||||
@ -672,6 +747,7 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
|
||||
className="col-span-1 mt-auto inline-flex h-10 w-10 items-center justify-center text-slate-500 hover:opacity-80 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
disabled={isSubmitting || signers.length === 1}
|
||||
onClick={() => onRemoveSigner(index)}
|
||||
data-testid="remove-placeholder-recipient-button"
|
||||
>
|
||||
<Trash className="h-5 w-5" />
|
||||
</button>
|
||||
|
||||
@ -9,6 +9,7 @@ import { InfoIcon } from 'lucide-react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { useAutoSave } from '@documenso/lib/client-only/hooks/use-autosave';
|
||||
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||
import { DATE_FORMATS, DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats';
|
||||
import {
|
||||
@ -83,6 +84,7 @@ export type AddTemplateSettingsFormProps = {
|
||||
template: TTemplate;
|
||||
currentTeamMemberRole?: TeamMemberRole;
|
||||
onSubmit: (_data: TAddTemplateSettingsFormSchema) => void;
|
||||
onAutoSave: (_data: TAddTemplateSettingsFormSchema) => Promise<void>;
|
||||
};
|
||||
|
||||
export const AddTemplateSettingsFormPartial = ({
|
||||
@ -93,6 +95,7 @@ export const AddTemplateSettingsFormPartial = ({
|
||||
template,
|
||||
currentTeamMemberRole,
|
||||
onSubmit,
|
||||
onAutoSave,
|
||||
}: AddTemplateSettingsFormProps) => {
|
||||
const { t, i18n } = useLingui();
|
||||
|
||||
@ -160,6 +163,28 @@ export const AddTemplateSettingsFormPartial = ({
|
||||
}
|
||||
}, [form, form.setValue, form.formState.touchedFields.meta?.timezone]);
|
||||
|
||||
const { scheduleSave } = useAutoSave(onAutoSave);
|
||||
|
||||
const handleAutoSave = async () => {
|
||||
const isFormValid = await form.trigger();
|
||||
|
||||
if (!isFormValid) {
|
||||
return;
|
||||
}
|
||||
|
||||
const formData = form.getValues();
|
||||
|
||||
/*
|
||||
* Parse the form data through the Zod schema to handle transformations
|
||||
* (like -1 -> undefined for the Document Global Auth Access)
|
||||
*/
|
||||
const parseResult = ZAddTemplateSettingsFormSchema.safeParse(formData);
|
||||
|
||||
if (parseResult.success) {
|
||||
scheduleSave(parseResult.data);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<DocumentFlowFormContainerHeader
|
||||
@ -191,7 +216,12 @@ export const AddTemplateSettingsFormPartial = ({
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<Input className="bg-background" {...field} />
|
||||
<Input
|
||||
className="bg-background"
|
||||
{...field}
|
||||
maxLength={255}
|
||||
onBlur={handleAutoSave}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
@ -219,7 +249,13 @@ export const AddTemplateSettingsFormPartial = ({
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<Select {...field} onValueChange={field.onChange}>
|
||||
<Select
|
||||
{...field}
|
||||
onValueChange={(value) => {
|
||||
field.onChange(value);
|
||||
void handleAutoSave();
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="bg-background">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
@ -250,9 +286,13 @@ export const AddTemplateSettingsFormPartial = ({
|
||||
|
||||
<FormControl>
|
||||
<DocumentGlobalAuthAccessSelect
|
||||
{...field}
|
||||
onValueChange={(value) => {
|
||||
field.onChange(value);
|
||||
void handleAutoSave();
|
||||
}}
|
||||
value={field.value}
|
||||
disabled={field.disabled}
|
||||
onValueChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
@ -275,7 +315,10 @@ export const AddTemplateSettingsFormPartial = ({
|
||||
canUpdateVisibility={canUpdateVisibility}
|
||||
currentTeamMemberRole={currentTeamMemberRole}
|
||||
{...field}
|
||||
onValueChange={field.onChange}
|
||||
onValueChange={(value) => {
|
||||
field.onChange(value);
|
||||
void handleAutoSave();
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
@ -334,7 +377,13 @@ export const AddTemplateSettingsFormPartial = ({
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<Select {...field} onValueChange={field.onChange}>
|
||||
<Select
|
||||
{...field}
|
||||
onValueChange={(value) => {
|
||||
field.onChange(value);
|
||||
void handleAutoSave();
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="bg-background text-muted-foreground">
|
||||
<SelectValue data-testid="documentDistributionMethodSelectValue" />
|
||||
</SelectTrigger>
|
||||
@ -371,7 +420,10 @@ export const AddTemplateSettingsFormPartial = ({
|
||||
value: option.value,
|
||||
}))}
|
||||
selectedValues={field.value}
|
||||
onChange={field.onChange}
|
||||
onChange={(value) => {
|
||||
field.onChange(value);
|
||||
void handleAutoSave();
|
||||
}}
|
||||
className="bg-background w-full"
|
||||
emptySelectionPlaceholder="Select signature types"
|
||||
/>
|
||||
@ -395,9 +447,13 @@ export const AddTemplateSettingsFormPartial = ({
|
||||
|
||||
<FormControl>
|
||||
<DocumentGlobalAuthActionSelect
|
||||
{...field}
|
||||
onValueChange={(value) => {
|
||||
field.onChange(value);
|
||||
void handleAutoSave();
|
||||
}}
|
||||
value={field.value}
|
||||
disabled={field.disabled}
|
||||
onValueChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
@ -468,7 +524,7 @@ export const AddTemplateSettingsFormPartial = ({
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
<Input {...field} maxLength={254} />
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
@ -488,7 +544,7 @@ export const AddTemplateSettingsFormPartial = ({
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
<Input {...field} maxLength={254} onBlur={handleAutoSave} />
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
@ -515,7 +571,12 @@ export const AddTemplateSettingsFormPartial = ({
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<Textarea className="bg-background h-16 resize-none" {...field} />
|
||||
<Textarea
|
||||
className="bg-background h-16 resize-none"
|
||||
{...field}
|
||||
maxLength={5000}
|
||||
onBlur={handleAutoSave}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
@ -525,7 +586,12 @@ export const AddTemplateSettingsFormPartial = ({
|
||||
|
||||
<DocumentEmailCheckboxes
|
||||
value={emailSettings}
|
||||
onChange={(value) => form.setValue('meta.emailSettings', value)}
|
||||
onChange={(value) => {
|
||||
form.setValue('meta.emailSettings', value, {
|
||||
shouldDirty: true,
|
||||
});
|
||||
void handleAutoSave();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
@ -563,7 +629,12 @@ export const AddTemplateSettingsFormPartial = ({
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<Input className="bg-background" {...field} />
|
||||
<Input
|
||||
className="bg-background"
|
||||
{...field}
|
||||
maxLength={255}
|
||||
onBlur={handleAutoSave}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
@ -581,7 +652,13 @@ export const AddTemplateSettingsFormPartial = ({
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<Select {...field} onValueChange={field.onChange}>
|
||||
<Select
|
||||
{...field}
|
||||
onValueChange={(value) => {
|
||||
field.onChange(value);
|
||||
void handleAutoSave();
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="bg-background">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
@ -615,7 +692,10 @@ export const AddTemplateSettingsFormPartial = ({
|
||||
className="bg-background time-zone-field"
|
||||
options={TIME_ZONES}
|
||||
{...field}
|
||||
onChange={(value) => value && field.onChange(value)}
|
||||
onChange={(value) => {
|
||||
value && field.onChange(value);
|
||||
void handleAutoSave();
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
@ -645,7 +725,12 @@ export const AddTemplateSettingsFormPartial = ({
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<Input className="bg-background" {...field} />
|
||||
<Input
|
||||
className="bg-background"
|
||||
{...field}
|
||||
maxLength={255}
|
||||
onBlur={handleAutoSave}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
|
||||
Reference in New Issue
Block a user