mirror of
https://github.com/documenso/documenso.git
synced 2025-11-20 19:51:32 +10:00
Merge branch 'main' into feat/expiry-links
This commit is contained in:
@ -310,12 +310,11 @@ export const ZGenerateDocumentFromTemplateMutationSchema = z.object({
|
||||
)
|
||||
.refine(
|
||||
(schema) => {
|
||||
const emails = schema.map((signer) => signer.email.toLowerCase());
|
||||
const ids = schema.map((signer) => signer.id);
|
||||
|
||||
return new Set(emails).size === emails.length && new Set(ids).size === ids.length;
|
||||
return new Set(ids).size === ids.length;
|
||||
},
|
||||
{ message: 'Recipient IDs and emails must be unique' },
|
||||
{ message: 'Recipient IDs must be unique' },
|
||||
),
|
||||
meta: z
|
||||
.object({
|
||||
|
||||
@ -0,0 +1,435 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { nanoid } from '@documenso/lib/universal/id';
|
||||
import { seedOrganisationMembers } from '@documenso/prisma/seed/organisations';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
|
||||
import { apiSignin } from '../../fixtures/authentication';
|
||||
|
||||
test('[ADMIN]: promote member to owner', async ({ page }) => {
|
||||
// Create an admin user who can access the admin panel
|
||||
const { user: adminUser } = await seedUser({
|
||||
isAdmin: true,
|
||||
});
|
||||
|
||||
// Create an organisation owner
|
||||
const { user: ownerUser, organisation } = await seedUser({
|
||||
isPersonalOrganisation: false,
|
||||
});
|
||||
|
||||
// Create organisation members with different roles
|
||||
const memberEmail = `member-${nanoid()}@test.documenso.com`;
|
||||
const managerEmail = `manager-${nanoid()}@test.documenso.com`;
|
||||
const adminMemberEmail = `admin-member-${nanoid()}@test.documenso.com`;
|
||||
|
||||
const [memberUser, managerUser, adminMemberUser] = await seedOrganisationMembers({
|
||||
members: [
|
||||
{
|
||||
email: memberEmail,
|
||||
name: 'Test Member',
|
||||
organisationRole: 'MEMBER',
|
||||
},
|
||||
{
|
||||
email: managerEmail,
|
||||
name: 'Test Manager',
|
||||
organisationRole: 'MANAGER',
|
||||
},
|
||||
{
|
||||
email: adminMemberEmail,
|
||||
name: 'Test Admin Member',
|
||||
organisationRole: 'ADMIN',
|
||||
},
|
||||
],
|
||||
organisationId: organisation.id,
|
||||
});
|
||||
|
||||
// Sign in as admin and navigate to the organisation admin page
|
||||
await apiSignin({
|
||||
page,
|
||||
email: adminUser.email,
|
||||
redirectPath: `/admin/organisations/${organisation.id}`,
|
||||
});
|
||||
|
||||
// Verify we're on the admin organisation page
|
||||
await expect(page.getByText(`Manage organisation`)).toBeVisible();
|
||||
|
||||
await expect(page.getByLabel('Organisation Name')).toHaveValue(organisation.name);
|
||||
|
||||
// Check that the organisation members table shows the correct roles
|
||||
const ownerRow = page.getByRole('row', { name: ownerUser.email });
|
||||
|
||||
await expect(ownerRow).toBeVisible();
|
||||
await expect(ownerRow.getByRole('status').filter({ hasText: 'Owner' })).toBeVisible();
|
||||
|
||||
await expect(page.getByRole('row', { name: memberUser.email })).toBeVisible();
|
||||
await expect(page.getByRole('row', { name: adminMemberUser.email })).toBeVisible();
|
||||
await expect(page.getByRole('row', { name: managerUser.email })).toBeVisible();
|
||||
|
||||
// Test promoting a MEMBER to owner
|
||||
const memberRow = page.getByRole('row', { name: memberUser.email });
|
||||
|
||||
// Find and click the "Promote to owner" button for the member
|
||||
const promoteButton = memberRow.getByRole('button', { name: 'Promote to owner' });
|
||||
await expect(promoteButton).toBeVisible();
|
||||
await expect(promoteButton).not.toBeDisabled();
|
||||
|
||||
await promoteButton.click();
|
||||
|
||||
// Verify success toast appears
|
||||
await expect(page.getByText('Member promoted to owner successfully').first()).toBeVisible();
|
||||
|
||||
// Reload the page to see the changes
|
||||
await page.reload();
|
||||
|
||||
// Verify that the member is now the owner
|
||||
const newOwnerRow = page.getByRole('row', { name: memberUser.email });
|
||||
await expect(newOwnerRow.getByRole('status').filter({ hasText: 'Owner' })).toBeVisible();
|
||||
|
||||
// Verify that the previous owner is no longer marked as owner
|
||||
const previousOwnerRow = page.getByRole('row', { name: ownerUser.email });
|
||||
await expect(previousOwnerRow.getByRole('status').filter({ hasText: 'Owner' })).not.toBeVisible();
|
||||
|
||||
// Verify that the promote button is now disabled for the new owner
|
||||
const newOwnerPromoteButton = newOwnerRow.getByRole('button', { name: 'Promote to owner' });
|
||||
await expect(newOwnerPromoteButton).toBeDisabled();
|
||||
|
||||
// Test that we can't promote the current owner (button should be disabled)
|
||||
await expect(newOwnerPromoteButton).toHaveAttribute('disabled');
|
||||
});
|
||||
|
||||
test('[ADMIN]: promote manager to owner', async ({ page }) => {
|
||||
// Create an admin user who can access the admin panel
|
||||
const { user: adminUser } = await seedUser({
|
||||
isAdmin: true,
|
||||
});
|
||||
|
||||
// Create an organisation with owner and manager
|
||||
const { organisation } = await seedUser({
|
||||
isPersonalOrganisation: false,
|
||||
});
|
||||
|
||||
const managerEmail = `manager-${nanoid()}@test.documenso.com`;
|
||||
|
||||
const [managerUser] = await seedOrganisationMembers({
|
||||
members: [
|
||||
{
|
||||
email: managerEmail,
|
||||
name: 'Test Manager',
|
||||
organisationRole: 'MANAGER',
|
||||
},
|
||||
],
|
||||
organisationId: organisation.id,
|
||||
});
|
||||
|
||||
// Sign in as admin
|
||||
await apiSignin({
|
||||
page,
|
||||
email: adminUser.email,
|
||||
redirectPath: `/admin/organisations/${organisation.id}`,
|
||||
});
|
||||
|
||||
// Promote the manager to owner
|
||||
const managerRow = page.getByRole('row', { name: managerUser.email });
|
||||
const promoteButton = managerRow.getByRole('button', { name: 'Promote to owner' });
|
||||
|
||||
await promoteButton.click();
|
||||
await expect(page.getByText('Member promoted to owner successfully').first()).toBeVisible();
|
||||
|
||||
// Reload and verify the change
|
||||
await page.reload();
|
||||
await expect(managerRow.getByRole('status').filter({ hasText: 'Owner' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('[ADMIN]: promote admin member to owner', async ({ page }) => {
|
||||
// Create an admin user who can access the admin panel
|
||||
const { user: adminUser } = await seedUser({
|
||||
isAdmin: true,
|
||||
});
|
||||
|
||||
// Create an organisation with owner and admin member
|
||||
const { organisation } = await seedUser({
|
||||
isPersonalOrganisation: false,
|
||||
});
|
||||
|
||||
const adminMemberEmail = `admin-member-${nanoid()}@test.documenso.com`;
|
||||
|
||||
const [adminMemberUser] = await seedOrganisationMembers({
|
||||
members: [
|
||||
{
|
||||
email: adminMemberEmail,
|
||||
name: 'Test Admin Member',
|
||||
organisationRole: 'ADMIN',
|
||||
},
|
||||
],
|
||||
organisationId: organisation.id,
|
||||
});
|
||||
|
||||
// Sign in as admin
|
||||
await apiSignin({
|
||||
page,
|
||||
email: adminUser.email,
|
||||
redirectPath: `/admin/organisations/${organisation.id}`,
|
||||
});
|
||||
|
||||
// Promote the admin member to owner
|
||||
const adminMemberRow = page.getByRole('row', { name: adminMemberUser.email });
|
||||
const promoteButton = adminMemberRow.getByRole('button', { name: 'Promote to owner' });
|
||||
|
||||
await promoteButton.click();
|
||||
|
||||
await expect(page.getByText('Member promoted to owner successfully').first()).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
|
||||
// Reload and verify the change
|
||||
await page.reload();
|
||||
await expect(adminMemberRow.getByRole('status').filter({ hasText: 'Owner' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('[ADMIN]: cannot promote non-existent user', async ({ page }) => {
|
||||
// Create an admin user
|
||||
const { user: adminUser } = await seedUser({
|
||||
isAdmin: true,
|
||||
});
|
||||
|
||||
// Create an organisation
|
||||
const { organisation } = await seedUser({
|
||||
isPersonalOrganisation: false,
|
||||
});
|
||||
|
||||
// Sign in as admin
|
||||
await apiSignin({
|
||||
page,
|
||||
email: adminUser.email,
|
||||
redirectPath: `/admin/organisations/${organisation.id}`,
|
||||
});
|
||||
|
||||
// Try to manually call the API with invalid data - this should be handled by the UI validation
|
||||
// In a real scenario, the promote button wouldn't be available for non-existent users
|
||||
// But we can test that the API properly handles invalid requests
|
||||
|
||||
// For now, just verify that non-existent users don't show up in the members table
|
||||
await expect(page.getByRole('row', { name: 'Non Existent User' })).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('[ADMIN]: verify role hierarchy after promotion', async ({ page }) => {
|
||||
// Create an admin user
|
||||
const { user: adminUser } = await seedUser({
|
||||
isAdmin: true,
|
||||
});
|
||||
|
||||
// Create organisation with a member
|
||||
const { organisation } = await seedUser({
|
||||
isPersonalOrganisation: false,
|
||||
});
|
||||
|
||||
const memberEmail = `member-${nanoid()}@test.documenso.com`;
|
||||
|
||||
const [memberUser] = await seedOrganisationMembers({
|
||||
members: [
|
||||
{
|
||||
email: memberEmail,
|
||||
name: 'Test Member',
|
||||
organisationRole: 'MEMBER',
|
||||
},
|
||||
],
|
||||
organisationId: organisation.id,
|
||||
});
|
||||
|
||||
// Sign in as admin
|
||||
await apiSignin({
|
||||
page,
|
||||
email: adminUser.email,
|
||||
redirectPath: `/admin/organisations/${organisation.id}`,
|
||||
});
|
||||
|
||||
// Before promotion - verify member has MEMBER role
|
||||
let memberRow = page.getByRole('row', { name: memberUser.email });
|
||||
await expect(memberRow).toBeVisible();
|
||||
await expect(memberRow.getByRole('status').filter({ hasText: 'Owner' })).not.toBeVisible();
|
||||
|
||||
// Promote member to owner
|
||||
const promoteButton = memberRow.getByRole('button', { name: 'Promote to owner' });
|
||||
await promoteButton.click();
|
||||
await expect(page.getByText('Member promoted to owner successfully').first()).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
|
||||
// Reload page to see updated state
|
||||
await page.reload();
|
||||
|
||||
// After promotion - verify member is now owner and has admin permissions
|
||||
memberRow = page.getByRole('row', { name: memberUser.email });
|
||||
await expect(memberRow.getByRole('status').filter({ hasText: 'Owner' })).toBeVisible();
|
||||
|
||||
// Verify the promote button is now disabled for the new owner
|
||||
const newOwnerPromoteButton = memberRow.getByRole('button', { name: 'Promote to owner' });
|
||||
await expect(newOwnerPromoteButton).toBeDisabled();
|
||||
|
||||
// Sign in as the newly promoted user to verify they have owner permissions
|
||||
await apiSignin({
|
||||
page,
|
||||
email: memberUser.email,
|
||||
redirectPath: `/o/${organisation.url}/settings/general`,
|
||||
});
|
||||
|
||||
// Verify they can access organisation settings (owner permission)
|
||||
await expect(page.getByText('Organisation Settings')).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'Delete' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('[ADMIN]: error handling for invalid organisation', async ({ page }) => {
|
||||
// Create an admin user
|
||||
const { user: adminUser } = await seedUser({
|
||||
isAdmin: true,
|
||||
});
|
||||
|
||||
// Sign in as admin and try to access non-existent organisation
|
||||
await apiSignin({
|
||||
page,
|
||||
email: adminUser.email,
|
||||
redirectPath: `/admin/organisations/non-existent-org-id`,
|
||||
});
|
||||
|
||||
// Should show 404 error
|
||||
await expect(page.getByRole('heading', { name: 'Organisation not found' })).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
});
|
||||
|
||||
test('[ADMIN]: multiple promotions in sequence', async ({ page }) => {
|
||||
// Create an admin user
|
||||
const { user: adminUser } = await seedUser({
|
||||
isAdmin: true,
|
||||
});
|
||||
|
||||
// Create organisation with multiple members
|
||||
const { organisation } = await seedUser({
|
||||
isPersonalOrganisation: false,
|
||||
});
|
||||
|
||||
const member1Email = `member1-${nanoid()}@test.documenso.com`;
|
||||
const member2Email = `member2-${nanoid()}@test.documenso.com`;
|
||||
|
||||
const [member1User, member2User] = await seedOrganisationMembers({
|
||||
members: [
|
||||
{
|
||||
email: member1Email,
|
||||
name: 'Test Member 1',
|
||||
organisationRole: 'MEMBER',
|
||||
},
|
||||
{
|
||||
email: member2Email,
|
||||
name: 'Test Member 2',
|
||||
organisationRole: 'MANAGER',
|
||||
},
|
||||
],
|
||||
organisationId: organisation.id,
|
||||
});
|
||||
|
||||
// Sign in as admin
|
||||
await apiSignin({
|
||||
page,
|
||||
email: adminUser.email,
|
||||
redirectPath: `/admin/organisations/${organisation.id}`,
|
||||
});
|
||||
|
||||
// First promotion: Member 1 becomes owner
|
||||
let member1Row = page.getByRole('row', { name: member1User.email });
|
||||
let promoteButton1 = member1Row.getByRole('button', { name: 'Promote to owner' });
|
||||
await promoteButton1.click();
|
||||
await expect(page.getByText('Member promoted to owner successfully').first()).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
|
||||
await page.reload();
|
||||
|
||||
// Verify Member 1 is now owner and button is disabled
|
||||
member1Row = page.getByRole('row', { name: member1User.email });
|
||||
await expect(member1Row.getByRole('status').filter({ hasText: 'Owner' })).toBeVisible();
|
||||
promoteButton1 = member1Row.getByRole('button', { name: 'Promote to owner' });
|
||||
await expect(promoteButton1).toBeDisabled();
|
||||
|
||||
// Second promotion: Member 2 becomes the new owner
|
||||
const member2Row = page.getByRole('row', { name: member2User.email });
|
||||
const promoteButton2 = member2Row.getByRole('button', { name: 'Promote to owner' });
|
||||
await expect(promoteButton2).not.toBeDisabled();
|
||||
await promoteButton2.click();
|
||||
await expect(page.getByText('Member promoted to owner successfully').first()).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
|
||||
await page.reload();
|
||||
|
||||
// Verify Member 2 is now owner and Member 1 is no longer owner
|
||||
await expect(member2Row.getByRole('status').filter({ hasText: 'Owner' })).toBeVisible();
|
||||
await expect(member1Row.getByRole('status').filter({ hasText: 'Owner' })).not.toBeVisible();
|
||||
|
||||
// Verify Member 1's promote button is now enabled again
|
||||
const newPromoteButton1 = member1Row.getByRole('button', { name: 'Promote to owner' });
|
||||
await expect(newPromoteButton1).not.toBeDisabled();
|
||||
});
|
||||
|
||||
test('[ADMIN]: verify organisation access after ownership change', async ({ page }) => {
|
||||
// Create admin user
|
||||
const { user: adminUser } = await seedUser({
|
||||
isAdmin: true,
|
||||
});
|
||||
|
||||
// Create organisation with owner and member
|
||||
const { user: originalOwner, organisation } = await seedUser({
|
||||
isPersonalOrganisation: false,
|
||||
});
|
||||
|
||||
const memberEmail = `member-${nanoid()}@test.documenso.com`;
|
||||
|
||||
const [memberUser] = await seedOrganisationMembers({
|
||||
members: [
|
||||
{
|
||||
email: memberEmail,
|
||||
name: 'Test Member',
|
||||
organisationRole: 'MEMBER',
|
||||
},
|
||||
],
|
||||
organisationId: organisation.id,
|
||||
});
|
||||
|
||||
// Sign in as admin and promote member to owner
|
||||
await apiSignin({
|
||||
page,
|
||||
email: adminUser.email,
|
||||
redirectPath: `/admin/organisations/${organisation.id}`,
|
||||
});
|
||||
|
||||
const memberRow = page.getByRole('row', { name: memberUser.email });
|
||||
const promoteButton = memberRow.getByRole('button', { name: 'Promote to owner' });
|
||||
await promoteButton.click();
|
||||
await expect(page.getByText('Member promoted to owner successfully').first()).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
|
||||
// Test that the new owner can access organisation settings
|
||||
await apiSignin({
|
||||
page,
|
||||
email: memberUser.email,
|
||||
redirectPath: `/o/${organisation.url}/settings/general`,
|
||||
});
|
||||
|
||||
// Should be able to access organisation settings
|
||||
await expect(page.getByText('Organisation Settings')).toBeVisible();
|
||||
await expect(page.getByLabel('Organisation Name*')).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'Update organisation' })).toBeVisible();
|
||||
|
||||
// Should have delete permissions
|
||||
await expect(page.getByRole('button', { name: 'Delete' })).toBeVisible();
|
||||
|
||||
// Test that the original owner no longer has owner-level access
|
||||
await apiSignin({
|
||||
page,
|
||||
email: originalOwner.email,
|
||||
redirectPath: `/o/${organisation.url}/settings/general`,
|
||||
});
|
||||
|
||||
// Should still be able to access settings (as they should now be an admin)
|
||||
await expect(page.getByText('Organisation Settings')).toBeVisible();
|
||||
});
|
||||
@ -33,7 +33,7 @@ const setupDocumentAndNavigateToFieldsStep = async (page: Page) => {
|
||||
};
|
||||
|
||||
const triggerAutosave = async (page: Page) => {
|
||||
await page.locator('#document-flow-form-container').click();
|
||||
await page.locator('body').click({ position: { x: 0, y: 0 } });
|
||||
await page.locator('#document-flow-form-container').blur();
|
||||
|
||||
await page.waitForTimeout(5000);
|
||||
@ -70,7 +70,7 @@ test.describe('AutoSave Fields Step', () => {
|
||||
|
||||
await triggerAutosave(page);
|
||||
|
||||
await page.getByRole('combobox').click();
|
||||
await page.getByRole('combobox').first().click();
|
||||
await page.getByRole('option', { name: 'Recipient 2 (recipient2@documenso.com)' }).click();
|
||||
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
@ -127,7 +127,7 @@ test.describe('AutoSave Fields Step', () => {
|
||||
|
||||
await triggerAutosave(page);
|
||||
|
||||
await page.getByRole('combobox').click();
|
||||
await page.getByRole('combobox').first().click();
|
||||
await page.getByRole('option', { name: 'Recipient 2 (recipient2@documenso.com)' }).click();
|
||||
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
@ -140,7 +140,7 @@ test.describe('AutoSave Fields Step', () => {
|
||||
|
||||
await triggerAutosave(page);
|
||||
|
||||
await page.getByRole('combobox').click();
|
||||
await page.getByRole('combobox').first().click();
|
||||
await page.getByRole('option', { name: 'Recipient 1 (recipient1@documenso.com)' }).click();
|
||||
|
||||
await page.getByText('Text').nth(1).click();
|
||||
@ -191,7 +191,7 @@ test.describe('AutoSave Fields Step', () => {
|
||||
|
||||
await triggerAutosave(page);
|
||||
|
||||
await page.getByRole('combobox').click();
|
||||
await page.getByRole('combobox').first().click();
|
||||
await page.getByRole('option', { name: 'Recipient 2 (recipient2@documenso.com)' }).click();
|
||||
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
@ -204,7 +204,7 @@ test.describe('AutoSave Fields Step', () => {
|
||||
|
||||
await triggerAutosave(page);
|
||||
|
||||
await page.getByRole('combobox').click();
|
||||
await page.getByRole('combobox').first().click();
|
||||
await page.getByRole('option', { name: 'Recipient 1 (recipient1@documenso.com)' }).click();
|
||||
|
||||
await page.getByText('Signature').nth(1).click();
|
||||
|
||||
@ -24,7 +24,7 @@ const setupDocument = async (page: Page) => {
|
||||
};
|
||||
|
||||
const triggerAutosave = async (page: Page) => {
|
||||
await page.locator('#document-flow-form-container').click();
|
||||
await page.locator('body').click({ position: { x: 0, y: 0 } });
|
||||
await page.locator('#document-flow-form-container').blur();
|
||||
|
||||
await page.waitForTimeout(5000);
|
||||
|
||||
@ -26,7 +26,7 @@ const setupDocumentAndNavigateToSignersStep = async (page: Page) => {
|
||||
};
|
||||
|
||||
const triggerAutosave = async (page: Page) => {
|
||||
await page.locator('#document-flow-form-container').click();
|
||||
await page.locator('body').click({ position: { x: 0, y: 0 } });
|
||||
await page.locator('#document-flow-form-container').blur();
|
||||
|
||||
await page.waitForTimeout(5000);
|
||||
@ -92,7 +92,7 @@ test.describe('AutoSave Signers Step', () => {
|
||||
|
||||
await triggerAutosave(page);
|
||||
|
||||
await page.getByRole('combobox').click();
|
||||
await page.getByRole('combobox').first().click();
|
||||
await page.getByRole('option', { name: 'Receives copy' }).click();
|
||||
|
||||
await triggerAutosave(page);
|
||||
@ -160,9 +160,20 @@ test.describe('AutoSave Signers Step', () => {
|
||||
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);
|
||||
|
||||
const firstRecipient = retrievedRecipients.find(
|
||||
(r) => r.email === 'recipient1@documenso.com',
|
||||
);
|
||||
const secondRecipient = retrievedRecipients.find(
|
||||
(r) => r.email === 'recipient2@documenso.com',
|
||||
);
|
||||
const thirdRecipient = retrievedRecipients.find(
|
||||
(r) => r.email === 'recipient3@documenso.com',
|
||||
);
|
||||
|
||||
expect(firstRecipient?.signingOrder).toBe(2);
|
||||
expect(secondRecipient?.signingOrder).toBe(3);
|
||||
expect(thirdRecipient?.signingOrder).toBe(1);
|
||||
}).toPass();
|
||||
});
|
||||
});
|
||||
|
||||
@ -42,7 +42,7 @@ export const setupDocumentAndNavigateToSubjectStep = async (page: Page) => {
|
||||
};
|
||||
|
||||
export const triggerAutosave = async (page: Page) => {
|
||||
await page.locator('#document-flow-form-container').click();
|
||||
await page.locator('body').click({ position: { x: 0, y: 0 } });
|
||||
await page.locator('#document-flow-form-container').blur();
|
||||
|
||||
await page.waitForTimeout(5000);
|
||||
|
||||
@ -0,0 +1,56 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { seedBlankDocument } from '@documenso/prisma/seed/documents';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
|
||||
import { apiSignin } from '../fixtures/authentication';
|
||||
|
||||
test('[DOCUMENT_FLOW]: Simple duplicate recipients test', async ({ page }) => {
|
||||
const { user, team } = await seedUser();
|
||||
const document = await seedBlankDocument(user, team.id);
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
redirectPath: `/t/${team.url}/documents/${document.id}/edit`,
|
||||
});
|
||||
|
||||
// Step 1: Settings - Continue with defaults
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Add Signers' })).toBeVisible();
|
||||
|
||||
// Step 2: Add duplicate recipients
|
||||
await page.getByPlaceholder('Email').fill('duplicate@example.com');
|
||||
await page.getByPlaceholder('Name').fill('Duplicate 1');
|
||||
|
||||
await page.getByRole('button', { name: 'Add Signer' }).click();
|
||||
await page.getByLabel('Email').nth(1).fill('duplicate@example.com');
|
||||
await page.getByLabel('Name').nth(1).fill('Duplicate 2');
|
||||
|
||||
// Continue to fields
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
|
||||
|
||||
// Step 3: Add fields
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator('canvas').click({ position: { x: 100, y: 100 } });
|
||||
|
||||
await page.getByRole('combobox').first().click();
|
||||
|
||||
// Switch to second duplicate and add field
|
||||
await page.getByText('Duplicate 2 (duplicate@example.com)').first().click();
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator('canvas').click({ position: { x: 200, y: 100 } });
|
||||
|
||||
// Continue to send
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Distribute Document' })).toBeVisible();
|
||||
|
||||
// Send document
|
||||
await page.waitForTimeout(2500);
|
||||
await page.getByRole('button', { name: 'Send' }).click();
|
||||
|
||||
await page.waitForURL(new RegExp(`/t/${team.url}/documents/\\d+`));
|
||||
|
||||
await expect(page.getByRole('link', { name: document.title })).toBeVisible();
|
||||
});
|
||||
@ -0,0 +1,355 @@
|
||||
import { type Page, expect, test } from '@playwright/test';
|
||||
import type { Document, Team } from '@prisma/client';
|
||||
|
||||
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { seedBlankDocument } from '@documenso/prisma/seed/documents';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
|
||||
import { apiSignin } from '../fixtures/authentication';
|
||||
import { signSignaturePad } from '../fixtures/signature';
|
||||
|
||||
/**
|
||||
* Test helper to complete the document creation flow with duplicate recipients
|
||||
*/
|
||||
const completeDocumentFlowWithDuplicateRecipients = async (options: {
|
||||
page: Page;
|
||||
team: Team;
|
||||
document: Document;
|
||||
}) => {
|
||||
const { page, team, document } = options;
|
||||
|
||||
// Step 1: Settings - Continue with defaults
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Add Signers' })).toBeVisible();
|
||||
|
||||
// Step 2: Add duplicate recipients
|
||||
await page.getByPlaceholder('Email').fill('duplicate@example.com');
|
||||
await page.getByPlaceholder('Name').fill('Duplicate Recipient 1');
|
||||
|
||||
// Add second signer with same email
|
||||
await page.getByRole('button', { name: 'Add Signer' }).click();
|
||||
await page.getByLabel('Email').nth(1).fill('duplicate@example.com');
|
||||
await page.getByLabel('Name').nth(1).fill('Duplicate Recipient 2');
|
||||
|
||||
// Add third signer with different email for comparison
|
||||
await page.getByRole('button', { name: 'Add Signer' }).click();
|
||||
await page.getByLabel('Email').nth(2).fill('unique@example.com');
|
||||
await page.getByLabel('Name').nth(2).fill('Unique Recipient');
|
||||
|
||||
// Continue to fields
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
|
||||
|
||||
// Step 3: Add fields for each recipient
|
||||
// Add signature field for first duplicate recipient
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator('canvas').click({ position: { x: 100, y: 100 } });
|
||||
|
||||
await page.getByText('Duplicate Recipient 1 (duplicate@example.com)').click();
|
||||
|
||||
// Switch to second duplicate recipient and add their field
|
||||
await page.getByText('Duplicate Recipient 2 (duplicate@example.com)').click();
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator('canvas').click({ position: { x: 200, y: 100 } });
|
||||
|
||||
await page.getByText('Duplicate Recipient 2 (duplicate@example.com)').click();
|
||||
|
||||
// Switch to unique recipient and add their field
|
||||
await page.getByText('Unique Recipient (unique@example.com)').click();
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator('canvas').click({ position: { x: 300, y: 100 } });
|
||||
|
||||
// Continue to subject
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Distribute Document' })).toBeVisible();
|
||||
|
||||
// Step 4: Complete with subject and send
|
||||
await page.waitForTimeout(2500);
|
||||
await page.getByRole('button', { name: 'Send' }).click();
|
||||
|
||||
// Wait for send confirmation
|
||||
await page.waitForURL(new RegExp(`/t/${team.url}/documents/\\d+`));
|
||||
|
||||
await expect(page.getByRole('link', { name: document.title })).toBeVisible();
|
||||
};
|
||||
|
||||
test.describe('[DOCUMENT_FLOW]: Duplicate Recipients', () => {
|
||||
test('should allow creating document with duplicate recipient emails', async ({ page }) => {
|
||||
const { user, team } = await seedUser();
|
||||
|
||||
const document = await seedBlankDocument(user, team.id);
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
redirectPath: `/t/${team.url}/documents/${document.id}/edit`,
|
||||
});
|
||||
|
||||
// Complete the flow
|
||||
await completeDocumentFlowWithDuplicateRecipients({
|
||||
page,
|
||||
team,
|
||||
document,
|
||||
});
|
||||
|
||||
// Verify document was created successfully
|
||||
await expect(page).toHaveURL(new RegExp(`/t/${team.url}/documents`));
|
||||
});
|
||||
|
||||
test('should allow adding duplicate recipient after saving document initially', async ({
|
||||
page,
|
||||
}) => {
|
||||
const { user, team } = await seedUser();
|
||||
const document = await seedBlankDocument(user, team.id);
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
redirectPath: `/t/${team.url}/documents/${document.id}/edit`,
|
||||
});
|
||||
|
||||
// Step 1: Settings - Continue with defaults
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Add Signers' })).toBeVisible();
|
||||
|
||||
// Step 2: Add initial recipient
|
||||
await page.getByPlaceholder('Email').fill('test@example.com');
|
||||
await page.getByPlaceholder('Name').fill('Test Recipient');
|
||||
|
||||
// Continue to fields and add a field
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator('canvas').click({ position: { x: 100, y: 100 } });
|
||||
|
||||
// Save the document by going to subject
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Distribute Document' })).toBeVisible();
|
||||
|
||||
// Navigate back to signers to add duplicate
|
||||
await page.getByRole('button', { name: 'Go Back' }).click();
|
||||
await page.getByRole('button', { name: 'Go Back' }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Add Signers' })).toBeVisible();
|
||||
|
||||
// Add duplicate recipient
|
||||
await page.getByRole('button', { name: 'Add Signer' }).click();
|
||||
await page.getByLabel('Email').nth(1).fill('test@example.com');
|
||||
await page.getByLabel('Name').nth(1).fill('Test Recipient Duplicate');
|
||||
|
||||
// Continue and add field for duplicate
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
|
||||
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// Switch to duplicate recipient and add field
|
||||
await page.getByRole('combobox').first().click();
|
||||
await page.getByText('Test Recipient Duplicate (test@example.com)').first().click();
|
||||
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator('canvas').click({ position: { x: 200, y: 100 } });
|
||||
|
||||
// Complete the flow
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Distribute Document' })).toBeVisible();
|
||||
await page.waitForTimeout(2500);
|
||||
await page.getByRole('button', { name: 'Send' }).click();
|
||||
|
||||
await page.waitForURL(new RegExp(`/t/${team.url}/documents/\\d+`));
|
||||
|
||||
await expect(page.getByRole('link', { name: document.title })).toBeVisible();
|
||||
});
|
||||
|
||||
test('should isolate fields per recipient token even with duplicate emails', async ({
|
||||
page,
|
||||
context,
|
||||
}) => {
|
||||
const { user, team } = await seedUser();
|
||||
|
||||
const document = await seedBlankDocument(user, team.id);
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
redirectPath: `/t/${team.url}/documents/${document.id}/edit`,
|
||||
});
|
||||
|
||||
// Complete the document flow
|
||||
await completeDocumentFlowWithDuplicateRecipients({
|
||||
page,
|
||||
team,
|
||||
document,
|
||||
});
|
||||
|
||||
// Navigate to documents list and get the document
|
||||
await expect(page).toHaveURL(new RegExp(`/t/${team.url}/documents`));
|
||||
|
||||
const recipients = await prisma.recipient.findMany({
|
||||
where: {
|
||||
documentId: document.id,
|
||||
},
|
||||
});
|
||||
|
||||
expect(recipients).toHaveLength(3);
|
||||
|
||||
const tokens = recipients.map((r) => r.token);
|
||||
|
||||
expect(new Set(tokens).size).toBe(3); // All tokens should be unique
|
||||
|
||||
// Test each signing experience in separate browser contexts
|
||||
for (const recipient of recipients) {
|
||||
// Navigate to signing URL
|
||||
await page.goto(`/sign/${recipient.token}`, {
|
||||
waitUntil: 'networkidle',
|
||||
});
|
||||
|
||||
await page.waitForSelector(PDF_VIEWER_PAGE_SELECTOR);
|
||||
|
||||
// Verify only one signature field is visible for this recipient
|
||||
expect(
|
||||
await page.locator(`[data-field-type="SIGNATURE"]:not([data-readonly="true"])`).all(),
|
||||
).toHaveLength(1);
|
||||
|
||||
// Verify recipient name is correct
|
||||
await expect(page.getByLabel('Full Name')).toHaveValue(recipient.name);
|
||||
|
||||
// Sign the document
|
||||
await signSignaturePad(page);
|
||||
|
||||
await page
|
||||
.locator('[data-field-type="SIGNATURE"]:not([data-readonly="true"])')
|
||||
.first()
|
||||
.click();
|
||||
|
||||
await page.getByRole('button', { name: 'Complete' }).click();
|
||||
await page.getByRole('button', { name: 'Sign' }).click();
|
||||
|
||||
// Verify completion
|
||||
await page.waitForURL(`/sign/${recipient?.token}/complete`);
|
||||
await expect(page.getByText('Document Signed')).toBeVisible();
|
||||
}
|
||||
});
|
||||
|
||||
test('should handle duplicate recipient workflow with different field types', async ({
|
||||
page,
|
||||
}) => {
|
||||
const { user, team } = await seedUser();
|
||||
const document = await seedBlankDocument(user, team.id);
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
redirectPath: `/t/${team.url}/documents/${document.id}/edit`,
|
||||
});
|
||||
|
||||
// Step 1: Settings
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
|
||||
// Step 2: Add duplicate recipients with different roles
|
||||
await page.getByPlaceholder('Email').fill('signer@example.com');
|
||||
await page.getByPlaceholder('Name').fill('Signer Role');
|
||||
|
||||
await page.getByRole('button', { name: 'Add Signer' }).click();
|
||||
await page.getByLabel('Email').nth(1).fill('signer@example.com');
|
||||
await page.getByLabel('Name').nth(1).fill('Approver Role');
|
||||
|
||||
// Change second recipient role if role selector is available
|
||||
const roleDropdown = page.getByLabel('Role').nth(1);
|
||||
|
||||
if (await roleDropdown.isVisible()) {
|
||||
await roleDropdown.click();
|
||||
await page.getByText('Approver').click();
|
||||
}
|
||||
|
||||
// Step 3: Add different field types for each duplicate
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
|
||||
// Add signature for first recipient
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator('canvas').click({ position: { x: 100, y: 100 } });
|
||||
|
||||
// Add name field for second recipient
|
||||
await page.getByRole('combobox').first().click();
|
||||
|
||||
await page.getByText('Approver Role (signer@example.com)').first().click();
|
||||
await page.getByRole('button', { name: 'Name' }).click();
|
||||
await page.locator('canvas').click({ position: { x: 200, y: 100 } });
|
||||
|
||||
// Add date field for second recipient
|
||||
await page.getByRole('button', { name: 'Date' }).click();
|
||||
await page.locator('canvas').click({ position: { x: 200, y: 150 } });
|
||||
|
||||
// Complete the document
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
|
||||
await page.getByRole('button', { name: 'Send' }).click();
|
||||
|
||||
await page.waitForURL(new RegExp(`/t/${team.url}/documents/\\d+`));
|
||||
|
||||
await expect(page.getByRole('link', { name: document.title })).toBeVisible();
|
||||
});
|
||||
|
||||
test('should preserve field assignments when editing document with duplicates', async ({
|
||||
page,
|
||||
}) => {
|
||||
const { user, team } = await seedUser();
|
||||
const document = await seedBlankDocument(user, team.id);
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
redirectPath: `/t/${team.url}/documents/${document.id}/edit`,
|
||||
});
|
||||
|
||||
// Create document with duplicates and fields
|
||||
await completeDocumentFlowWithDuplicateRecipients({
|
||||
page,
|
||||
team,
|
||||
document,
|
||||
});
|
||||
|
||||
// Navigate back to edit the document
|
||||
await page.goto(`/t/${team.url}/documents/${document.id}/edit`);
|
||||
|
||||
// Go to fields step
|
||||
await page.getByRole('button', { name: 'Continue' }).click(); // Settings
|
||||
await page.getByRole('button', { name: 'Continue' }).click(); // Signers
|
||||
|
||||
// Verify fields are assigned to correct recipients
|
||||
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
|
||||
|
||||
// Click on first duplicate recipient
|
||||
await page.getByText('Duplicate Recipient 1 (duplicate@example.com)').click();
|
||||
|
||||
// Verify their field is visible and can be selected
|
||||
const firstRecipientFields = await page
|
||||
.locator(`[data-field-type="SIGNATURE"]:not(:disabled)`)
|
||||
.all();
|
||||
expect(firstRecipientFields.length).toBeGreaterThan(0);
|
||||
|
||||
// Switch to second duplicate recipient
|
||||
await page.getByText('Duplicate Recipient 2 (duplicate@example.com)').click();
|
||||
|
||||
// Verify they have their own field
|
||||
const secondRecipientFields = await page
|
||||
.locator(`[data-field-type="SIGNATURE"]:not(:disabled)`)
|
||||
.all();
|
||||
expect(secondRecipientFields.length).toBeGreaterThan(0);
|
||||
|
||||
// Add another field to the second duplicate
|
||||
await page.getByRole('button', { name: 'Name' }).click();
|
||||
await page.locator('canvas').click({ position: { x: 250, y: 150 } });
|
||||
|
||||
// Save changes
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Distribute Document' })).toBeVisible();
|
||||
await page.waitForTimeout(2500);
|
||||
await page.getByRole('button', { name: 'Send' }).click();
|
||||
|
||||
await page.waitForURL(new RegExp(`/t/${team.url}/documents/\\d+`));
|
||||
|
||||
await expect(page.getByRole('link', { name: document.title })).toBeVisible();
|
||||
});
|
||||
});
|
||||
@ -573,6 +573,7 @@ test('[DOCUMENT_FLOW]: should be able to create and sign a document with 3 recip
|
||||
y: 100 * i,
|
||||
},
|
||||
});
|
||||
|
||||
await page.getByText(`User ${i} (user${i}@example.com)`).click();
|
||||
}
|
||||
|
||||
|
||||
@ -277,13 +277,13 @@ test('[TEAMS]: document folder and its contents can be deleted', async ({ page }
|
||||
|
||||
await page.goto(`/t/${team.url}/documents`);
|
||||
|
||||
await expect(page.locator('div').filter({ hasText: folder.name })).not.toBeVisible();
|
||||
await expect(page.locator(`[data-folder-id="${folder.id}"]`)).not.toBeVisible();
|
||||
await expect(page.getByText(proposal.title)).not.toBeVisible();
|
||||
|
||||
await page.goto(`/t/${team.url}/documents/f/${folder.id}`);
|
||||
|
||||
await expect(page.getByText(report.title)).not.toBeVisible();
|
||||
await expect(page.locator('div').filter({ hasText: reportsFolder.name })).not.toBeVisible();
|
||||
await expect(page.locator(`[data-folder-id="${reportsFolder.id}"]`)).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('[TEAMS]: create folder button is visible on templates page', async ({ page }) => {
|
||||
@ -318,9 +318,7 @@ test('[TEAMS]: can create a template folder', async ({ page }) => {
|
||||
await expect(page.getByText('Team template folder')).toBeVisible();
|
||||
|
||||
await page.goto(`/t/${team.url}/templates`);
|
||||
await expect(
|
||||
page.locator('div').filter({ hasText: 'Team template folder' }).nth(3),
|
||||
).toBeVisible();
|
||||
await expect(page.locator(`[data-folder-name="Team template folder"]`)).toBeVisible();
|
||||
});
|
||||
|
||||
test('[TEAMS]: can create a template subfolder inside a template folder', async ({ page }) => {
|
||||
@ -374,11 +372,8 @@ test('[TEAMS]: can create a template inside a template folder', async ({ page })
|
||||
|
||||
await page.getByRole('button', { name: 'New Template' }).click();
|
||||
|
||||
await page
|
||||
.locator('div')
|
||||
.filter({ hasText: /^Upload Template DocumentDrag & drop your PDF here\.$/ })
|
||||
.nth(2)
|
||||
.click();
|
||||
await page.getByText('Upload Template Document').click();
|
||||
|
||||
await page.locator('input[type="file"]').nth(0).waitFor({ state: 'attached' });
|
||||
|
||||
await page
|
||||
@ -537,7 +532,7 @@ test('[TEAMS]: template folder can be moved to another template folder', async (
|
||||
await expect(page.getByText('Team Contract Templates')).toBeVisible();
|
||||
});
|
||||
|
||||
test('[TEAMS]: template folder and its contents can be deleted', async ({ page }) => {
|
||||
test('[TEAMS]: template folder can be deleted', async ({ page }) => {
|
||||
const { team, teamOwner } = await seedTeamDocuments();
|
||||
|
||||
const folder = await seedBlankFolder(teamOwner, team.id, {
|
||||
@ -585,13 +580,16 @@ test('[TEAMS]: template folder and its contents can be deleted', async ({ page }
|
||||
|
||||
await page.goto(`/t/${team.url}/templates`);
|
||||
|
||||
await expect(page.locator('div').filter({ hasText: folder.name })).not.toBeVisible();
|
||||
await expect(page.getByText(template.title)).not.toBeVisible();
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// !: This is no longer the case, when deleting a folder its contents will be moved to the root folder.
|
||||
// await expect(page.locator(`[data-folder-id="${folder.id}"]`)).not.toBeVisible();
|
||||
// await expect(page.getByText(template.title)).not.toBeVisible();
|
||||
|
||||
await page.goto(`/t/${team.url}/templates/f/${folder.id}`);
|
||||
|
||||
await expect(page.getByText(reportTemplate.title)).not.toBeVisible();
|
||||
await expect(page.locator('div').filter({ hasText: subfolder.name })).not.toBeVisible();
|
||||
await expect(page.locator(`[data-folder-id="${subfolder.id}"]`)).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('[TEAMS]: can navigate between template folders', async ({ page }) => {
|
||||
@ -843,10 +841,15 @@ test('[TEAMS]: documents inherit folder visibility', async ({ page }) => {
|
||||
|
||||
await page.getByText('Admin Only Folder').click();
|
||||
|
||||
const fileInput = page.locator('input[type="file"]').nth(1);
|
||||
await fileInput.waitFor({ state: 'attached' });
|
||||
await page.waitForURL(new RegExp(`/t/${team.url}/documents/f/.+`));
|
||||
|
||||
await fileInput.setInputFiles(
|
||||
// Upload document.
|
||||
const [fileChooser] = await Promise.all([
|
||||
page.waitForEvent('filechooser'),
|
||||
page.getByRole('button', { name: 'Upload Document' }).click(),
|
||||
]);
|
||||
|
||||
await fileChooser.setFiles(
|
||||
path.join(__dirname, '../../../assets/documenso-supporter-pledge.pdf'),
|
||||
);
|
||||
|
||||
|
||||
@ -30,8 +30,8 @@ test('[ORGANISATIONS]: manage document preferences', async ({ page }) => {
|
||||
await page.getByRole('option', { name: 'Australia/Perth' }).click();
|
||||
|
||||
// Set default date
|
||||
await page.getByRole('combobox').filter({ hasText: 'yyyy-MM-dd hh:mm a' }).click();
|
||||
await page.getByRole('option', { name: 'DD/MM/YYYY' }).click();
|
||||
await page.getByRole('combobox').filter({ hasText: 'yyyy-MM-dd hh:mm AM/PM' }).click();
|
||||
await page.getByRole('option', { name: 'DD/MM/YYYY', exact: true }).click();
|
||||
|
||||
await page.getByTestId('signature-types-trigger').click();
|
||||
await page.getByRole('option', { name: 'Draw' }).click();
|
||||
@ -51,7 +51,7 @@ test('[ORGANISATIONS]: manage document preferences', async ({ page }) => {
|
||||
expect(teamSettings.documentVisibility).toEqual(DocumentVisibility.MANAGER_AND_ABOVE);
|
||||
expect(teamSettings.documentLanguage).toEqual('de');
|
||||
expect(teamSettings.documentTimezone).toEqual('Australia/Perth');
|
||||
expect(teamSettings.documentDateFormat).toEqual('dd/MM/yyyy hh:mm a');
|
||||
expect(teamSettings.documentDateFormat).toEqual('dd/MM/yyyy');
|
||||
expect(teamSettings.includeSenderDetails).toEqual(false);
|
||||
expect(teamSettings.includeSigningCertificate).toEqual(false);
|
||||
expect(teamSettings.typedSignatureEnabled).toEqual(true);
|
||||
@ -72,7 +72,7 @@ test('[ORGANISATIONS]: manage document preferences', async ({ page }) => {
|
||||
|
||||
// Override team date format settings
|
||||
await page.getByTestId('document-date-format-trigger').click();
|
||||
await page.getByRole('option', { name: 'MM/DD/YYYY' }).click();
|
||||
await page.getByRole('option', { name: 'MM/DD/YYYY', exact: true }).click();
|
||||
|
||||
await page.getByRole('button', { name: 'Update' }).first().click();
|
||||
await expect(page.getByText('Your document preferences have been updated').first()).toBeVisible();
|
||||
@ -85,7 +85,7 @@ test('[ORGANISATIONS]: manage document preferences', async ({ page }) => {
|
||||
expect(updatedTeamSettings.documentVisibility).toEqual(DocumentVisibility.EVERYONE);
|
||||
expect(updatedTeamSettings.documentLanguage).toEqual('pl');
|
||||
expect(updatedTeamSettings.documentTimezone).toEqual('Europe/London');
|
||||
expect(updatedTeamSettings.documentDateFormat).toEqual('MM/dd/yyyy hh:mm a');
|
||||
expect(updatedTeamSettings.documentDateFormat).toEqual('MM/dd/yyyy');
|
||||
expect(updatedTeamSettings.includeSenderDetails).toEqual(false);
|
||||
expect(updatedTeamSettings.includeSigningCertificate).toEqual(false);
|
||||
expect(updatedTeamSettings.typedSignatureEnabled).toEqual(true);
|
||||
@ -108,7 +108,7 @@ test('[ORGANISATIONS]: manage document preferences', async ({ page }) => {
|
||||
expect(documentMeta.drawSignatureEnabled).toEqual(false);
|
||||
expect(documentMeta.language).toEqual('pl');
|
||||
expect(documentMeta.timezone).toEqual('Europe/London');
|
||||
expect(documentMeta.dateFormat).toEqual('MM/dd/yyyy hh:mm a');
|
||||
expect(documentMeta.dateFormat).toEqual('MM/dd/yyyy');
|
||||
});
|
||||
|
||||
test('[ORGANISATIONS]: manage branding preferences', async ({ page }) => {
|
||||
|
||||
@ -0,0 +1,283 @@
|
||||
import { type Page, expect, test } from '@playwright/test';
|
||||
import type { Team, Template } from '@prisma/client';
|
||||
|
||||
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { seedBlankTemplate } from '@documenso/prisma/seed/templates';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
|
||||
import { apiSignin } from '../fixtures/authentication';
|
||||
|
||||
/**
|
||||
* Test helper to complete template creation with duplicate recipients
|
||||
*/
|
||||
const completeTemplateFlowWithDuplicateRecipients = async (options: {
|
||||
page: Page;
|
||||
team: Team;
|
||||
template: Template;
|
||||
}) => {
|
||||
const { page, team, template } = options;
|
||||
// Step 1: Settings - Continue with defaults
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Add Placeholders' })).toBeVisible();
|
||||
|
||||
// Step 2: Add duplicate recipients with real emails for testing
|
||||
await page.getByPlaceholder('Email').fill('duplicate@example.com');
|
||||
await page.getByPlaceholder('Name').fill('First Instance');
|
||||
|
||||
// Add second signer with same email
|
||||
await page.getByRole('button', { name: 'Add Placeholder Recipient' }).click();
|
||||
await page.getByPlaceholder('Email').nth(1).fill('duplicate@example.com');
|
||||
await page.getByPlaceholder('Name').nth(1).fill('Second Instance');
|
||||
|
||||
// Add third signer with different email
|
||||
await page.getByRole('button', { name: 'Add Placeholder Recipient' }).click();
|
||||
await page.getByPlaceholder('Email').nth(2).fill('unique@example.com');
|
||||
await page.getByPlaceholder('Name').nth(2).fill('Different Recipient');
|
||||
|
||||
// Continue to fields
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
|
||||
|
||||
// Step 3: Add fields for each recipient instance
|
||||
// Add signature field for first instance
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator('canvas').click({ position: { x: 100, y: 100 } });
|
||||
|
||||
// Switch to second instance and add their field
|
||||
await page.getByRole('combobox').first().click();
|
||||
await page.getByText('Second Instance').first().click();
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator('canvas').click({ position: { x: 200, y: 100 } });
|
||||
|
||||
// Switch to different recipient and add their field
|
||||
await page.getByRole('combobox').first().click();
|
||||
await page.getByText('Different Recipient').first().click();
|
||||
await page.getByRole('button', { name: 'Name' }).click();
|
||||
await page.locator('canvas').click({ position: { x: 300, y: 100 } });
|
||||
|
||||
// Save template
|
||||
await page.getByRole('button', { name: 'Save Template' }).click();
|
||||
|
||||
// Wait for creation confirmation
|
||||
await page.waitForURL(`/t/${team.url}/templates`);
|
||||
await expect(page.getByRole('link', { name: template.title })).toBeVisible();
|
||||
};
|
||||
|
||||
test.describe('[TEMPLATE_FLOW]: Duplicate Recipients', () => {
|
||||
test('should allow creating template with duplicate recipient emails', async ({ page }) => {
|
||||
const { user, team } = await seedUser();
|
||||
|
||||
const template = await seedBlankTemplate(user, team.id);
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
redirectPath: `/t/${team.url}/templates/${template.id}/edit`,
|
||||
});
|
||||
|
||||
// Complete the template flow
|
||||
await completeTemplateFlowWithDuplicateRecipients({ page, team, template });
|
||||
|
||||
// Verify template was created successfully
|
||||
await expect(page).toHaveURL(`/t/${team.url}/templates`);
|
||||
});
|
||||
|
||||
test('should create document from template with duplicate recipients using same email', async ({
|
||||
page,
|
||||
context,
|
||||
}) => {
|
||||
const { user, team } = await seedUser();
|
||||
|
||||
const template = await seedBlankTemplate(user, team.id);
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
redirectPath: `/t/${team.url}/templates/${template.id}/edit`,
|
||||
});
|
||||
|
||||
// Complete template creation
|
||||
await completeTemplateFlowWithDuplicateRecipients({ page, team, template });
|
||||
|
||||
// Navigate to template and create document
|
||||
await page.goto(`/t/${team.url}/templates`);
|
||||
|
||||
await page
|
||||
.getByRole('row', { name: template.title })
|
||||
.getByRole('button', { name: 'Use Template' })
|
||||
.click();
|
||||
|
||||
// Fill recipient information with same email for both instances
|
||||
await expect(page.getByRole('heading', { name: 'Create document' })).toBeVisible();
|
||||
|
||||
// Set same email for both recipient instances
|
||||
const emailInputs = await page.locator('[aria-label="Email"]').all();
|
||||
const nameInputs = await page.locator('[aria-label="Name"]').all();
|
||||
|
||||
// First instance
|
||||
await emailInputs[0].fill('same@example.com');
|
||||
await nameInputs[0].fill('John Doe - Role 1');
|
||||
|
||||
// Second instance (same email)
|
||||
await emailInputs[1].fill('same@example.com');
|
||||
await nameInputs[1].fill('John Doe - Role 2');
|
||||
|
||||
// Different recipient
|
||||
await emailInputs[2].fill('different@example.com');
|
||||
await nameInputs[2].fill('Jane Smith');
|
||||
|
||||
await page.getByLabel('Send document').click();
|
||||
|
||||
// Create document
|
||||
await page.getByRole('button', { name: 'Create and send' }).click();
|
||||
await page.waitForURL(new RegExp(`/t/${team.url}/documents/\\d+`));
|
||||
|
||||
// Get the document ID from URL for database queries
|
||||
const url = page.url();
|
||||
const documentIdMatch = url.match(/\/documents\/(\d+)/);
|
||||
|
||||
const documentId = documentIdMatch ? parseInt(documentIdMatch[1]) : null;
|
||||
|
||||
expect(documentId).not.toBeNull();
|
||||
|
||||
// Get recipients directly from database
|
||||
const recipients = await prisma.recipient.findMany({
|
||||
where: {
|
||||
documentId: documentId!,
|
||||
},
|
||||
});
|
||||
|
||||
expect(recipients).toHaveLength(3);
|
||||
|
||||
// Verify all tokens are unique
|
||||
const tokens = recipients.map((r) => r.token);
|
||||
expect(new Set(tokens).size).toBe(3);
|
||||
|
||||
// Test signing experience for duplicate email recipients
|
||||
const duplicateRecipients = recipients.filter((r) => r.email === 'same@example.com');
|
||||
expect(duplicateRecipients).toHaveLength(2);
|
||||
|
||||
for (const recipient of duplicateRecipients) {
|
||||
// Navigate to signing URL
|
||||
await page.goto(`/sign/${recipient.token}`, {
|
||||
waitUntil: 'networkidle',
|
||||
});
|
||||
|
||||
await page.waitForSelector(PDF_VIEWER_PAGE_SELECTOR);
|
||||
|
||||
// Verify correct recipient name is shown
|
||||
await expect(page.getByLabel('Full Name')).toHaveValue(recipient.name);
|
||||
|
||||
// Verify only one signature field is visible for this recipient
|
||||
expect(
|
||||
await page.locator(`[data-field-type="SIGNATURE"]:not([data-readonly="true"])`).all(),
|
||||
).toHaveLength(1);
|
||||
}
|
||||
});
|
||||
|
||||
test('should handle template with different types of duplicate emails', async ({ page }) => {
|
||||
const { user, team } = await seedUser();
|
||||
|
||||
const template = await seedBlankTemplate(user, team.id);
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
redirectPath: `/t/${team.url}/templates/${template.id}/edit`,
|
||||
});
|
||||
|
||||
// Step 1: Settings
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
|
||||
// Step 2: Add multiple recipients with duplicate emails
|
||||
await page.getByPlaceholder('Email').fill('duplicate@example.com');
|
||||
await page.getByPlaceholder('Name').fill('Duplicate Recipient 1');
|
||||
|
||||
await page.getByRole('button', { name: 'Add Placeholder Recipient' }).click();
|
||||
await page.getByPlaceholder('Email').nth(1).fill('duplicate@example.com');
|
||||
await page.getByPlaceholder('Name').nth(1).fill('Duplicate Recipient 2');
|
||||
|
||||
await page.getByRole('button', { name: 'Add Placeholder Recipient' }).click();
|
||||
await page.getByPlaceholder('Email').nth(2).fill('different@example.com');
|
||||
await page.getByPlaceholder('Name').nth(2).fill('Different Recipient');
|
||||
|
||||
// Continue and add fields
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
|
||||
// Add fields for each recipient
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator('canvas').click({ position: { x: 100, y: 100 } });
|
||||
|
||||
await page.getByRole('combobox').first().click();
|
||||
await page.getByText('Duplicate Recipient 2').first().click();
|
||||
await page.getByRole('button', { name: 'Date' }).click();
|
||||
await page.locator('canvas').click({ position: { x: 200, y: 100 } });
|
||||
|
||||
await page.getByRole('combobox').first().click();
|
||||
await page.getByText('Different Recipient').first().click();
|
||||
await page.getByRole('button', { name: 'Name' }).click();
|
||||
await page.locator('canvas').click({ position: { x: 100, y: 200 } });
|
||||
|
||||
// Save template
|
||||
await page.getByRole('button', { name: 'Save Template' }).click();
|
||||
|
||||
await page.waitForURL(`/t/${team.url}/templates`);
|
||||
|
||||
await expect(page.getByRole('link', { name: template.title })).toBeVisible();
|
||||
});
|
||||
|
||||
test('should validate field assignments per recipient in template editing', async ({ page }) => {
|
||||
const { user, team } = await seedUser();
|
||||
|
||||
const template = await seedBlankTemplate(user, team.id);
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
redirectPath: `/t/${team.url}/templates/${template.id}/edit`,
|
||||
});
|
||||
|
||||
// Create template with duplicates
|
||||
await completeTemplateFlowWithDuplicateRecipients({ page, team, template });
|
||||
|
||||
// Navigate back to edit the template
|
||||
await page.goto(`/t/${team.url}/templates/${template.id}/edit`);
|
||||
|
||||
// Go to fields step
|
||||
await page.getByRole('button', { name: 'Continue' }).click(); // Settings
|
||||
await page.getByRole('button', { name: 'Continue' }).click(); // Signers
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
|
||||
|
||||
// Verify fields are correctly assigned to each recipient instance
|
||||
await page.getByRole('combobox').first().click();
|
||||
await page.getByRole('option', { name: 'First Instance' }).first().click();
|
||||
let visibleFields = await page.locator(`[data-field-type="SIGNATURE"]:not(:disabled)`).all();
|
||||
expect(visibleFields.length).toBeGreaterThan(0);
|
||||
|
||||
await page.getByRole('combobox').first().click();
|
||||
await page.getByRole('option', { name: 'Second Instance' }).first().click();
|
||||
visibleFields = await page.locator(`[data-field-type="SIGNATURE"]:not(:disabled)`).all();
|
||||
expect(visibleFields.length).toBeGreaterThan(0);
|
||||
|
||||
await page.getByRole('combobox').first().click();
|
||||
await page.getByRole('option', { name: 'Different Recipient' }).first().click();
|
||||
const nameFields = await page.locator(`[data-field-type="NAME"]:not(:disabled)`).all();
|
||||
expect(nameFields.length).toBeGreaterThan(0);
|
||||
|
||||
// Add additional field to verify proper assignment
|
||||
await page.getByRole('combobox').first().click();
|
||||
await page.getByRole('option', { name: 'First Instance' }).first().click();
|
||||
await page.getByRole('button', { name: 'Name' }).click();
|
||||
await page.locator('canvas').click({ position: { x: 100, y: 300 } });
|
||||
|
||||
await page.waitForTimeout(2500);
|
||||
|
||||
// Save changes
|
||||
await page.getByRole('button', { name: 'Save Template' }).click();
|
||||
|
||||
await page.waitForURL(`/t/${team.url}/templates`);
|
||||
await expect(page.getByRole('link', { name: template.title })).toBeVisible();
|
||||
});
|
||||
});
|
||||
@ -33,7 +33,7 @@ const setupTemplateAndNavigateToFieldsStep = async (page: Page) => {
|
||||
};
|
||||
|
||||
const triggerAutosave = async (page: Page) => {
|
||||
await page.locator('#document-flow-form-container').click();
|
||||
await page.locator('body').click({ position: { x: 0, y: 0 } });
|
||||
await page.locator('#document-flow-form-container').blur();
|
||||
|
||||
await page.waitForTimeout(5000);
|
||||
@ -70,7 +70,7 @@ test.describe('AutoSave Fields Step', () => {
|
||||
|
||||
await triggerAutosave(page);
|
||||
|
||||
await page.getByRole('combobox').click();
|
||||
await page.getByRole('combobox').first().click();
|
||||
await page.getByRole('option', { name: 'Recipient 2 (recipient2@documenso.com)' }).click();
|
||||
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
@ -129,7 +129,7 @@ test.describe('AutoSave Fields Step', () => {
|
||||
|
||||
await triggerAutosave(page);
|
||||
|
||||
await page.getByRole('combobox').click();
|
||||
await page.getByRole('combobox').first().click();
|
||||
await page.getByRole('option', { name: 'Recipient 2 (recipient2@documenso.com)' }).click();
|
||||
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
@ -142,7 +142,7 @@ test.describe('AutoSave Fields Step', () => {
|
||||
|
||||
await triggerAutosave(page);
|
||||
|
||||
await page.getByRole('combobox').click();
|
||||
await page.getByRole('combobox').first().click();
|
||||
await page.getByRole('option', { name: 'Recipient 1 (recipient1@documenso.com)' }).click();
|
||||
|
||||
await page.getByText('Text').nth(1).click();
|
||||
@ -195,7 +195,7 @@ test.describe('AutoSave Fields Step', () => {
|
||||
|
||||
await triggerAutosave(page);
|
||||
|
||||
await page.getByRole('combobox').click();
|
||||
await page.getByRole('combobox').first().click();
|
||||
await page.getByRole('option', { name: 'Recipient 2 (recipient2@documenso.com)' }).click();
|
||||
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
@ -208,7 +208,7 @@ test.describe('AutoSave Fields Step', () => {
|
||||
|
||||
await triggerAutosave(page);
|
||||
|
||||
await page.getByRole('combobox').click();
|
||||
await page.getByRole('combobox').first().click();
|
||||
await page.getByRole('option', { name: 'Recipient 1 (recipient1@documenso.com)' }).click();
|
||||
|
||||
await page.getByText('Signature').nth(1).click();
|
||||
|
||||
@ -23,7 +23,7 @@ const setupTemplate = async (page: Page) => {
|
||||
};
|
||||
|
||||
const triggerAutosave = async (page: Page) => {
|
||||
await page.locator('#document-flow-form-container').click();
|
||||
await page.locator('body').click({ position: { x: 0, y: 0 } });
|
||||
await page.locator('#document-flow-form-container').blur();
|
||||
|
||||
await page.waitForTimeout(5000);
|
||||
|
||||
@ -26,7 +26,7 @@ const setupTemplateAndNavigateToSignersStep = async (page: Page) => {
|
||||
};
|
||||
|
||||
const triggerAutosave = async (page: Page) => {
|
||||
await page.locator('#document-flow-form-container').click();
|
||||
await page.locator('body').click({ position: { x: 0, y: 0 } });
|
||||
await page.locator('#document-flow-form-container').blur();
|
||||
|
||||
await page.waitForTimeout(5000);
|
||||
|
||||
@ -47,8 +47,8 @@ test('[TEMPLATE]: should create a document from a template', async ({ page }) =>
|
||||
|
||||
// Set advanced options.
|
||||
await page.getByRole('button', { name: 'Advanced Options' }).click();
|
||||
await page.locator('button').filter({ hasText: 'YYYY-MM-DD HH:mm a' }).click();
|
||||
await page.getByLabel('DD/MM/YYYY').click();
|
||||
await page.locator('button').filter({ hasText: 'YYYY-MM-DD hh:mm AM/PM' }).click();
|
||||
await page.getByLabel('DD/MM/YYYY HH:mm', { exact: true }).click();
|
||||
|
||||
await page.locator('.time-zone-field').click();
|
||||
await page.getByRole('option', { name: 'Etc/UTC' }).click();
|
||||
@ -96,7 +96,7 @@ test('[TEMPLATE]: should create a document from a template', async ({ page }) =>
|
||||
expect(document.title).toEqual('TEMPLATE_TITLE');
|
||||
expect(documentAuth.documentAuthOption.globalAccessAuth).toContain('ACCOUNT');
|
||||
|
||||
expect(document.documentMeta?.dateFormat).toEqual('dd/MM/yyyy hh:mm a');
|
||||
expect(document.documentMeta?.dateFormat).toEqual('dd/MM/yyyy HH:mm');
|
||||
expect(document.documentMeta?.message).toEqual('MESSAGE');
|
||||
expect(document.documentMeta?.redirectUrl).toEqual('https://documenso.com');
|
||||
expect(document.documentMeta?.subject).toEqual('SUBJECT');
|
||||
@ -150,8 +150,8 @@ test('[TEMPLATE]: should create a team document from a team template', async ({
|
||||
|
||||
// Set advanced options.
|
||||
await page.getByRole('button', { name: 'Advanced Options' }).click();
|
||||
await page.locator('button').filter({ hasText: 'YYYY-MM-DD HH:mm a' }).click();
|
||||
await page.getByLabel('DD/MM/YYYY').click();
|
||||
await page.locator('button').filter({ hasText: 'YYYY-MM-DD hh:mm AM/PM' }).click();
|
||||
await page.getByLabel('DD/MM/YYYY HH:mm', { exact: true }).click();
|
||||
|
||||
await page.locator('.time-zone-field').click();
|
||||
await page.getByRole('option', { name: 'Etc/UTC' }).click();
|
||||
@ -200,7 +200,7 @@ test('[TEMPLATE]: should create a team document from a team template', async ({
|
||||
|
||||
expect(document.title).toEqual('TEMPLATE_TITLE');
|
||||
expect(documentAuth.documentAuthOption.globalAccessAuth).toContain('ACCOUNT');
|
||||
expect(document.documentMeta?.dateFormat).toEqual('dd/MM/yyyy hh:mm a');
|
||||
expect(document.documentMeta?.dateFormat).toEqual('dd/MM/yyyy HH:mm');
|
||||
expect(document.documentMeta?.message).toEqual('MESSAGE');
|
||||
expect(document.documentMeta?.redirectUrl).toEqual('https://documenso.com');
|
||||
expect(document.documentMeta?.subject).toEqual('SUBJECT');
|
||||
|
||||
@ -17,7 +17,7 @@ export default defineConfig({
|
||||
testDir: './e2e',
|
||||
/* Run tests in files in parallel */
|
||||
fullyParallel: false,
|
||||
workers: 4,
|
||||
workers: 2,
|
||||
maxFailures: process.env.CI ? 1 : undefined,
|
||||
/* Fail the build on CI if you accidentally left test.only in the source code. */
|
||||
forbidOnly: !!process.env.CI,
|
||||
@ -33,7 +33,7 @@ export default defineConfig({
|
||||
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
|
||||
trace: 'on',
|
||||
|
||||
video: 'retain-on-failure',
|
||||
video: 'on-first-retry',
|
||||
|
||||
/* Add explicit timeouts for actions */
|
||||
actionTimeout: 15_000,
|
||||
@ -48,7 +48,7 @@ export default defineConfig({
|
||||
name: 'chromium',
|
||||
use: {
|
||||
...devices['Desktop Chrome'],
|
||||
viewport: { width: 1920, height: 1080 },
|
||||
viewport: { width: 1920, height: 1200 },
|
||||
},
|
||||
},
|
||||
|
||||
|
||||
@ -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,60 @@
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
|
||||
import { Heading, Img, Section, Text } from '../components';
|
||||
|
||||
export type TemplateAccessAuth2FAProps = {
|
||||
documentTitle: string;
|
||||
code: string;
|
||||
userEmail: string;
|
||||
userName: string;
|
||||
expiresInMinutes: number;
|
||||
assetBaseUrl?: string;
|
||||
};
|
||||
|
||||
export const TemplateAccessAuth2FA = ({
|
||||
documentTitle,
|
||||
code,
|
||||
userName,
|
||||
expiresInMinutes,
|
||||
assetBaseUrl = 'http://localhost:3002',
|
||||
}: TemplateAccessAuth2FAProps) => {
|
||||
const getAssetUrl = (path: string) => {
|
||||
return new URL(path, assetBaseUrl).toString();
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Img src={getAssetUrl('/static/document.png')} alt="Document" className="mx-auto h-12 w-12" />
|
||||
|
||||
<Section className="mt-8">
|
||||
<Heading className="text-center text-lg font-semibold text-slate-900">
|
||||
<Trans>Verification Code Required</Trans>
|
||||
</Heading>
|
||||
|
||||
<Text className="mt-2 text-center text-slate-700">
|
||||
<Trans>
|
||||
Hi {userName}, you need to enter a verification code to complete the document "
|
||||
{documentTitle}".
|
||||
</Trans>
|
||||
</Text>
|
||||
|
||||
<Section className="mt-6 rounded-lg bg-slate-50 p-6 text-center">
|
||||
<Text className="mb-2 text-sm font-medium text-slate-600">
|
||||
<Trans>Your verification code:</Trans>
|
||||
</Text>
|
||||
<Text className="text-2xl font-bold tracking-wider text-slate-900">{code}</Text>
|
||||
</Section>
|
||||
|
||||
<Text className="mt-4 text-center text-sm text-slate-600">
|
||||
<Trans>This code will expire in {expiresInMinutes} minutes.</Trans>
|
||||
</Text>
|
||||
|
||||
<Text className="mt-4 text-center text-sm text-slate-500">
|
||||
<Trans>
|
||||
If you didn't request this verification code, you can safely ignore this email.
|
||||
</Trans>
|
||||
</Text>
|
||||
</Section>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -1,5 +1,3 @@
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { OrganisationType, RecipientRole } from '@prisma/client';
|
||||
@ -38,12 +36,6 @@ export const TemplateDocumentInvite = ({
|
||||
|
||||
const { actionVerb } = RECIPIENT_ROLES_DESCRIPTION[role];
|
||||
|
||||
const rejectDocumentLink = useMemo(() => {
|
||||
const url = new URL(signDocumentLink);
|
||||
url.searchParams.set('reject', 'true');
|
||||
return url.toString();
|
||||
}, [signDocumentLink]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<TemplateDocumentImage className="mt-6" assetBaseUrl={assetBaseUrl} />
|
||||
@ -99,22 +91,15 @@ export const TemplateDocumentInvite = ({
|
||||
|
||||
<Section className="mb-6 mt-8 text-center">
|
||||
<Button
|
||||
className="mr-4 inline-flex items-center justify-center rounded-lg bg-red-500 px-6 py-3 text-center text-sm font-medium text-black no-underline"
|
||||
href={rejectDocumentLink}
|
||||
>
|
||||
<Trans>Reject Document</Trans>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
className="bg-documenso-500 inline-flex items-center justify-center rounded-lg px-6 py-3 text-center text-sm font-medium text-black no-underline"
|
||||
className="bg-documenso-500 text-sbase inline-flex items-center justify-center rounded-lg px-6 py-3 text-center font-medium text-black no-underline"
|
||||
href={signDocumentLink}
|
||||
>
|
||||
{match(role)
|
||||
.with(RecipientRole.SIGNER, () => <Trans>Sign Document</Trans>)
|
||||
.with(RecipientRole.SIGNER, () => <Trans>View Document to sign</Trans>)
|
||||
.with(RecipientRole.VIEWER, () => <Trans>View Document</Trans>)
|
||||
.with(RecipientRole.APPROVER, () => <Trans>Approve Document</Trans>)
|
||||
.with(RecipientRole.APPROVER, () => <Trans>View Document to approve</Trans>)
|
||||
.with(RecipientRole.CC, () => '')
|
||||
.with(RecipientRole.ASSISTANT, () => <Trans>Assist Document</Trans>)
|
||||
.with(RecipientRole.ASSISTANT, () => <Trans>View Document to assist</Trans>)
|
||||
.exhaustive()}
|
||||
</Button>
|
||||
</Section>
|
||||
|
||||
77
packages/email/templates/access-auth-2fa.tsx
Normal file
77
packages/email/templates/access-auth-2fa.tsx
Normal file
@ -0,0 +1,77 @@
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
|
||||
import { Body, Container, Head, Html, Img, Preview, Section } from '../components';
|
||||
import { useBranding } from '../providers/branding';
|
||||
import { TemplateAccessAuth2FA } from '../template-components/template-access-auth-2fa';
|
||||
import { TemplateFooter } from '../template-components/template-footer';
|
||||
|
||||
export type AccessAuth2FAEmailTemplateProps = {
|
||||
documentTitle: string;
|
||||
code: string;
|
||||
userEmail: string;
|
||||
userName: string;
|
||||
expiresInMinutes: number;
|
||||
assetBaseUrl?: string;
|
||||
};
|
||||
|
||||
export const AccessAuth2FAEmailTemplate = ({
|
||||
documentTitle,
|
||||
code,
|
||||
userEmail,
|
||||
userName,
|
||||
expiresInMinutes,
|
||||
assetBaseUrl = 'http://localhost:3002',
|
||||
}: AccessAuth2FAEmailTemplateProps) => {
|
||||
const { _ } = useLingui();
|
||||
|
||||
const branding = useBranding();
|
||||
|
||||
const previewText = msg`Your verification code is ${code}`;
|
||||
|
||||
const getAssetUrl = (path: string) => {
|
||||
return new URL(path, assetBaseUrl).toString();
|
||||
};
|
||||
|
||||
return (
|
||||
<Html>
|
||||
<Head />
|
||||
<Preview>{_(previewText)}</Preview>
|
||||
|
||||
<Body className="mx-auto my-auto bg-white font-sans">
|
||||
<Section>
|
||||
<Container className="mx-auto mb-2 mt-8 max-w-xl rounded-lg border border-solid border-slate-200 p-4 backdrop-blur-sm">
|
||||
<Section>
|
||||
{branding.brandingEnabled && branding.brandingLogo ? (
|
||||
<Img src={branding.brandingLogo} alt="Branding Logo" className="mb-4 h-6" />
|
||||
) : (
|
||||
<Img
|
||||
src={getAssetUrl('/static/logo.png')}
|
||||
alt="Documenso Logo"
|
||||
className="mb-4 h-6"
|
||||
/>
|
||||
)}
|
||||
|
||||
<TemplateAccessAuth2FA
|
||||
documentTitle={documentTitle}
|
||||
code={code}
|
||||
userEmail={userEmail}
|
||||
userName={userName}
|
||||
expiresInMinutes={expiresInMinutes}
|
||||
assetBaseUrl={assetBaseUrl}
|
||||
/>
|
||||
</Section>
|
||||
</Container>
|
||||
|
||||
<div className="mx-auto mt-12 max-w-xl" />
|
||||
|
||||
<Container className="mx-auto max-w-xl">
|
||||
<TemplateFooter isDocument={false} />
|
||||
</Container>
|
||||
</Section>
|
||||
</Body>
|
||||
</Html>
|
||||
);
|
||||
};
|
||||
|
||||
export default AccessAuth2FAEmailTemplate;
|
||||
@ -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;
|
||||
@ -1,23 +1,56 @@
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
|
||||
export const useAutoSave = <T>(onSave: (data: T) => Promise<void>) => {
|
||||
const saveTimeoutRef = useRef<NodeJS.Timeout>();
|
||||
type SaveRequest<T, R> = {
|
||||
data: T;
|
||||
onResponse?: (response: R) => void;
|
||||
};
|
||||
|
||||
const saveFormData = async (data: T) => {
|
||||
try {
|
||||
await onSave(data);
|
||||
} catch (error) {
|
||||
console.error('Auto-save failed:', error);
|
||||
export const useAutoSave = <T, R = void>(
|
||||
onSave: (data: T) => Promise<R>,
|
||||
options: { delay?: number } = {},
|
||||
) => {
|
||||
const { delay = 2000 } = options;
|
||||
|
||||
const saveTimeoutRef = useRef<NodeJS.Timeout>();
|
||||
const saveQueueRef = useRef<SaveRequest<T, R>[]>([]);
|
||||
const isProcessingRef = useRef(false);
|
||||
|
||||
const processQueue = async () => {
|
||||
if (isProcessingRef.current || saveQueueRef.current.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
isProcessingRef.current = true;
|
||||
|
||||
while (saveQueueRef.current.length > 0) {
|
||||
const request = saveQueueRef.current.shift()!;
|
||||
|
||||
try {
|
||||
const response = await onSave(request.data);
|
||||
request.onResponse?.(response);
|
||||
} catch (error) {
|
||||
console.error('Auto-save failed:', error);
|
||||
}
|
||||
}
|
||||
|
||||
isProcessingRef.current = false;
|
||||
};
|
||||
|
||||
const scheduleSave = useCallback((data: T) => {
|
||||
if (saveTimeoutRef.current) {
|
||||
clearTimeout(saveTimeoutRef.current);
|
||||
}
|
||||
const saveFormData = async (data: T, onResponse?: (response: R) => void) => {
|
||||
saveQueueRef.current.push({ data, onResponse });
|
||||
await processQueue();
|
||||
};
|
||||
|
||||
saveTimeoutRef.current = setTimeout(() => void saveFormData(data), 2000);
|
||||
}, []);
|
||||
const scheduleSave = useCallback(
|
||||
(data: T, onResponse?: (response: R) => void) => {
|
||||
if (saveTimeoutRef.current) {
|
||||
clearTimeout(saveTimeoutRef.current);
|
||||
}
|
||||
|
||||
saveTimeoutRef.current = setTimeout(() => void saveFormData(data, onResponse), delay);
|
||||
},
|
||||
[delay],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -7,14 +7,25 @@ export const DEFAULT_DOCUMENT_DATE_FORMAT = 'yyyy-MM-dd hh:mm a';
|
||||
export const VALID_DATE_FORMAT_VALUES = [
|
||||
DEFAULT_DOCUMENT_DATE_FORMAT,
|
||||
'yyyy-MM-dd',
|
||||
'dd/MM/yyyy',
|
||||
'MM/dd/yyyy',
|
||||
'yy-MM-dd',
|
||||
'MMMM dd, yyyy',
|
||||
'EEEE, MMMM dd, yyyy',
|
||||
'dd/MM/yyyy hh:mm a',
|
||||
'dd/MM/yyyy HH:mm',
|
||||
'MM/dd/yyyy hh:mm a',
|
||||
'MM/dd/yyyy HH:mm',
|
||||
'dd.MM.yyyy',
|
||||
'dd.MM.yyyy HH:mm',
|
||||
'yyyy-MM-dd HH:mm',
|
||||
'yy-MM-dd hh:mm a',
|
||||
'yy-MM-dd HH:mm',
|
||||
'yyyy-MM-dd HH:mm:ss',
|
||||
'MMMM dd, yyyy hh:mm a',
|
||||
'MMMM dd, yyyy HH:mm',
|
||||
'EEEE, MMMM dd, yyyy hh:mm a',
|
||||
'EEEE, MMMM dd, yyyy HH:mm',
|
||||
"yyyy-MM-dd'T'HH:mm:ss.SSSXXX",
|
||||
] as const;
|
||||
|
||||
@ -22,10 +33,80 @@ export type ValidDateFormat = (typeof VALID_DATE_FORMAT_VALUES)[number];
|
||||
|
||||
export const DATE_FORMATS = [
|
||||
{
|
||||
key: 'yyyy-MM-dd_hh:mm_a',
|
||||
label: 'YYYY-MM-DD HH:mm a',
|
||||
key: 'yyyy-MM-dd_HH:mm_12H',
|
||||
label: 'YYYY-MM-DD hh:mm AM/PM',
|
||||
value: DEFAULT_DOCUMENT_DATE_FORMAT,
|
||||
},
|
||||
{
|
||||
key: 'yyyy-MM-dd_HH:mm',
|
||||
label: 'YYYY-MM-DD HH:mm',
|
||||
value: 'yyyy-MM-dd HH:mm',
|
||||
},
|
||||
{
|
||||
key: 'DDMMYYYY_TIME',
|
||||
label: 'DD/MM/YYYY HH:mm',
|
||||
value: 'dd/MM/yyyy HH:mm',
|
||||
},
|
||||
{
|
||||
key: 'DDMMYYYY_TIME_12H',
|
||||
label: 'DD/MM/YYYY HH:mm AM/PM',
|
||||
value: 'dd/MM/yyyy hh:mm a',
|
||||
},
|
||||
{
|
||||
key: 'MMDDYYYY_TIME',
|
||||
label: 'MM/DD/YYYY HH:mm',
|
||||
value: 'MM/dd/yyyy HH:mm',
|
||||
},
|
||||
{
|
||||
key: 'MMDDYYYY_TIME_12H',
|
||||
label: 'MM/DD/YYYY HH:mm AM/PM',
|
||||
value: 'MM/dd/yyyy hh:mm a',
|
||||
},
|
||||
{
|
||||
key: 'DDMMYYYYHHMM',
|
||||
label: 'DD.MM.YYYY HH:mm',
|
||||
value: 'dd.MM.yyyy HH:mm',
|
||||
},
|
||||
{
|
||||
key: 'YYMMDD_TIME',
|
||||
label: 'YY-MM-DD HH:mm',
|
||||
value: 'yy-MM-dd HH:mm',
|
||||
},
|
||||
{
|
||||
key: 'YYMMDD_TIME_12H',
|
||||
label: 'YY-MM-DD HH:mm AM/PM',
|
||||
value: 'yy-MM-dd hh:mm a',
|
||||
},
|
||||
{
|
||||
key: 'YYYY_MM_DD_HH_MM_SS',
|
||||
label: 'YYYY-MM-DD HH:mm:ss',
|
||||
value: 'yyyy-MM-dd HH:mm:ss',
|
||||
},
|
||||
{
|
||||
key: 'MonthDateYear_TIME',
|
||||
label: 'Month Date, Year HH:mm',
|
||||
value: 'MMMM dd, yyyy HH:mm',
|
||||
},
|
||||
{
|
||||
key: 'MonthDateYear_TIME_12H',
|
||||
label: 'Month Date, Year HH:mm AM/PM',
|
||||
value: 'MMMM dd, yyyy hh:mm a',
|
||||
},
|
||||
{
|
||||
key: 'DayMonthYear_TIME',
|
||||
label: 'Day, Month Year HH:mm',
|
||||
value: 'EEEE, MMMM dd, yyyy HH:mm',
|
||||
},
|
||||
{
|
||||
key: 'DayMonthYear_TIME_12H',
|
||||
label: 'Day, Month Year HH:mm AM/PM',
|
||||
value: 'EEEE, MMMM dd, yyyy hh:mm a',
|
||||
},
|
||||
{
|
||||
key: 'ISO8601',
|
||||
label: 'ISO 8601',
|
||||
value: "yyyy-MM-dd'T'HH:mm:ss.SSSXXX",
|
||||
},
|
||||
{
|
||||
key: 'YYYYMMDD',
|
||||
label: 'YYYY-MM-DD',
|
||||
@ -34,47 +115,32 @@ export const DATE_FORMATS = [
|
||||
{
|
||||
key: 'DDMMYYYY',
|
||||
label: 'DD/MM/YYYY',
|
||||
value: 'dd/MM/yyyy hh:mm a',
|
||||
value: 'dd/MM/yyyy',
|
||||
},
|
||||
{
|
||||
key: 'MMDDYYYY',
|
||||
label: 'MM/DD/YYYY',
|
||||
value: 'MM/dd/yyyy hh:mm a',
|
||||
value: 'MM/dd/yyyy',
|
||||
},
|
||||
{
|
||||
key: 'DDMMYYYYHHMM',
|
||||
label: 'DD.MM.YYYY HH:mm',
|
||||
value: 'dd.MM.yyyy HH:mm',
|
||||
},
|
||||
{
|
||||
key: 'YYYYMMDDHHmm',
|
||||
label: 'YYYY-MM-DD HH:mm',
|
||||
value: 'yyyy-MM-dd HH:mm',
|
||||
key: 'DDMMYYYY_DOT',
|
||||
label: 'DD.MM.YYYY',
|
||||
value: 'dd.MM.yyyy',
|
||||
},
|
||||
{
|
||||
key: 'YYMMDD',
|
||||
label: 'YY-MM-DD',
|
||||
value: 'yy-MM-dd hh:mm a',
|
||||
},
|
||||
{
|
||||
key: 'YYYYMMDDhhmmss',
|
||||
label: 'YYYY-MM-DD HH:mm:ss',
|
||||
value: 'yyyy-MM-dd HH:mm:ss',
|
||||
value: 'yy-MM-dd',
|
||||
},
|
||||
{
|
||||
key: 'MonthDateYear',
|
||||
label: 'Month Date, Year',
|
||||
value: 'MMMM dd, yyyy hh:mm a',
|
||||
value: 'MMMM dd, yyyy',
|
||||
},
|
||||
{
|
||||
key: 'DayMonthYear',
|
||||
label: 'Day, Month Year',
|
||||
value: 'EEEE, MMMM dd, yyyy hh:mm a',
|
||||
},
|
||||
{
|
||||
key: 'ISO8601',
|
||||
label: 'ISO 8601',
|
||||
value: "yyyy-MM-dd'T'HH:mm:ss.SSSXXX",
|
||||
value: 'EEEE, MMMM dd, yyyy',
|
||||
},
|
||||
] satisfies {
|
||||
key: string;
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -17,6 +17,7 @@ export enum AppErrorCode {
|
||||
'RETRY_EXCEPTION' = 'RETRY_EXCEPTION',
|
||||
'SCHEMA_FAILED' = 'SCHEMA_FAILED',
|
||||
'TOO_MANY_REQUESTS' = 'TOO_MANY_REQUESTS',
|
||||
'TWO_FACTOR_AUTH_FAILED' = 'TWO_FACTOR_AUTH_FAILED',
|
||||
}
|
||||
|
||||
export const genericErrorCodeToTrpcErrorCodeMap: Record<string, { code: string; status: number }> =
|
||||
@ -32,6 +33,7 @@ export const genericErrorCodeToTrpcErrorCodeMap: Record<string, { code: string;
|
||||
[AppErrorCode.RETRY_EXCEPTION]: { code: 'INTERNAL_SERVER_ERROR', status: 500 },
|
||||
[AppErrorCode.SCHEMA_FAILED]: { code: 'INTERNAL_SERVER_ERROR', status: 500 },
|
||||
[AppErrorCode.TOO_MANY_REQUESTS]: { code: 'TOO_MANY_REQUESTS', status: 429 },
|
||||
[AppErrorCode.TWO_FACTOR_AUTH_FAILED]: { code: 'UNAUTHORIZED', status: 401 },
|
||||
};
|
||||
|
||||
export const ZAppErrorJsonSchema = z.object({
|
||||
|
||||
1
packages/lib/server-only/2fa/email/constants.ts
Normal file
1
packages/lib/server-only/2fa/email/constants.ts
Normal file
@ -0,0 +1 @@
|
||||
export const TWO_FACTOR_EMAIL_EXPIRATION_MINUTES = 5;
|
||||
@ -0,0 +1,38 @@
|
||||
import { hmac } from '@noble/hashes/hmac';
|
||||
import { sha256 } from '@noble/hashes/sha256';
|
||||
import { createTOTPKeyURI } from 'oslo/otp';
|
||||
|
||||
import { DOCUMENSO_ENCRYPTION_KEY } from '../../../constants/crypto';
|
||||
|
||||
const ISSUER = 'Documenso Email 2FA';
|
||||
|
||||
export type GenerateTwoFactorCredentialsFromEmailOptions = {
|
||||
documentId: number;
|
||||
email: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Generate an encrypted token containing a 6-digit 2FA code for email verification.
|
||||
*
|
||||
* @param options - The options for generating the token
|
||||
* @returns Object containing the token and the 6-digit code
|
||||
*/
|
||||
export const generateTwoFactorCredentialsFromEmail = ({
|
||||
documentId,
|
||||
email,
|
||||
}: GenerateTwoFactorCredentialsFromEmailOptions) => {
|
||||
if (!DOCUMENSO_ENCRYPTION_KEY) {
|
||||
throw new Error('Missing DOCUMENSO_ENCRYPTION_KEY');
|
||||
}
|
||||
|
||||
const identity = `email-2fa|v1|email:${email}|id:${documentId}`;
|
||||
|
||||
const secret = hmac(sha256, DOCUMENSO_ENCRYPTION_KEY, identity);
|
||||
|
||||
const uri = createTOTPKeyURI(ISSUER, email, secret);
|
||||
|
||||
return {
|
||||
uri,
|
||||
secret,
|
||||
};
|
||||
};
|
||||
@ -0,0 +1,23 @@
|
||||
import { generateHOTP } from 'oslo/otp';
|
||||
|
||||
import { generateTwoFactorCredentialsFromEmail } from './generate-2fa-credentials-from-email';
|
||||
|
||||
export type GenerateTwoFactorTokenFromEmailOptions = {
|
||||
documentId: number;
|
||||
email: string;
|
||||
period?: number;
|
||||
};
|
||||
|
||||
export const generateTwoFactorTokenFromEmail = async ({
|
||||
email,
|
||||
documentId,
|
||||
period = 30_000,
|
||||
}: GenerateTwoFactorTokenFromEmailOptions) => {
|
||||
const { secret } = generateTwoFactorCredentialsFromEmail({ email, documentId });
|
||||
|
||||
const counter = Math.floor(Date.now() / period);
|
||||
|
||||
const token = await generateHOTP(secret, counter);
|
||||
|
||||
return token;
|
||||
};
|
||||
124
packages/lib/server-only/2fa/email/send-2fa-token-email.ts
Normal file
124
packages/lib/server-only/2fa/email/send-2fa-token-email.ts
Normal file
@ -0,0 +1,124 @@
|
||||
import { createElement } from 'react';
|
||||
|
||||
import { msg } from '@lingui/core/macro';
|
||||
|
||||
import { mailer } from '@documenso/email/mailer';
|
||||
import { AccessAuth2FAEmailTemplate } from '@documenso/email/templates/access-auth-2fa';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { getI18nInstance } from '../../../client-only/providers/i18n-server';
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '../../../constants/app';
|
||||
import { AppError, AppErrorCode } from '../../../errors/app-error';
|
||||
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../../types/document-audit-logs';
|
||||
import { createDocumentAuditLogData } from '../../../utils/document-audit-logs';
|
||||
import { renderEmailWithI18N } from '../../../utils/render-email-with-i18n';
|
||||
import { getEmailContext } from '../../email/get-email-context';
|
||||
import { TWO_FACTOR_EMAIL_EXPIRATION_MINUTES } from './constants';
|
||||
import { generateTwoFactorTokenFromEmail } from './generate-2fa-token-from-email';
|
||||
|
||||
export type Send2FATokenEmailOptions = {
|
||||
token: string;
|
||||
documentId: number;
|
||||
};
|
||||
|
||||
export const send2FATokenEmail = async ({ token, documentId }: Send2FATokenEmailOptions) => {
|
||||
const document = await prisma.document.findFirst({
|
||||
where: {
|
||||
id: documentId,
|
||||
recipients: {
|
||||
some: {
|
||||
token,
|
||||
},
|
||||
},
|
||||
},
|
||||
include: {
|
||||
recipients: {
|
||||
where: {
|
||||
token,
|
||||
},
|
||||
},
|
||||
documentMeta: true,
|
||||
team: {
|
||||
select: {
|
||||
teamEmail: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!document) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'Document not found',
|
||||
});
|
||||
}
|
||||
|
||||
const [recipient] = document.recipients;
|
||||
|
||||
if (!recipient) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'Recipient not found',
|
||||
});
|
||||
}
|
||||
|
||||
const twoFactorTokenToken = await generateTwoFactorTokenFromEmail({
|
||||
documentId,
|
||||
email: recipient.email,
|
||||
});
|
||||
|
||||
const { branding, emailLanguage, senderEmail, replyToEmail } = await getEmailContext({
|
||||
emailType: 'RECIPIENT',
|
||||
source: {
|
||||
type: 'team',
|
||||
teamId: document.teamId,
|
||||
},
|
||||
meta: document.documentMeta,
|
||||
});
|
||||
|
||||
const i18n = await getI18nInstance(emailLanguage);
|
||||
|
||||
const subject = i18n._(msg`Your two-factor authentication code`);
|
||||
|
||||
const template = createElement(AccessAuth2FAEmailTemplate, {
|
||||
documentTitle: document.title,
|
||||
userName: recipient.name,
|
||||
userEmail: recipient.email,
|
||||
code: twoFactorTokenToken,
|
||||
expiresInMinutes: TWO_FACTOR_EMAIL_EXPIRATION_MINUTES,
|
||||
assetBaseUrl: NEXT_PUBLIC_WEBAPP_URL(),
|
||||
});
|
||||
|
||||
const [html, text] = await Promise.all([
|
||||
renderEmailWithI18N(template, { lang: emailLanguage, branding }),
|
||||
renderEmailWithI18N(template, { lang: emailLanguage, branding, plainText: true }),
|
||||
]);
|
||||
|
||||
await prisma.$transaction(
|
||||
async (tx) => {
|
||||
await mailer.sendMail({
|
||||
to: {
|
||||
address: recipient.email,
|
||||
name: recipient.name,
|
||||
},
|
||||
from: senderEmail,
|
||||
replyTo: replyToEmail,
|
||||
subject,
|
||||
html,
|
||||
text,
|
||||
});
|
||||
|
||||
await tx.documentAuditLog.create({
|
||||
data: createDocumentAuditLogData({
|
||||
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_ACCESS_AUTH_2FA_REQUESTED,
|
||||
documentId: document.id,
|
||||
data: {
|
||||
recipientEmail: recipient.email,
|
||||
recipientName: recipient.name,
|
||||
recipientId: recipient.id,
|
||||
},
|
||||
}),
|
||||
});
|
||||
},
|
||||
{ timeout: 30_000 },
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,37 @@
|
||||
import { generateHOTP } from 'oslo/otp';
|
||||
|
||||
import { generateTwoFactorCredentialsFromEmail } from './generate-2fa-credentials-from-email';
|
||||
|
||||
export type ValidateTwoFactorTokenFromEmailOptions = {
|
||||
documentId: number;
|
||||
email: string;
|
||||
code: string;
|
||||
period?: number;
|
||||
window?: number;
|
||||
};
|
||||
|
||||
export const validateTwoFactorTokenFromEmail = async ({
|
||||
documentId,
|
||||
email,
|
||||
code,
|
||||
period = 30_000,
|
||||
window = 1,
|
||||
}: ValidateTwoFactorTokenFromEmailOptions) => {
|
||||
const { secret } = generateTwoFactorCredentialsFromEmail({ email, documentId });
|
||||
|
||||
let now = Date.now();
|
||||
|
||||
for (let i = 0; i < window; i++) {
|
||||
const counter = Math.floor(now / period);
|
||||
|
||||
const hotp = await generateHOTP(secret, counter);
|
||||
|
||||
if (code === hotp) {
|
||||
return true;
|
||||
}
|
||||
|
||||
now -= period;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
@ -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,
|
||||
|
||||
28
packages/lib/server-only/cert/cert-status.ts
Normal file
28
packages/lib/server-only/cert/cert-status.ts
Normal file
@ -0,0 +1,28 @@
|
||||
import * as fs from 'node:fs';
|
||||
|
||||
import { env } from '@documenso/lib/utils/env';
|
||||
|
||||
export const getCertificateStatus = () => {
|
||||
if (env('NEXT_PRIVATE_SIGNING_TRANSPORT') !== 'local') {
|
||||
return { isAvailable: true };
|
||||
}
|
||||
|
||||
if (env('NEXT_PRIVATE_SIGNING_LOCAL_FILE_CONTENTS')) {
|
||||
return { isAvailable: true };
|
||||
}
|
||||
|
||||
const defaultPath =
|
||||
env('NODE_ENV') === 'production' ? '/opt/documenso/cert.p12' : './example/cert.p12';
|
||||
|
||||
const filePath = env('NEXT_PRIVATE_SIGNING_LOCAL_FILE_PATH') || defaultPath;
|
||||
|
||||
try {
|
||||
fs.accessSync(filePath, fs.constants.F_OK | fs.constants.R_OK);
|
||||
|
||||
const stats = fs.statSync(filePath);
|
||||
|
||||
return { isAvailable: stats.size > 0 };
|
||||
} catch {
|
||||
return { isAvailable: false };
|
||||
}
|
||||
};
|
||||
@ -18,7 +18,8 @@ import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||
import { jobs } from '../../jobs/client';
|
||||
import type { TRecipientActionAuth } from '../../types/document-auth';
|
||||
import type { TRecipientAccessAuth, TRecipientActionAuth } from '../../types/document-auth';
|
||||
import { DocumentAuth } from '../../types/document-auth';
|
||||
import {
|
||||
ZWebhookDocumentSchema,
|
||||
mapDocumentToWebhookDocumentPayload,
|
||||
@ -26,6 +27,7 @@ import {
|
||||
import { extractDocumentAuthMethods } from '../../utils/document-auth';
|
||||
import { getIsRecipientsTurnToSign } from '../recipient/get-is-recipient-turn';
|
||||
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
|
||||
import { isRecipientAuthorized } from './is-recipient-authorized';
|
||||
import { sendPendingEmail } from './send-pending-email';
|
||||
|
||||
export type CompleteDocumentWithTokenOptions = {
|
||||
@ -33,6 +35,7 @@ export type CompleteDocumentWithTokenOptions = {
|
||||
documentId: number;
|
||||
userId?: number;
|
||||
authOptions?: TRecipientActionAuth;
|
||||
accessAuthOptions?: TRecipientAccessAuth;
|
||||
requestMetadata?: RequestMetadata;
|
||||
nextSigner?: {
|
||||
email: string;
|
||||
@ -64,6 +67,8 @@ const getDocument = async ({ token, documentId }: CompleteDocumentWithTokenOptio
|
||||
export const completeDocumentWithToken = async ({
|
||||
token,
|
||||
documentId,
|
||||
userId,
|
||||
accessAuthOptions,
|
||||
requestMetadata,
|
||||
nextSigner,
|
||||
}: CompleteDocumentWithTokenOptions) => {
|
||||
@ -111,24 +116,57 @@ export const completeDocumentWithToken = async ({
|
||||
throw new Error(`Recipient ${recipient.id} has unsigned fields`);
|
||||
}
|
||||
|
||||
// Document reauth for completing documents is currently not required.
|
||||
// Check ACCESS AUTH 2FA validation during document completion
|
||||
const { derivedRecipientAccessAuth } = extractDocumentAuthMethods({
|
||||
documentAuth: document.authOptions,
|
||||
recipientAuth: recipient.authOptions,
|
||||
});
|
||||
|
||||
// const { derivedRecipientActionAuth } = extractDocumentAuthMethods({
|
||||
// documentAuth: document.authOptions,
|
||||
// recipientAuth: recipient.authOptions,
|
||||
// });
|
||||
if (derivedRecipientAccessAuth.includes(DocumentAuth.TWO_FACTOR_AUTH)) {
|
||||
if (!accessAuthOptions) {
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
||||
message: 'Access authentication required',
|
||||
});
|
||||
}
|
||||
|
||||
// const isValid = await isRecipientAuthorized({
|
||||
// type: 'ACTION',
|
||||
// document: document,
|
||||
// recipient: recipient,
|
||||
// userId,
|
||||
// authOptions,
|
||||
// });
|
||||
const isValid = await isRecipientAuthorized({
|
||||
type: 'ACCESS_2FA',
|
||||
documentAuthOptions: document.authOptions,
|
||||
recipient: recipient,
|
||||
userId, // Can be undefined for non-account recipients
|
||||
authOptions: accessAuthOptions,
|
||||
});
|
||||
|
||||
// if (!isValid) {
|
||||
// throw new AppError(AppErrorCode.UNAUTHORIZED, 'Invalid authentication values');
|
||||
// }
|
||||
if (!isValid) {
|
||||
await prisma.documentAuditLog.create({
|
||||
data: createDocumentAuditLogData({
|
||||
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_ACCESS_AUTH_2FA_FAILED,
|
||||
documentId: document.id,
|
||||
data: {
|
||||
recipientId: recipient.id,
|
||||
recipientName: recipient.name,
|
||||
recipientEmail: recipient.email,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
throw new AppError(AppErrorCode.TWO_FACTOR_AUTH_FAILED, {
|
||||
message: 'Invalid 2FA authentication',
|
||||
});
|
||||
}
|
||||
|
||||
await prisma.documentAuditLog.create({
|
||||
data: createDocumentAuditLogData({
|
||||
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_ACCESS_AUTH_2FA_VALIDATED,
|
||||
documentId: document.id,
|
||||
data: {
|
||||
recipientId: recipient.id,
|
||||
recipientName: recipient.name,
|
||||
recipientEmail: recipient.email,
|
||||
},
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await tx.recipient.update({
|
||||
|
||||
@ -91,6 +91,12 @@ export const getDocumentAndSenderByToken = async ({
|
||||
select: {
|
||||
name: true,
|
||||
teamEmail: true,
|
||||
teamGlobalSettings: {
|
||||
select: {
|
||||
brandingEnabled: true,
|
||||
brandingLogo: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@ -4,6 +4,7 @@ import { match } from 'ts-pattern';
|
||||
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { validateTwoFactorTokenFromEmail } from '../2fa/email/validate-2fa-token-from-email';
|
||||
import { verifyTwoFactorAuthenticationToken } from '../2fa/verify-2fa-token';
|
||||
import { verifyPassword } from '../2fa/verify-password';
|
||||
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||
@ -14,9 +15,10 @@ import { getAuthenticatorOptions } from '../../utils/authenticator';
|
||||
import { extractDocumentAuthMethods } from '../../utils/document-auth';
|
||||
|
||||
type IsRecipientAuthorizedOptions = {
|
||||
type: 'ACCESS' | 'ACTION';
|
||||
// !: Probably find a better name than 'ACCESS_2FA' if requirements change.
|
||||
type: 'ACCESS' | 'ACCESS_2FA' | 'ACTION';
|
||||
documentAuthOptions: Document['authOptions'];
|
||||
recipient: Pick<Recipient, 'authOptions' | 'email'>;
|
||||
recipient: Pick<Recipient, 'authOptions' | 'email' | 'documentId'>;
|
||||
|
||||
/**
|
||||
* The ID of the user who initiated the request.
|
||||
@ -61,8 +63,11 @@ export const isRecipientAuthorized = async ({
|
||||
recipientAuth: recipient.authOptions,
|
||||
});
|
||||
|
||||
const authMethods: TDocumentAuth[] =
|
||||
type === 'ACCESS' ? derivedRecipientAccessAuth : derivedRecipientActionAuth;
|
||||
const authMethods: TDocumentAuth[] = match(type)
|
||||
.with('ACCESS', () => derivedRecipientAccessAuth)
|
||||
.with('ACCESS_2FA', () => derivedRecipientAccessAuth)
|
||||
.with('ACTION', () => derivedRecipientActionAuth)
|
||||
.exhaustive();
|
||||
|
||||
// Early true return when auth is not required.
|
||||
if (
|
||||
@ -72,6 +77,11 @@ export const isRecipientAuthorized = async ({
|
||||
return true;
|
||||
}
|
||||
|
||||
// Early true return for ACCESS auth if all methods are 2FA since validation happens in ACCESS_2FA.
|
||||
if (type === 'ACCESS' && authMethods.every((method) => method === DocumentAuth.TWO_FACTOR_AUTH)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Create auth options when none are passed for account.
|
||||
if (!authOptions && authMethods.some((method) => method === DocumentAuth.ACCOUNT)) {
|
||||
authOptions = {
|
||||
@ -80,12 +90,16 @@ export const isRecipientAuthorized = async ({
|
||||
}
|
||||
|
||||
// Authentication required does not match provided method.
|
||||
if (!authOptions || !authMethods.includes(authOptions.type) || !userId) {
|
||||
if (!authOptions || !authMethods.includes(authOptions.type)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return await match(authOptions)
|
||||
.with({ type: DocumentAuth.ACCOUNT }, async () => {
|
||||
if (!userId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const recipientUser = await getUserByEmail(recipient.email);
|
||||
|
||||
if (!recipientUser) {
|
||||
@ -95,13 +109,40 @@ export const isRecipientAuthorized = async ({
|
||||
return recipientUser.id === userId;
|
||||
})
|
||||
.with({ type: DocumentAuth.PASSKEY }, async ({ authenticationResponse, tokenReference }) => {
|
||||
if (!userId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return await isPasskeyAuthValid({
|
||||
userId,
|
||||
authenticationResponse,
|
||||
tokenReference,
|
||||
});
|
||||
})
|
||||
.with({ type: DocumentAuth.TWO_FACTOR_AUTH }, async ({ token }) => {
|
||||
.with({ type: DocumentAuth.TWO_FACTOR_AUTH }, async ({ token, method }) => {
|
||||
if (type === 'ACCESS') {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (type === 'ACCESS_2FA' && method === 'email') {
|
||||
if (!recipient.documentId) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'Document ID is required for email 2FA verification',
|
||||
});
|
||||
}
|
||||
|
||||
return await validateTwoFactorTokenFromEmail({
|
||||
documentId: recipient.documentId,
|
||||
email: recipient.email,
|
||||
code: token,
|
||||
window: 10, // 5 minutes worth of tokens
|
||||
});
|
||||
}
|
||||
|
||||
if (!userId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const user = await prisma.user.findFirst({
|
||||
where: {
|
||||
id: userId,
|
||||
@ -115,6 +156,7 @@ export const isRecipientAuthorized = async ({
|
||||
});
|
||||
}
|
||||
|
||||
// For ACTION auth or authenticator method, use TOTP
|
||||
return await verifyTwoFactorAuthenticationToken({
|
||||
user,
|
||||
totpCode: token,
|
||||
@ -122,6 +164,10 @@ export const isRecipientAuthorized = async ({
|
||||
});
|
||||
})
|
||||
.with({ type: DocumentAuth.PASSWORD }, async ({ password }) => {
|
||||
if (!userId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return await verifyPassword({
|
||||
userId,
|
||||
password,
|
||||
|
||||
@ -7,7 +7,7 @@ import { isRecipientAuthorized } from './is-recipient-authorized';
|
||||
|
||||
export type ValidateFieldAuthOptions = {
|
||||
documentAuthOptions: Document['authOptions'];
|
||||
recipient: Pick<Recipient, 'authOptions' | 'email'>;
|
||||
recipient: Pick<Recipient, 'authOptions' | 'email' | 'documentId'>;
|
||||
field: Field;
|
||||
userId?: number;
|
||||
authOptions?: TRecipientActionAuth;
|
||||
|
||||
@ -84,9 +84,7 @@ export const setFieldsForDocument = async ({
|
||||
const linkedFields = fields.map((field) => {
|
||||
const existing = existingFields.find((existingField) => existingField.id === field.id);
|
||||
|
||||
const recipient = document.recipients.find(
|
||||
(recipient) => recipient.email.toLowerCase() === field.signerEmail.toLowerCase(),
|
||||
);
|
||||
const recipient = document.recipients.find((recipient) => recipient.id === field.recipientId);
|
||||
|
||||
// Each field MUST have a recipient associated with it.
|
||||
if (!recipient) {
|
||||
@ -226,10 +224,8 @@ export const setFieldsForDocument = async ({
|
||||
},
|
||||
recipient: {
|
||||
connect: {
|
||||
documentId_email: {
|
||||
documentId,
|
||||
email: fieldSignerEmail,
|
||||
},
|
||||
id: field.recipientId,
|
||||
documentId,
|
||||
},
|
||||
},
|
||||
},
|
||||
@ -330,6 +326,7 @@ type FieldData = {
|
||||
id?: number | null;
|
||||
type: FieldType;
|
||||
signerEmail: string;
|
||||
recipientId: number;
|
||||
pageNumber: number;
|
||||
pageX: number;
|
||||
pageY: number;
|
||||
|
||||
@ -26,6 +26,7 @@ export type SetFieldsForTemplateOptions = {
|
||||
id?: number | null;
|
||||
type: FieldType;
|
||||
signerEmail: string;
|
||||
recipientId: number;
|
||||
pageNumber: number;
|
||||
pageX: number;
|
||||
pageY: number;
|
||||
@ -169,10 +170,8 @@ export const setFieldsForTemplate = async ({
|
||||
},
|
||||
recipient: {
|
||||
connect: {
|
||||
templateId_email: {
|
||||
templateId,
|
||||
email: field.signerEmail.toLowerCase(),
|
||||
},
|
||||
id: field.recipientId,
|
||||
templateId,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -85,20 +85,6 @@ export const createDocumentRecipients = async ({
|
||||
email: recipient.email.toLowerCase(),
|
||||
}));
|
||||
|
||||
const duplicateRecipients = normalizedRecipients.filter((newRecipient) => {
|
||||
const existingRecipient = document.recipients.find(
|
||||
(existingRecipient) => existingRecipient.email === newRecipient.email,
|
||||
);
|
||||
|
||||
return existingRecipient !== undefined;
|
||||
});
|
||||
|
||||
if (duplicateRecipients.length > 0) {
|
||||
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||
message: `Duplicate recipient(s) found for ${duplicateRecipients.map((recipient) => recipient.email).join(', ')}`,
|
||||
});
|
||||
}
|
||||
|
||||
const createdRecipients = await prisma.$transaction(async (tx) => {
|
||||
return await Promise.all(
|
||||
normalizedRecipients.map(async (recipient) => {
|
||||
|
||||
@ -71,20 +71,6 @@ export const createTemplateRecipients = async ({
|
||||
email: recipient.email.toLowerCase(),
|
||||
}));
|
||||
|
||||
const duplicateRecipients = normalizedRecipients.filter((newRecipient) => {
|
||||
const existingRecipient = template.recipients.find(
|
||||
(existingRecipient) => existingRecipient.email === newRecipient.email,
|
||||
);
|
||||
|
||||
return existingRecipient !== undefined;
|
||||
});
|
||||
|
||||
if (duplicateRecipients.length > 0) {
|
||||
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||
message: `Duplicate recipient(s) found for ${duplicateRecipients.map((recipient) => recipient.email).join(', ')}`,
|
||||
});
|
||||
}
|
||||
|
||||
const createdRecipients = await prisma.$transaction(async (tx) => {
|
||||
return await Promise.all(
|
||||
normalizedRecipients.map(async (recipient) => {
|
||||
|
||||
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;
|
||||
};
|
||||
@ -122,16 +122,12 @@ export const setDocumentRecipients = async ({
|
||||
|
||||
const removedRecipients = existingRecipients.filter(
|
||||
(existingRecipient) =>
|
||||
!normalizedRecipients.find(
|
||||
(recipient) =>
|
||||
recipient.id === existingRecipient.id || recipient.email === existingRecipient.email,
|
||||
),
|
||||
!normalizedRecipients.find((recipient) => recipient.id === existingRecipient.id),
|
||||
);
|
||||
|
||||
const linkedRecipients = normalizedRecipients.map((recipient) => {
|
||||
const existing = existingRecipients.find(
|
||||
(existingRecipient) =>
|
||||
existingRecipient.id === recipient.id || existingRecipient.email === recipient.email,
|
||||
(existingRecipient) => existingRecipient.id === recipient.id,
|
||||
);
|
||||
|
||||
const canPersistedRecipientBeModified =
|
||||
|
||||
@ -94,10 +94,7 @@ export const setTemplateRecipients = async ({
|
||||
|
||||
const removedRecipients = existingRecipients.filter(
|
||||
(existingRecipient) =>
|
||||
!normalizedRecipients.find(
|
||||
(recipient) =>
|
||||
recipient.id === existingRecipient.id || recipient.email === existingRecipient.email,
|
||||
),
|
||||
!normalizedRecipients.find((recipient) => recipient.id === existingRecipient.id),
|
||||
);
|
||||
|
||||
if (template.directLink !== null) {
|
||||
@ -124,8 +121,7 @@ export const setTemplateRecipients = async ({
|
||||
|
||||
const linkedRecipients = normalizedRecipients.map((recipient) => {
|
||||
const existing = existingRecipients.find(
|
||||
(existingRecipient) =>
|
||||
existingRecipient.id === recipient.id || existingRecipient.email === recipient.email,
|
||||
(existingRecipient) => existingRecipient.id === recipient.id,
|
||||
);
|
||||
|
||||
return {
|
||||
|
||||
@ -91,17 +91,6 @@ export const updateDocumentRecipients = async ({
|
||||
});
|
||||
}
|
||||
|
||||
const duplicateRecipientWithSameEmail = document.recipients.find(
|
||||
(existingRecipient) =>
|
||||
existingRecipient.email === recipient.email && existingRecipient.id !== recipient.id,
|
||||
);
|
||||
|
||||
if (duplicateRecipientWithSameEmail) {
|
||||
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||
message: `Duplicate recipient with the same email found: ${duplicateRecipientWithSameEmail.email}`,
|
||||
});
|
||||
}
|
||||
|
||||
if (!canRecipientBeModified(originalRecipient, document.fields)) {
|
||||
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||
message: 'Cannot modify a recipient who has already interacted with the document',
|
||||
|
||||
@ -80,17 +80,6 @@ export const updateTemplateRecipients = async ({
|
||||
});
|
||||
}
|
||||
|
||||
const duplicateRecipientWithSameEmail = template.recipients.find(
|
||||
(existingRecipient) =>
|
||||
existingRecipient.email === recipient.email && existingRecipient.id !== recipient.id,
|
||||
);
|
||||
|
||||
if (duplicateRecipientWithSameEmail) {
|
||||
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||
message: `Duplicate recipient with the same email found: ${duplicateRecipientWithSameEmail.email}`,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
originalRecipient,
|
||||
recipientUpdateData: recipient,
|
||||
|
||||
@ -159,6 +159,7 @@ export const createDocumentFromDirectTemplate = async ({
|
||||
// Ensure typesafety when we add more options.
|
||||
const isAccessAuthValid = match(derivedRecipientAccessAuth.at(0))
|
||||
.with(DocumentAccessAuth.ACCOUNT, () => user && user?.email === directRecipientEmail)
|
||||
.with(DocumentAccessAuth.TWO_FACTOR_AUTH, () => false) // Not supported for direct templates
|
||||
.with(undefined, () => true)
|
||||
.exhaustive();
|
||||
|
||||
@ -205,6 +206,7 @@ export const createDocumentFromDirectTemplate = async ({
|
||||
recipient: {
|
||||
authOptions: directTemplateRecipient.authOptions,
|
||||
email: directRecipientEmail,
|
||||
documentId: template.id,
|
||||
},
|
||||
field: templateField,
|
||||
userId: user?.id,
|
||||
|
||||
@ -19,6 +19,8 @@ export type CreateDocumentFromTemplateLegacyOptions = {
|
||||
}[];
|
||||
};
|
||||
|
||||
// !TODO: Make this work
|
||||
|
||||
/**
|
||||
* Legacy server function for /api/v1
|
||||
*/
|
||||
@ -58,6 +60,15 @@ export const createDocumentFromTemplateLegacy = async ({
|
||||
},
|
||||
});
|
||||
|
||||
const recipientsToCreate = template.recipients.map((recipient) => ({
|
||||
id: recipient.id,
|
||||
email: recipient.email,
|
||||
name: recipient.name,
|
||||
role: recipient.role,
|
||||
signingOrder: recipient.signingOrder,
|
||||
token: nanoid(),
|
||||
}));
|
||||
|
||||
const document = await prisma.document.create({
|
||||
data: {
|
||||
qrToken: prefixedId('qr'),
|
||||
@ -70,12 +81,12 @@ export const createDocumentFromTemplateLegacy = async ({
|
||||
documentDataId: documentData.id,
|
||||
useLegacyFieldInsertion: template.useLegacyFieldInsertion ?? false,
|
||||
recipients: {
|
||||
create: template.recipients.map((recipient) => ({
|
||||
create: recipientsToCreate.map((recipient) => ({
|
||||
email: recipient.email,
|
||||
name: recipient.name,
|
||||
role: recipient.role,
|
||||
signingOrder: recipient.signingOrder,
|
||||
token: nanoid(),
|
||||
token: recipient.token,
|
||||
})),
|
||||
},
|
||||
documentMeta: {
|
||||
@ -95,9 +106,11 @@ export const createDocumentFromTemplateLegacy = async ({
|
||||
|
||||
await prisma.field.createMany({
|
||||
data: template.fields.map((field) => {
|
||||
const recipient = template.recipients.find((recipient) => recipient.id === field.recipientId);
|
||||
const recipient = recipientsToCreate.find((recipient) => recipient.id === field.recipientId);
|
||||
|
||||
const documentRecipient = document.recipients.find((doc) => doc.email === recipient?.email);
|
||||
const documentRecipient = document.recipients.find(
|
||||
(documentRecipient) => documentRecipient.token === recipient?.token,
|
||||
);
|
||||
|
||||
if (!documentRecipient) {
|
||||
throw new Error('Recipient not found.');
|
||||
@ -118,28 +131,32 @@ export const createDocumentFromTemplateLegacy = async ({
|
||||
}),
|
||||
});
|
||||
|
||||
// Replicate the old logic, get by index and create if we exceed the number of existing recipients.
|
||||
if (recipients && recipients.length > 0) {
|
||||
document.recipients = await Promise.all(
|
||||
await Promise.all(
|
||||
recipients.map(async (recipient, index) => {
|
||||
const existingRecipient = document.recipients.at(index);
|
||||
|
||||
return await prisma.recipient.upsert({
|
||||
where: {
|
||||
documentId_email: {
|
||||
if (existingRecipient) {
|
||||
return await prisma.recipient.update({
|
||||
where: {
|
||||
id: existingRecipient.id,
|
||||
documentId: document.id,
|
||||
email: existingRecipient?.email ?? recipient.email,
|
||||
},
|
||||
},
|
||||
update: {
|
||||
name: recipient.name,
|
||||
email: recipient.email,
|
||||
role: recipient.role,
|
||||
signingOrder: recipient.signingOrder,
|
||||
},
|
||||
create: {
|
||||
data: {
|
||||
name: recipient.name,
|
||||
email: recipient.email,
|
||||
role: recipient.role,
|
||||
signingOrder: recipient.signingOrder,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return await prisma.recipient.create({
|
||||
data: {
|
||||
documentId: document.id,
|
||||
email: recipient.email,
|
||||
name: recipient.name,
|
||||
email: recipient.email,
|
||||
role: recipient.role,
|
||||
signingOrder: recipient.signingOrder,
|
||||
token: nanoid(),
|
||||
@ -149,5 +166,18 @@ export const createDocumentFromTemplateLegacy = async ({
|
||||
);
|
||||
}
|
||||
|
||||
return document;
|
||||
// Gross but we need to do the additional fetch since we mutate above.
|
||||
const updatedRecipients = await prisma.recipient.findMany({
|
||||
where: {
|
||||
documentId: document.id,
|
||||
},
|
||||
orderBy: {
|
||||
id: 'asc',
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
...document,
|
||||
recipients: updatedRecipients,
|
||||
};
|
||||
};
|
||||
|
||||
@ -54,7 +54,7 @@ import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
|
||||
|
||||
type FinalRecipient = Pick<
|
||||
Recipient,
|
||||
'name' | 'email' | 'role' | 'authOptions' | 'signingOrder'
|
||||
'name' | 'email' | 'role' | 'authOptions' | 'signingOrder' | 'token'
|
||||
> & {
|
||||
templateRecipientId: number;
|
||||
fields: Field[];
|
||||
@ -353,6 +353,7 @@ export const createDocumentFromTemplate = async ({
|
||||
role: templateRecipient.role,
|
||||
signingOrder: foundRecipient?.signingOrder ?? templateRecipient.signingOrder,
|
||||
authOptions: templateRecipient.authOptions,
|
||||
token: nanoid(),
|
||||
};
|
||||
});
|
||||
|
||||
@ -459,7 +460,7 @@ export const createDocumentFromTemplate = async ({
|
||||
: SigningStatus.NOT_SIGNED,
|
||||
signingOrder: recipient.signingOrder,
|
||||
expired: recipientExpiryDate,
|
||||
token: nanoid(),
|
||||
token: recipient.token,
|
||||
};
|
||||
}),
|
||||
},
|
||||
@ -518,8 +519,8 @@ export const createDocumentFromTemplate = async ({
|
||||
}
|
||||
}
|
||||
|
||||
Object.values(finalRecipients).forEach(({ email, fields }) => {
|
||||
const recipient = document.recipients.find((recipient) => recipient.email === email);
|
||||
Object.values(finalRecipients).forEach(({ token, fields }) => {
|
||||
const recipient = document.recipients.find((recipient) => recipient.token === token);
|
||||
|
||||
if (!recipient) {
|
||||
throw new Error('Recipient not found.');
|
||||
|
||||
@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@ -41,6 +41,11 @@ export const ZDocumentAuditLogTypeSchema = z.enum([
|
||||
'DOCUMENT_EXTERNAL_ID_UPDATED', // When the document external ID is updated.
|
||||
'DOCUMENT_MOVED_TO_TEAM', // When the document is moved to a team.
|
||||
'DOCUMENT_RECIPIENT_EXPIRY_EXTENDED', // When a recipient's expiry is extended via resend.
|
||||
|
||||
// ACCESS AUTH 2FA events.
|
||||
'DOCUMENT_ACCESS_AUTH_2FA_REQUESTED', // When ACCESS AUTH 2FA is requested.
|
||||
'DOCUMENT_ACCESS_AUTH_2FA_VALIDATED', // When ACCESS AUTH 2FA is successfully validated.
|
||||
'DOCUMENT_ACCESS_AUTH_2FA_FAILED', // When ACCESS AUTH 2FA validation fails.
|
||||
]);
|
||||
|
||||
export const ZDocumentAuditLogEmailTypeSchema = z.enum([
|
||||
@ -488,6 +493,42 @@ export const ZDocumentAuditLogEventDocumentRecipientRejectedSchema = z.object({
|
||||
}),
|
||||
});
|
||||
|
||||
/**
|
||||
* Event: Document recipient requested a 2FA token.
|
||||
*/
|
||||
export const ZDocumentAuditLogEventDocumentRecipientRequested2FAEmailSchema = z.object({
|
||||
type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_ACCESS_AUTH_2FA_REQUESTED),
|
||||
data: z.object({
|
||||
recipientEmail: z.string(),
|
||||
recipientName: z.string(),
|
||||
recipientId: z.number(),
|
||||
}),
|
||||
});
|
||||
|
||||
/**
|
||||
* Event: Document recipient validated a 2FA token.
|
||||
*/
|
||||
export const ZDocumentAuditLogEventDocumentRecipientValidated2FAEmailSchema = z.object({
|
||||
type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_ACCESS_AUTH_2FA_VALIDATED),
|
||||
data: z.object({
|
||||
recipientEmail: z.string(),
|
||||
recipientName: z.string(),
|
||||
recipientId: z.number(),
|
||||
}),
|
||||
});
|
||||
|
||||
/**
|
||||
* Event: Document recipient failed to validate a 2FA token.
|
||||
*/
|
||||
export const ZDocumentAuditLogEventDocumentRecipientFailed2FAEmailSchema = z.object({
|
||||
type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_ACCESS_AUTH_2FA_FAILED),
|
||||
data: z.object({
|
||||
recipientEmail: z.string(),
|
||||
recipientName: z.string(),
|
||||
recipientId: z.number(),
|
||||
}),
|
||||
});
|
||||
|
||||
/**
|
||||
* Event: Document sent.
|
||||
*/
|
||||
@ -642,6 +683,9 @@ export const ZDocumentAuditLogSchema = ZDocumentAuditLogBaseSchema.and(
|
||||
ZDocumentAuditLogEventDocumentViewedSchema,
|
||||
ZDocumentAuditLogEventDocumentRecipientCompleteSchema,
|
||||
ZDocumentAuditLogEventDocumentRecipientRejectedSchema,
|
||||
ZDocumentAuditLogEventDocumentRecipientRequested2FAEmailSchema,
|
||||
ZDocumentAuditLogEventDocumentRecipientValidated2FAEmailSchema,
|
||||
ZDocumentAuditLogEventDocumentRecipientFailed2FAEmailSchema,
|
||||
ZDocumentAuditLogEventDocumentSentSchema,
|
||||
ZDocumentAuditLogEventDocumentTitleUpdatedSchema,
|
||||
ZDocumentAuditLogEventDocumentExternalIdUpdatedSchema,
|
||||
|
||||
@ -37,6 +37,7 @@ const ZDocumentAuthPasswordSchema = z.object({
|
||||
const ZDocumentAuth2FASchema = z.object({
|
||||
type: z.literal(DocumentAuth.TWO_FACTOR_AUTH),
|
||||
token: z.string().min(4).max(10),
|
||||
method: z.enum(['email', 'authenticator']).default('authenticator').optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
@ -55,9 +56,12 @@ export const ZDocumentAuthMethodsSchema = z.discriminatedUnion('type', [
|
||||
*
|
||||
* Must keep these two in sync.
|
||||
*/
|
||||
export const ZDocumentAccessAuthSchema = z.discriminatedUnion('type', [ZDocumentAuthAccountSchema]);
|
||||
export const ZDocumentAccessAuthSchema = z.discriminatedUnion('type', [
|
||||
ZDocumentAuthAccountSchema,
|
||||
ZDocumentAuth2FASchema,
|
||||
]);
|
||||
export const ZDocumentAccessAuthTypesSchema = z
|
||||
.enum([DocumentAuth.ACCOUNT])
|
||||
.enum([DocumentAuth.ACCOUNT, DocumentAuth.TWO_FACTOR_AUTH])
|
||||
.describe('The type of authentication required for the recipient to access the document.');
|
||||
|
||||
/**
|
||||
@ -89,9 +93,10 @@ export const ZDocumentActionAuthTypesSchema = z
|
||||
*/
|
||||
export const ZRecipientAccessAuthSchema = z.discriminatedUnion('type', [
|
||||
ZDocumentAuthAccountSchema,
|
||||
ZDocumentAuth2FASchema,
|
||||
]);
|
||||
export const ZRecipientAccessAuthTypesSchema = z
|
||||
.enum([DocumentAuth.ACCOUNT])
|
||||
.enum([DocumentAuth.ACCOUNT, DocumentAuth.TWO_FACTOR_AUTH])
|
||||
.describe('The type of authentication required for the recipient to access the document.');
|
||||
|
||||
/**
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -476,6 +476,36 @@ export const formatDocumentAuditLogAction = (
|
||||
identified: result,
|
||||
};
|
||||
})
|
||||
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_ACCESS_AUTH_2FA_REQUESTED }, ({ data }) => {
|
||||
const userName = prefix || _(msg`Recipient`);
|
||||
|
||||
const result = msg`${userName} requested a 2FA token for the document`;
|
||||
|
||||
return {
|
||||
anonymous: result,
|
||||
identified: result,
|
||||
};
|
||||
})
|
||||
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_ACCESS_AUTH_2FA_VALIDATED }, ({ data }) => {
|
||||
const userName = prefix || _(msg`Recipient`);
|
||||
|
||||
const result = msg`${userName} validated a 2FA token for the document`;
|
||||
|
||||
return {
|
||||
anonymous: result,
|
||||
identified: result,
|
||||
};
|
||||
})
|
||||
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_ACCESS_AUTH_2FA_FAILED }, ({ data }) => {
|
||||
const userName = prefix || _(msg`Recipient`);
|
||||
|
||||
const result = msg`${userName} failed to validate a 2FA token for the document`;
|
||||
|
||||
return {
|
||||
anonymous: result,
|
||||
identified: result,
|
||||
};
|
||||
})
|
||||
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT }, ({ data }) => ({
|
||||
anonymous: data.isResending ? msg`Email resent` : msg`Email sent`,
|
||||
identified: data.isResending
|
||||
|
||||
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";
|
||||
@ -0,0 +1,5 @@
|
||||
-- DropIndex
|
||||
DROP INDEX "Recipient_documentId_email_key";
|
||||
|
||||
-- DropIndex
|
||||
DROP INDEX "Recipient_templateId_email_key";
|
||||
@ -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?
|
||||
|
||||
@ -525,8 +531,6 @@ model Recipient {
|
||||
fields Field[]
|
||||
signatures Signature[]
|
||||
|
||||
@@unique([documentId, email])
|
||||
@@unique([templateId, email])
|
||||
@@index([documentId])
|
||||
@@index([templateId])
|
||||
@@index([token])
|
||||
@ -636,6 +640,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 {
|
||||
@ -1034,3 +1041,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({
|
||||
|
||||
124
packages/trpc/server/admin-router/promote-member-to-owner.ts
Normal file
124
packages/trpc/server/admin-router/promote-member-to-owner.ts
Normal file
@ -0,0 +1,124 @@
|
||||
import { OrganisationGroupType, OrganisationMemberRole } from '@prisma/client';
|
||||
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { generateDatabaseId } from '@documenso/lib/universal/id';
|
||||
import { getHighestOrganisationRoleInGroup } from '@documenso/lib/utils/organisations';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { adminProcedure } from '../trpc';
|
||||
import {
|
||||
ZPromoteMemberToOwnerRequestSchema,
|
||||
ZPromoteMemberToOwnerResponseSchema,
|
||||
} from './promote-member-to-owner.types';
|
||||
|
||||
export const promoteMemberToOwnerRoute = adminProcedure
|
||||
.input(ZPromoteMemberToOwnerRequestSchema)
|
||||
.output(ZPromoteMemberToOwnerResponseSchema)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const { organisationId, userId } = input;
|
||||
|
||||
ctx.logger.info({
|
||||
input: {
|
||||
organisationId,
|
||||
userId,
|
||||
},
|
||||
});
|
||||
|
||||
// First, verify the organisation exists and get member details with groups
|
||||
const organisation = await prisma.organisation.findUnique({
|
||||
where: {
|
||||
id: organisationId,
|
||||
},
|
||||
include: {
|
||||
groups: {
|
||||
where: {
|
||||
type: OrganisationGroupType.INTERNAL_ORGANISATION,
|
||||
},
|
||||
},
|
||||
members: {
|
||||
where: {
|
||||
userId,
|
||||
},
|
||||
include: {
|
||||
organisationGroupMembers: {
|
||||
include: {
|
||||
group: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!organisation) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'Organisation not found',
|
||||
});
|
||||
}
|
||||
|
||||
// Verify the user is a member of the organisation
|
||||
const [member] = organisation.members;
|
||||
|
||||
if (!member) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'User is not a member of this organisation',
|
||||
});
|
||||
}
|
||||
|
||||
// Verify the user is not already the owner
|
||||
if (organisation.ownerUserId === userId) {
|
||||
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||
message: 'User is already the owner of this organisation',
|
||||
});
|
||||
}
|
||||
|
||||
// Get current organisation role
|
||||
const currentOrganisationRole = getHighestOrganisationRoleInGroup(
|
||||
member.organisationGroupMembers.flatMap((member) => member.group),
|
||||
);
|
||||
|
||||
// Find the current and target organisation groups
|
||||
const currentMemberGroup = organisation.groups.find(
|
||||
(group) => group.organisationRole === currentOrganisationRole,
|
||||
);
|
||||
|
||||
const adminGroup = organisation.groups.find(
|
||||
(group) => group.organisationRole === OrganisationMemberRole.ADMIN,
|
||||
);
|
||||
|
||||
if (!currentMemberGroup) {
|
||||
throw new AppError(AppErrorCode.UNKNOWN_ERROR, {
|
||||
message: 'Current member group not found',
|
||||
});
|
||||
}
|
||||
|
||||
if (!adminGroup) {
|
||||
throw new AppError(AppErrorCode.UNKNOWN_ERROR, {
|
||||
message: 'Admin group not found',
|
||||
});
|
||||
}
|
||||
|
||||
// Update the organisation owner and member role in a transaction
|
||||
await prisma.$transaction(async (tx) => {
|
||||
// Update the organisation to set the new owner
|
||||
await tx.organisation.update({
|
||||
where: {
|
||||
id: organisationId,
|
||||
},
|
||||
data: {
|
||||
ownerUserId: userId,
|
||||
},
|
||||
});
|
||||
|
||||
// Only update role if the user is not already an admin then add them to the admin group
|
||||
if (currentOrganisationRole !== OrganisationMemberRole.ADMIN) {
|
||||
await tx.organisationGroupMember.create({
|
||||
data: {
|
||||
id: generateDatabaseId('group_member'),
|
||||
organisationMemberId: member.id,
|
||||
groupId: adminGroup.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,11 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const ZPromoteMemberToOwnerRequestSchema = z.object({
|
||||
organisationId: z.string().min(1),
|
||||
userId: z.number().min(1),
|
||||
});
|
||||
|
||||
export const ZPromoteMemberToOwnerResponseSchema = z.void();
|
||||
|
||||
export type TPromoteMemberToOwnerRequest = z.infer<typeof ZPromoteMemberToOwnerRequestSchema>;
|
||||
export type TPromoteMemberToOwnerResponse = z.infer<typeof ZPromoteMemberToOwnerResponseSchema>;
|
||||
@ -12,6 +12,7 @@ import { findDocumentsRoute } from './find-documents';
|
||||
import { findSubscriptionClaimsRoute } from './find-subscription-claims';
|
||||
import { getAdminOrganisationRoute } from './get-admin-organisation';
|
||||
import { getUserRoute } from './get-user';
|
||||
import { promoteMemberToOwnerRoute } from './promote-member-to-owner';
|
||||
import { resealDocumentRoute } from './reseal-document';
|
||||
import { resetTwoFactorRoute } from './reset-two-factor-authentication';
|
||||
import { updateAdminOrganisationRoute } from './update-admin-organisation';
|
||||
@ -27,6 +28,9 @@ export const adminRouter = router({
|
||||
create: createAdminOrganisationRoute,
|
||||
update: updateAdminOrganisationRoute,
|
||||
},
|
||||
organisationMember: {
|
||||
promoteToOwner: promoteMemberToOwnerRoute,
|
||||
},
|
||||
claims: {
|
||||
find: findSubscriptionClaimsRoute,
|
||||
create: createSubscriptionClaimRoute,
|
||||
|
||||
@ -0,0 +1,94 @@
|
||||
import { TRPCError } from '@trpc/server';
|
||||
import { DateTime } from 'luxon';
|
||||
|
||||
import { TWO_FACTOR_EMAIL_EXPIRATION_MINUTES } from '@documenso/lib/server-only/2fa/email/constants';
|
||||
import { send2FATokenEmail } from '@documenso/lib/server-only/2fa/email/send-2fa-token-email';
|
||||
import { DocumentAuth } from '@documenso/lib/types/document-auth';
|
||||
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { procedure } from '../trpc';
|
||||
import {
|
||||
ZAccessAuthRequest2FAEmailRequestSchema,
|
||||
ZAccessAuthRequest2FAEmailResponseSchema,
|
||||
} from './access-auth-request-2fa-email.types';
|
||||
|
||||
export const accessAuthRequest2FAEmailRoute = procedure
|
||||
.input(ZAccessAuthRequest2FAEmailRequestSchema)
|
||||
.output(ZAccessAuthRequest2FAEmailResponseSchema)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
try {
|
||||
const { token } = input;
|
||||
|
||||
const user = ctx.user;
|
||||
|
||||
// Get document and recipient by token
|
||||
const document = await prisma.document.findFirst({
|
||||
where: {
|
||||
recipients: {
|
||||
some: {
|
||||
token,
|
||||
},
|
||||
},
|
||||
},
|
||||
include: {
|
||||
recipients: {
|
||||
where: {
|
||||
token,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!document) {
|
||||
throw new TRPCError({
|
||||
code: 'NOT_FOUND',
|
||||
message: 'Document not found',
|
||||
});
|
||||
}
|
||||
|
||||
const [recipient] = document.recipients;
|
||||
|
||||
const { derivedRecipientAccessAuth } = extractDocumentAuthMethods({
|
||||
documentAuth: document.authOptions,
|
||||
recipientAuth: recipient.authOptions,
|
||||
});
|
||||
|
||||
if (!derivedRecipientAccessAuth.includes(DocumentAuth.TWO_FACTOR_AUTH)) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: '2FA is not required for this document',
|
||||
});
|
||||
}
|
||||
|
||||
// if (user && recipient.email !== user.email) {
|
||||
// throw new TRPCError({
|
||||
// code: 'UNAUTHORIZED',
|
||||
// message: 'User does not match recipient',
|
||||
// });
|
||||
// }
|
||||
|
||||
const expiresAt = DateTime.now().plus({ minutes: TWO_FACTOR_EMAIL_EXPIRATION_MINUTES });
|
||||
|
||||
await send2FATokenEmail({
|
||||
token,
|
||||
documentId: document.id,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
expiresAt: expiresAt.toJSDate(),
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error sending access auth 2FA email:', error);
|
||||
|
||||
if (error instanceof TRPCError) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
throw new TRPCError({
|
||||
code: 'INTERNAL_SERVER_ERROR',
|
||||
message: 'Failed to send 2FA email',
|
||||
});
|
||||
}
|
||||
});
|
||||
@ -0,0 +1,17 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const ZAccessAuthRequest2FAEmailRequestSchema = z.object({
|
||||
token: z.string().min(1),
|
||||
});
|
||||
|
||||
export const ZAccessAuthRequest2FAEmailResponseSchema = z.object({
|
||||
success: z.boolean(),
|
||||
expiresAt: z.date(),
|
||||
});
|
||||
|
||||
export type TAccessAuthRequest2FAEmailRequest = z.infer<
|
||||
typeof ZAccessAuthRequest2FAEmailRequestSchema
|
||||
>;
|
||||
export type TAccessAuthRequest2FAEmailResponse = z.infer<
|
||||
typeof ZAccessAuthRequest2FAEmailResponseSchema
|
||||
>;
|
||||
@ -82,14 +82,7 @@ export const ZCreateDocumentTemporaryRequestSchema = z.object({
|
||||
.optional(),
|
||||
}),
|
||||
)
|
||||
.refine(
|
||||
(recipients) => {
|
||||
const emails = recipients.map((recipient) => recipient.email);
|
||||
|
||||
return new Set(emails).size === emails.length;
|
||||
},
|
||||
{ message: 'Recipients must have unique emails' },
|
||||
)
|
||||
.optional(),
|
||||
meta: z
|
||||
.object({
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { router } from '../trpc';
|
||||
import { accessAuthRequest2FAEmailRoute } from './access-auth-request-2fa-email';
|
||||
import { createDocumentRoute } from './create-document';
|
||||
import { createDocumentTemporaryRoute } from './create-document-temporary';
|
||||
import { deleteDocumentRoute } from './delete-document';
|
||||
@ -38,6 +39,10 @@ export const documentRouter = router({
|
||||
getDocumentByToken: getDocumentByTokenRoute,
|
||||
findDocumentsInternal: findDocumentsInternalRoute,
|
||||
|
||||
accessAuth: router({
|
||||
request2FAEmail: accessAuthRequest2FAEmailRoute,
|
||||
}),
|
||||
|
||||
auditLog: {
|
||||
find: findDocumentAuditLogsRoute,
|
||||
download: downloadDocumentAuditLogsRoute,
|
||||
|
||||
@ -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
|
||||
|
||||
@ -47,14 +47,7 @@ export const ZCreateEmbeddingDocumentRequestSchema = z.object({
|
||||
.optional(),
|
||||
}),
|
||||
)
|
||||
.refine(
|
||||
(recipients) => {
|
||||
const emails = recipients.map((recipient) => recipient.email);
|
||||
|
||||
return new Set(emails).size === emails.length;
|
||||
},
|
||||
{ message: 'Recipients must have unique emails' },
|
||||
)
|
||||
.optional(),
|
||||
meta: z
|
||||
.object({
|
||||
|
||||
@ -30,36 +30,27 @@ export const ZUpdateEmbeddingDocumentRequestSchema = z.object({
|
||||
documentId: z.number(),
|
||||
title: ZDocumentTitleSchema,
|
||||
externalId: ZDocumentExternalIdSchema.optional(),
|
||||
recipients: z
|
||||
.array(
|
||||
z.object({
|
||||
id: z.number().optional(),
|
||||
email: z.string().toLowerCase().email().min(1),
|
||||
name: z.string(),
|
||||
role: z.nativeEnum(RecipientRole),
|
||||
signingOrder: z.number().optional(),
|
||||
fields: ZFieldAndMetaSchema.and(
|
||||
z.object({
|
||||
id: z.number().optional(),
|
||||
pageNumber: ZFieldPageNumberSchema,
|
||||
pageX: ZFieldPageXSchema,
|
||||
pageY: ZFieldPageYSchema,
|
||||
width: ZFieldWidthSchema,
|
||||
height: ZFieldHeightSchema,
|
||||
}),
|
||||
)
|
||||
.array()
|
||||
.optional(),
|
||||
}),
|
||||
)
|
||||
.refine(
|
||||
(recipients) => {
|
||||
const emails = recipients.map((recipient) => recipient.email);
|
||||
|
||||
return new Set(emails).size === emails.length;
|
||||
},
|
||||
{ message: 'Recipients must have unique emails' },
|
||||
),
|
||||
recipients: z.array(
|
||||
z.object({
|
||||
id: z.number().optional(),
|
||||
email: z.string().toLowerCase().email().min(1),
|
||||
name: z.string(),
|
||||
role: z.nativeEnum(RecipientRole),
|
||||
signingOrder: z.number().optional(),
|
||||
fields: ZFieldAndMetaSchema.and(
|
||||
z.object({
|
||||
id: z.number().optional(),
|
||||
pageNumber: ZFieldPageNumberSchema,
|
||||
pageX: ZFieldPageXSchema,
|
||||
pageY: ZFieldPageYSchema,
|
||||
width: ZFieldWidthSchema,
|
||||
height: ZFieldHeightSchema,
|
||||
}),
|
||||
)
|
||||
.array()
|
||||
.optional(),
|
||||
}),
|
||||
),
|
||||
meta: z
|
||||
.object({
|
||||
subject: ZDocumentMetaSubjectSchema.optional(),
|
||||
|
||||
@ -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
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user