Merge branch 'main' into feat/add-envelopes-api

This commit is contained in:
David Nguyen
2025-11-01 12:47:55 +11:00
committed by GitHub
25 changed files with 1790 additions and 179 deletions

View File

@ -68,15 +68,29 @@ test('[ADMIN]: promote member to owner', async ({ page }) => {
// 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();
// Find and click the "Update role" button for the member
const updateRoleButton = memberRow.getByRole('button', {
name: 'Update role',
});
await expect(updateRoleButton).toBeVisible();
await expect(updateRoleButton).not.toBeDisabled();
await promoteButton.click();
await updateRoleButton.click();
// Verify success toast appears
await expect(page.getByText('Member promoted to owner successfully').first()).toBeVisible();
// Wait for dialog to open and select Owner role
await expect(page.getByRole('dialog')).toBeVisible();
// Find and click the select trigger - it's a button with role="combobox"
await page.getByRole('dialog').locator('button[role="combobox"]').click();
// Select "Owner" from the dropdown options
await page.getByRole('option', { name: 'Owner' }).click();
// Click Update button
await page.getByRole('dialog').getByRole('button', { name: 'Update' }).click();
// Wait for dialog to close (indicates success)
await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10_000 });
// Reload the page to see the changes
await page.reload();
@ -89,12 +103,18 @@ test('[ADMIN]: promote member to owner', async ({ page }) => {
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();
// Verify that the Update role button exists for the new owner and shows Owner as current role
const newOwnerUpdateButton = newOwnerRow.getByRole('button', {
name: 'Update role',
});
await expect(newOwnerUpdateButton).toBeVisible();
// Test that we can't promote the current owner (button should be disabled)
await expect(newOwnerPromoteButton).toHaveAttribute('disabled');
// Verify clicking it shows the dialog with Owner already selected
await newOwnerUpdateButton.click();
await expect(page.getByRole('dialog')).toBeVisible();
// Close the dialog without making changes
await page.getByRole('button', { name: 'Cancel' }).click();
});
test('[ADMIN]: promote manager to owner', async ({ page }) => {
@ -130,10 +150,26 @@ test('[ADMIN]: promote manager to owner', async ({ page }) => {
// Promote the manager to owner
const managerRow = page.getByRole('row', { name: managerUser.email });
const promoteButton = managerRow.getByRole('button', { name: 'Promote to owner' });
const updateRoleButton = managerRow.getByRole('button', {
name: 'Update role',
});
await promoteButton.click();
await expect(page.getByText('Member promoted to owner successfully').first()).toBeVisible();
await updateRoleButton.click();
// Wait for dialog to open and select Owner role
await expect(page.getByRole('dialog')).toBeVisible();
// Find and click the select trigger - it's a button with role="combobox"
await page.getByRole('dialog').locator('button[role="combobox"]').click();
// Select "Owner" from the dropdown options
await page.getByRole('option', { name: 'Owner' }).click();
// Click Update button
await page.getByRole('dialog').getByRole('button', { name: 'Update' }).click();
// Wait for dialog to close (indicates success)
await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10_000 });
// Reload and verify the change
await page.reload();
@ -173,14 +209,27 @@ test('[ADMIN]: promote admin member to owner', async ({ page }) => {
// 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,
const updateRoleButton = adminMemberRow.getByRole('button', {
name: 'Update role',
});
await updateRoleButton.click();
// Wait for dialog to open and select Owner role
await expect(page.getByRole('dialog')).toBeVisible();
// Find and click the select trigger - it's a button with role="combobox"
await page.getByRole('dialog').locator('button[role="combobox"]').click();
// Select "Owner" from the dropdown options
await page.getByRole('option', { name: 'Owner' }).click();
// Click Update button
await page.getByRole('dialog').getByRole('button', { name: 'Update' }).click();
// Wait for dialog to close (indicates success)
await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10_000 });
// Reload and verify the change
await page.reload();
await expect(adminMemberRow.getByRole('status').filter({ hasText: 'Owner' })).toBeVisible();
@ -249,11 +298,25 @@ test('[ADMIN]: verify role hierarchy after promotion', async ({ page }) => {
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,
const updateRoleButton = memberRow.getByRole('button', {
name: 'Update role',
});
await updateRoleButton.click();
// Wait for dialog to open and select Owner role
await expect(page.getByRole('dialog')).toBeVisible();
// Find and click the select trigger - it's a button with role="combobox"
await page.getByRole('dialog').locator('button[role="combobox"]').click();
// Select "Owner" from the dropdown options
await page.getByRole('option', { name: 'Owner' }).click();
// Click Update button
await page.getByRole('dialog').getByRole('button', { name: 'Update' }).click();
// Wait for dialog to close (indicates success)
await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10_000 });
// Reload page to see updated state
await page.reload();
@ -262,9 +325,11 @@ test('[ADMIN]: verify role hierarchy after promotion', async ({ page }) => {
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();
// Verify the Update role button exists and shows Owner as current role
const newOwnerUpdateButton = memberRow.getByRole('button', {
name: 'Update role',
});
await expect(newOwnerUpdateButton).toBeVisible();
// Sign in as the newly promoted user to verify they have owner permissions
await apiSignin({
@ -336,28 +401,56 @@ test('[ADMIN]: multiple promotions in sequence', async ({ page }) => {
// 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,
let updateRoleButton1 = member1Row.getByRole('button', {
name: 'Update role',
});
await updateRoleButton1.click();
// Wait for dialog to open and select Owner role
await expect(page.getByRole('dialog')).toBeVisible();
// Find and click the select trigger - it's a button with role="combobox"
await page.getByRole('dialog').locator('button[role="combobox"]').click();
// Select "Owner" from the dropdown options
await page.getByRole('option', { name: 'Owner' }).click();
// Click Update button
await page.getByRole('dialog').getByRole('button', { name: 'Update' }).click();
// Wait for dialog to close (indicates success)
await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10_000 });
await page.reload();
// Verify Member 1 is now owner and button is disabled
// Verify Member 1 is now owner
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();
updateRoleButton1 = member1Row.getByRole('button', { name: 'Update role' });
await expect(updateRoleButton1).toBeVisible();
// 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,
const updateRoleButton2 = member2Row.getByRole('button', {
name: 'Update role',
});
await expect(updateRoleButton2).toBeVisible();
await updateRoleButton2.click();
// Wait for dialog to open and select Owner role
await expect(page.getByRole('dialog')).toBeVisible();
// Find and click the select trigger - it's a button with role="combobox"
await page.getByRole('dialog').locator('button[role="combobox"]').click();
// Select "Owner" from the dropdown options
await page.getByRole('option', { name: 'Owner' }).click();
// Click Update button
await page.getByRole('dialog').getByRole('button', { name: 'Update' }).click();
// Wait for dialog to close (indicates success)
await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10_000 });
await page.reload();
@ -365,9 +458,11 @@ test('[ADMIN]: multiple promotions in sequence', async ({ page }) => {
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();
// Verify Member 1's Update role button is still visible
const newUpdateButton1 = member1Row.getByRole('button', {
name: 'Update role',
});
await expect(newUpdateButton1).toBeVisible();
});
test('[ADMIN]: verify organisation access after ownership change', async ({ page }) => {
@ -402,11 +497,25 @@ test('[ADMIN]: verify organisation access after ownership change', async ({ page
});
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,
const updateRoleButton = memberRow.getByRole('button', {
name: 'Update role',
});
await updateRoleButton.click();
// Wait for dialog to open and select Owner role
await expect(page.getByRole('dialog')).toBeVisible();
// Find and click the select trigger - it's a button with role="combobox"
await page.getByRole('dialog').locator('button[role="combobox"]').click();
// Select "Owner" from the dropdown options
await page.getByRole('option', { name: 'Owner' }).click();
// Click Update button
await page.getByRole('dialog').getByRole('button', { name: 'Update' }).click();
// Wait for dialog to close (indicates success)
await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10_000 });
// Test that the new owner can access organisation settings
await apiSignin({

View File

@ -1,9 +1,12 @@
import { expect, test } from '@playwright/test';
import { DocumentSigningOrder, RecipientRole } from '@prisma/client';
import { customAlphabet } from 'nanoid';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { createDocumentAuthOptions } from '@documenso/lib/utils/document-auth';
import { mapSecondaryIdToTemplateId } from '@documenso/lib/utils/envelope';
import { formatDirectTemplatePath } from '@documenso/lib/utils/templates';
import { prisma } from '@documenso/prisma';
import { seedTeam } from '@documenso/prisma/seed/teams';
import { seedDirectTemplate, seedTemplate } from '@documenso/prisma/seed/templates';
import { seedTestEmail, seedUser } from '@documenso/prisma/seed/users';
@ -121,7 +124,7 @@ test('[DIRECT_TEMPLATES]: delete direct template link', async ({ page }) => {
await expect(page.getByText('404 not found')).toBeVisible();
});
test('[DIRECT_TEMPLATES]: direct template link auth access', async ({ page }) => {
test('[DIRECT_TEMPLATES]: V1 direct template link auth access', async ({ page }) => {
const { user, team } = await seedUser();
const directTemplateWithAuth = await seedDirectTemplate({
@ -153,6 +156,53 @@ test('[DIRECT_TEMPLATES]: direct template link auth access', async ({ page }) =>
await expect(page.getByRole('heading', { name: 'General' })).toBeVisible();
await expect(page.getByLabel('Email')).toBeDisabled();
await page.getByRole('button', { name: 'Continue' }).click();
await page.getByRole('button', { name: 'Complete' }).click();
await page.getByRole('button', { name: 'Sign' }).click();
await page.waitForURL(/\/sign/);
await expect(page.getByRole('heading', { name: 'Document Signed' })).toBeVisible();
});
test('[DIRECT_TEMPLATES]: V2 direct template link auth access', async ({ page }) => {
const { user, team } = await seedUser();
const directTemplateWithAuth = await seedDirectTemplate({
title: 'Personal direct template link',
userId: user.id,
teamId: team.id,
internalVersion: 2,
createTemplateOptions: {
authOptions: createDocumentAuthOptions({
globalAccessAuth: ['ACCOUNT'],
globalActionAuth: [],
}),
},
});
const directTemplatePath = formatDirectTemplatePath(
directTemplateWithAuth.directLink?.token || '',
);
await page.goto(directTemplatePath);
await expect(page.getByText('Authentication required')).toBeVisible();
await apiSignin({
page,
email: user.email,
});
await page.goto(directTemplatePath);
await expect(page.getByRole('heading', { name: 'Personal direct template link' })).toBeVisible();
await page.getByRole('button', { name: 'Complete' }).click();
await expect(page.getByLabel('Your Email')).not.toBeVisible();
await page.getByRole('button', { name: 'Sign' }).click();
await page.waitForURL(/\/sign/);
await expect(page.getByRole('heading', { name: 'Document Signed' })).toBeVisible();
});
test('[DIRECT_TEMPLATES]: use direct template link with 1 recipient', async ({ page }) => {
@ -175,6 +225,9 @@ test('[DIRECT_TEMPLATES]: use direct template link with 1 recipient', async ({ p
await page.getByPlaceholder('recipient@documenso.com').fill(seedTestEmail());
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByText('Next Recipient Name')).not.toBeVisible();
await page.getByRole('button', { name: 'Complete' }).click();
await page.getByRole('button', { name: 'Sign' }).click();
await page.waitForURL(/\/sign/);
@ -183,3 +236,173 @@ test('[DIRECT_TEMPLATES]: use direct template link with 1 recipient', async ({ p
// Add a longer waiting period to ensure document status is updated
await page.waitForTimeout(3000);
});
test('[DIRECT_TEMPLATES]: V1 use direct template link with 2 recipients with next signer dictation', async ({
page,
}) => {
const { team, owner, organisation } = await seedTeam({
createTeamMembers: 1,
});
// Should be visible to team members.
const template = await seedDirectTemplate({
title: 'Team direct template link 1',
userId: owner.id,
teamId: team.id,
});
await prisma.documentMeta.update({
where: {
id: template.documentMetaId,
},
data: {
allowDictateNextSigner: true,
signingOrder: DocumentSigningOrder.SEQUENTIAL,
},
});
const originalName = 'Signer 2';
const originalSecondSignerEmail = seedTestEmail();
// Add another signer
await prisma.recipient.create({
data: {
signingOrder: 2,
envelopeId: template.id,
email: originalSecondSignerEmail,
name: originalName,
token: Math.random().toString().slice(2, 7),
role: RecipientRole.SIGNER,
},
});
// Check that the direct template link is accessible.
await page.goto(formatDirectTemplatePath(template.directLink?.token || ''));
await expect(page.getByRole('heading', { name: 'General' })).toBeVisible();
await page.waitForTimeout(100);
await page.getByPlaceholder('recipient@documenso.com').fill(seedTestEmail());
await page.getByRole('button', { name: 'Continue' }).click();
await page.getByRole('button', { name: 'Complete' }).click();
await expect(page.getByText('Next Recipient Name')).toBeVisible();
const nextRecipientNameInputValue = await page.getByLabel('Next Recipient Name').inputValue();
expect(nextRecipientNameInputValue).toBe(originalName);
const nextRecipientEmailInputValue = await page.getByLabel('Next Recipient Email').inputValue();
expect(nextRecipientEmailInputValue).toBe(originalSecondSignerEmail);
const newName = 'Hello';
const newSecondSignerEmail = seedTestEmail();
await page.getByLabel('Next Recipient Email').fill(newSecondSignerEmail);
await page.getByLabel('Next Recipient Name').fill(newName);
await page.getByRole('button', { name: 'Sign' }).click();
await page.waitForURL(/\/sign/);
await expect(page.getByRole('heading', { name: 'Document Signed' })).toBeVisible();
const createdEnvelopeRecipients = await prisma.recipient.findMany({
where: {
envelope: {
templateId: mapSecondaryIdToTemplateId(template.secondaryId),
},
},
});
const updatedSecondRecipient = createdEnvelopeRecipients.find(
(recipient) => recipient.signingOrder === 2,
);
expect(updatedSecondRecipient?.name).toBe(newName);
expect(updatedSecondRecipient?.email).toBe(newSecondSignerEmail);
});
test('[DIRECT_TEMPLATES]: V2 use direct template link with 2 recipients with next signer dictation', async ({
page,
}) => {
const { team, owner, organisation } = await seedTeam({
createTeamMembers: 1,
});
// Should be visible to team members.
const template = await seedDirectTemplate({
title: 'Team direct template link 1',
userId: owner.id,
teamId: team.id,
internalVersion: 2,
});
await prisma.documentMeta.update({
where: {
id: template.documentMetaId,
},
data: {
allowDictateNextSigner: true,
signingOrder: DocumentSigningOrder.SEQUENTIAL,
},
});
const originalName = 'Signer 2';
const originalSecondSignerEmail = seedTestEmail();
// Add another signer
await prisma.recipient.create({
data: {
signingOrder: 2,
envelopeId: template.id,
email: originalSecondSignerEmail,
name: originalName,
token: Math.random().toString().slice(2, 7),
role: RecipientRole.SIGNER,
},
});
// Check that the direct template link is accessible.
await page.goto(formatDirectTemplatePath(template.directLink?.token || ''));
await expect(page.getByRole('heading', { name: 'Team direct template link 1' })).toBeVisible();
await page.waitForTimeout(100);
await page.getByRole('button', { name: 'Complete' }).click();
const currentName = 'John Doe';
const currentEmail = seedTestEmail();
await page.getByPlaceholder('Enter Your Name').fill(currentName);
await page.getByPlaceholder('Enter Your Email').fill(currentEmail);
await expect(page.getByText('Next Recipient Name')).toBeVisible();
const nextRecipientNameInputValue = await page.getByLabel('Next Recipient Name').inputValue();
expect(nextRecipientNameInputValue).toBe(originalName);
const nextRecipientEmailInputValue = await page.getByLabel('Next Recipient Email').inputValue();
expect(nextRecipientEmailInputValue).toBe(originalSecondSignerEmail);
const newName = 'Hello';
const newSecondSignerEmail = seedTestEmail();
await page.getByLabel('Next Recipient Email').fill(newSecondSignerEmail);
await page.getByLabel('Next Recipient Name').fill(newName);
await page.getByRole('button', { name: 'Sign' }).click();
await page.waitForURL(/\/sign/);
await expect(page.getByRole('heading', { name: 'Document Signed' })).toBeVisible();
const createdEnvelopeRecipients = await prisma.recipient.findMany({
where: {
envelope: {
templateId: mapSecondaryIdToTemplateId(template.secondaryId),
},
},
});
const updatedSecondRecipient = createdEnvelopeRecipients.find(
(recipient) => recipient.signingOrder === 2,
);
expect(updatedSecondRecipient?.name).toBe(newName);
expect(updatedSecondRecipient?.email).toBe(newSecondSignerEmail);
});

View File

@ -1,7 +1,5 @@
import { EnvelopeType, TeamMemberRole } from '@prisma/client';
import type { Prisma, User } from '@prisma/client';
import { SigningStatus } from '@prisma/client';
import { DocumentVisibility } from '@prisma/client';
import { DocumentVisibility, EnvelopeType, SigningStatus, TeamMemberRole } from '@prisma/client';
import { DateTime } from 'luxon';
import { match } from 'ts-pattern';
@ -215,13 +213,14 @@ const getTeamCounts = async (options: GetTeamCountsOption) => {
],
};
const rootPageFilter = folderId === undefined ? { folderId: null } : {};
let ownerCountsWhereInput: Prisma.EnvelopeWhereInput = {
type: EnvelopeType.DOCUMENT,
userId: userIdWhereClause,
createdAt,
teamId,
deletedAt: null,
folderId,
};
let notSignedCountsGroupByArgs = null;
@ -265,8 +264,16 @@ const getTeamCounts = async (options: GetTeamCountsOption) => {
ownerCountsWhereInput = {
...ownerCountsWhereInput,
...visibilityFiltersWhereInput,
...searchFilter,
AND: [
...(Array.isArray(visibilityFiltersWhereInput.AND)
? visibilityFiltersWhereInput.AND
: visibilityFiltersWhereInput.AND
? [visibilityFiltersWhereInput.AND]
: []),
searchFilter,
rootPageFilter,
folderId ? { folderId } : {},
],
};
if (teamEmail) {
@ -285,6 +292,7 @@ const getTeamCounts = async (options: GetTeamCountsOption) => {
},
],
deletedAt: null,
AND: [searchFilter, rootPageFilter, folderId ? { folderId } : {}],
};
notSignedCountsGroupByArgs = {
@ -296,7 +304,6 @@ const getTeamCounts = async (options: GetTeamCountsOption) => {
type: EnvelopeType.DOCUMENT,
userId: userIdWhereClause,
createdAt,
folderId,
status: ExtendedDocumentStatus.PENDING,
recipients: {
some: {
@ -306,6 +313,7 @@ const getTeamCounts = async (options: GetTeamCountsOption) => {
},
},
deletedAt: null,
AND: [searchFilter, rootPageFilter, folderId ? { folderId } : {}],
},
} satisfies Prisma.EnvelopeGroupByArgs;
@ -318,7 +326,6 @@ const getTeamCounts = async (options: GetTeamCountsOption) => {
type: EnvelopeType.DOCUMENT,
userId: userIdWhereClause,
createdAt,
folderId,
OR: [
{
status: ExtendedDocumentStatus.PENDING,
@ -342,6 +349,7 @@ const getTeamCounts = async (options: GetTeamCountsOption) => {
},
},
],
AND: [searchFilter, rootPageFilter, folderId ? { folderId } : {}],
},
} satisfies Prisma.EnvelopeGroupByArgs;
}

View File

@ -1,10 +1,11 @@
import { DocumentStatus, EnvelopeType } from '@prisma/client';
import { match } from 'ts-pattern';
import { prisma } from '@documenso/prisma';
import { AppError, AppErrorCode } from '../../errors/app-error';
import type { TDocumentAuthMethods } from '../../types/document-auth';
import { isRecipientAuthorized } from '../document/is-recipient-authorized';
import { DocumentAccessAuth, type TDocumentAuthMethods } from '../../types/document-auth';
import { extractDocumentAuthMethods } from '../../utils/document-auth';
import { getTeamSettings } from '../team/get-team-settings';
import type { EnvelopeForSigningResponse } from './get-envelope-for-recipient-signing';
import { ZEnvelopeForSigningResponse } from './get-envelope-for-recipient-signing';
@ -98,14 +99,28 @@ export const getEnvelopeForDirectTemplateSigning = async ({
});
}
const documentAccessValid = await isRecipientAuthorized({
type: 'ACCESS',
documentAuthOptions: envelope.authOptions,
recipient,
userId,
authOptions: accessAuth,
// Currently not using this since for direct templates "User" access means they just need to be
// logged in.
// const documentAccessValid = await isRecipientAuthorized({
// type: 'ACCESS',
// documentAuthOptions: envelope.authOptions,
// recipient,
// userId,
// authOptions: accessAuth,
// });
const { derivedRecipientAccessAuth } = extractDocumentAuthMethods({
documentAuth: envelope.authOptions,
});
// Ensure typesafety when we add more options.
const documentAccessValid = derivedRecipientAccessAuth.every((auth) =>
match(auth)
.with(DocumentAccessAuth.ACCOUNT, () => Boolean(userId))
.with(DocumentAccessAuth.TWO_FACTOR_AUTH, () => true)
.exhaustive(),
);
if (!documentAccessValid) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'Invalid access values',

View File

@ -54,54 +54,3 @@ export const getEnvelopeRequiredAccessData = async ({ token }: { token: string }
recipientHasAccount: Boolean(recipientUserAccount),
} as const;
};
export const getEnvelopeDirectTemplateRequiredAccessData = async ({ token }: { token: string }) => {
const envelope = await prisma.envelope.findFirst({
where: {
type: EnvelopeType.TEMPLATE,
directLink: {
enabled: true,
token,
},
status: DocumentStatus.DRAFT,
},
include: {
recipients: {
where: {
token,
},
},
directLink: true,
},
});
if (!envelope) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Envelope not found',
});
}
const recipient = envelope.recipients.find(
(r) => r.id === envelope.directLink?.directTemplateRecipientId,
);
if (!recipient) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Recipient not found',
});
}
const recipientUserAccount = await prisma.user.findFirst({
where: {
email: recipient.email.toLowerCase(),
},
select: {
id: true,
},
});
return {
recipientEmail: recipient.email,
recipientHasAccount: Boolean(recipientUserAccount),
} as const;
};

View File

@ -3,6 +3,7 @@ import { createElement } from 'react';
import { msg } from '@lingui/core/macro';
import type { Field, Signature } from '@prisma/client';
import {
DocumentSigningOrder,
DocumentSource,
DocumentStatus,
EnvelopeType,
@ -26,7 +27,7 @@ import type { TSignFieldWithTokenMutationSchema } from '@documenso/trpc/server/f
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 { DOCUMENT_AUDIT_LOG_TYPE, RECIPIENT_DIFF_TYPE } from '../../types/document-audit-logs';
import type { TRecipientActionAuthTypes } from '../../types/document-auth';
import { DocumentAccessAuth, ZRecipientAuthOptionsSchema } from '../../types/document-auth';
import { ZFieldMetaSchema } from '../../types/field-meta';
@ -68,6 +69,10 @@ export type CreateDocumentFromDirectTemplateOptions = {
name?: string;
email: string;
};
nextSigner?: {
email: string;
name: string;
};
};
type CreatedDirectRecipientField = {
@ -92,6 +97,7 @@ export const createDocumentFromDirectTemplate = async ({
directTemplateExternalId,
signedFieldValues,
templateUpdatedAt,
nextSigner,
requestMetadata,
user,
}: CreateDocumentFromDirectTemplateOptions): Promise<TCreateDocumentFromDirectTemplateResponse> => {
@ -128,6 +134,17 @@ export const createDocumentFromDirectTemplate = async ({
throw new AppError(AppErrorCode.INVALID_REQUEST, { message: 'Invalid or missing template' });
}
if (
nextSigner &&
(!directTemplateEnvelope.documentMeta?.allowDictateNextSigner ||
directTemplateEnvelope.documentMeta?.signingOrder !== DocumentSigningOrder.SEQUENTIAL)
) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message:
'You need to enable allowDictateNextSigner and sequential signing to dictate the next signer',
});
}
const directTemplateEnvelopeLegacyId = mapSecondaryIdToTemplateId(
directTemplateEnvelope.secondaryId,
);
@ -630,6 +647,77 @@ export const createDocumentFromDirectTemplate = async ({
}),
];
if (nextSigner) {
const pendingRecipients = await tx.recipient.findMany({
select: {
id: true,
signingOrder: true,
name: true,
email: true,
role: true,
},
where: {
envelopeId: createdEnvelope.id,
signingStatus: {
not: SigningStatus.SIGNED,
},
role: {
not: RecipientRole.CC,
},
},
// Composite sort so our next recipient is always the one with the lowest signing order or id
// if there is a tie.
orderBy: [{ signingOrder: { sort: 'asc', nulls: 'last' } }, { id: 'asc' }],
});
const nextRecipient = pendingRecipients[0];
if (nextRecipient) {
auditLogsToCreate.push(
createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_UPDATED,
envelopeId: createdEnvelope.id,
user: {
name: user?.name || directRecipientName || '',
email: user?.email || directRecipientEmail,
},
metadata: requestMetadata,
data: {
recipientEmail: nextRecipient.email,
recipientName: nextRecipient.name,
recipientId: nextRecipient.id,
recipientRole: nextRecipient.role,
changes: [
{
type: RECIPIENT_DIFF_TYPE.NAME,
from: nextRecipient.name,
to: nextSigner.name,
},
{
type: RECIPIENT_DIFF_TYPE.EMAIL,
from: nextRecipient.email,
to: nextSigner.email,
},
],
},
}),
);
await tx.recipient.update({
where: { id: nextRecipient.id },
data: {
sendStatus: SendStatus.SENT,
...(nextSigner && documentMeta?.allowDictateNextSigner
? {
name: nextSigner.name,
email: nextSigner.email,
}
: {}),
},
});
}
}
await tx.documentAuditLog.createMany({
data: auditLogsToCreate,
});

View File

@ -28,6 +28,7 @@ type SeedTemplateOptions = {
title?: string;
userId: number;
teamId: number;
internalVersion?: 1 | 2;
createTemplateOptions?: Partial<Prisma.EnvelopeUncheckedCreateInput>;
};
@ -167,7 +168,7 @@ export const seedDirectTemplate = async (options: SeedTemplateOptions) => {
data: {
id: prefixedId('envelope'),
secondaryId: templateId.formattedTemplateId,
internalVersion: 1,
internalVersion: options.internalVersion ?? 1,
type: EnvelopeType.TEMPLATE,
title,
envelopeItems: {
@ -184,6 +185,7 @@ export const seedDirectTemplate = async (options: SeedTemplateOptions) => {
teamId,
recipients: {
create: {
signingOrder: 1,
email: DIRECT_TEMPLATE_RECIPIENT_EMAIL,
name: DIRECT_TEMPLATE_RECIPIENT_NAME,
token: Math.random().toString().slice(2, 7),

View File

@ -39,6 +39,11 @@ export const getAdminOrganisation = async ({ organisationId }: GetOrganisationOp
teams: true,
members: {
include: {
organisationGroupMembers: {
include: {
group: true,
},
},
user: {
select: {
id: true,

View File

@ -3,6 +3,8 @@ import { z } from 'zod';
import { ZOrganisationSchema } from '@documenso/lib/types/organisation';
import OrganisationClaimSchema from '@documenso/prisma/generated/zod/modelSchema/OrganisationClaimSchema';
import OrganisationGlobalSettingsSchema from '@documenso/prisma/generated/zod/modelSchema/OrganisationGlobalSettingsSchema';
import OrganisationGroupMemberSchema from '@documenso/prisma/generated/zod/modelSchema/OrganisationGroupMemberSchema';
import OrganisationGroupSchema from '@documenso/prisma/generated/zod/modelSchema/OrganisationGroupSchema';
import OrganisationMemberSchema from '@documenso/prisma/generated/zod/modelSchema/OrganisationMemberSchema';
import SubscriptionSchema from '@documenso/prisma/generated/zod/modelSchema/SubscriptionSchema';
import TeamSchema from '@documenso/prisma/generated/zod/modelSchema/TeamSchema';
@ -30,6 +32,18 @@ export const ZGetAdminOrganisationResponseSchema = ZOrganisationSchema.extend({
email: true,
name: true,
}),
organisationGroupMembers: z.array(
OrganisationGroupMemberSchema.pick({
id: true,
groupId: true,
}).extend({
group: OrganisationGroupSchema.pick({
id: true,
type: true,
organisationRole: true,
}),
}),
),
}).array(),
subscription: SubscriptionSchema.nullable(),
organisationClaim: OrganisationClaimSchema,

View File

@ -17,6 +17,7 @@ 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';
import { updateOrganisationMemberRoleRoute } from './update-organisation-member-role';
import { updateRecipientRoute } from './update-recipient';
import { updateSiteSettingRoute } from './update-site-setting';
import { updateSubscriptionClaimRoute } from './update-subscription-claim';
@ -31,6 +32,7 @@ export const adminRouter = router({
},
organisationMember: {
promoteToOwner: promoteMemberToOwnerRoute,
updateRole: updateOrganisationMemberRoleRoute,
},
claims: {
find: findSubscriptionClaimsRoute,

View File

@ -0,0 +1,220 @@
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 {
ZUpdateOrganisationMemberRoleRequestSchema,
ZUpdateOrganisationMemberRoleResponseSchema,
} from './update-organisation-member-role.types';
/**
* Admin mutation to update organisation member role or transfer ownership.
*
* This mutation handles two scenarios:
* 1. When role='OWNER': Transfers organisation ownership and promotes to ADMIN
* 2. When role=ADMIN/MANAGER/MEMBER: Updates group membership
*
* Admin privileges bypass normal hierarchy restrictions.
*/
export const updateOrganisationMemberRoleRoute = adminProcedure
.input(ZUpdateOrganisationMemberRoleRequestSchema)
.output(ZUpdateOrganisationMemberRoleResponseSchema)
.mutation(async ({ input, ctx }) => {
const { organisationId, userId, role } = input;
ctx.logger.info({
input: {
organisationId,
userId,
role,
},
});
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',
});
}
const [member] = organisation.members;
if (!member) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'User is not a member of this organisation',
});
}
const currentOrganisationRole = getHighestOrganisationRoleInGroup(
member.organisationGroupMembers.flatMap((member) => member.group),
);
if (role === 'OWNER') {
if (organisation.ownerUserId === userId) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'User is already the owner of this organisation',
});
}
const currentMemberGroup = organisation.groups.find(
(group) => group.organisationRole === currentOrganisationRole,
);
const adminGroup = organisation.groups.find(
(group) => group.organisationRole === OrganisationMemberRole.ADMIN,
);
if (!currentMemberGroup) {
ctx.logger.error({
message: '[CRITICAL]: Missing internal group',
organisationId,
userId,
role: currentOrganisationRole,
});
throw new AppError(AppErrorCode.UNKNOWN_ERROR, {
message: 'Current member group not found',
});
}
if (!adminGroup) {
ctx.logger.error({
message: '[CRITICAL]: Missing internal group',
organisationId,
userId,
targetRole: 'ADMIN',
});
throw new AppError(AppErrorCode.UNKNOWN_ERROR, {
message: 'Admin group not found',
});
}
await prisma.$transaction(async (tx) => {
await tx.organisation.update({
where: {
id: organisationId,
},
data: {
ownerUserId: userId,
},
});
if (currentOrganisationRole !== OrganisationMemberRole.ADMIN) {
await tx.organisationGroupMember.delete({
where: {
organisationMemberId_groupId: {
organisationMemberId: member.id,
groupId: currentMemberGroup.id,
},
},
});
await tx.organisationGroupMember.create({
data: {
id: generateDatabaseId('group_member'),
organisationMemberId: member.id,
groupId: adminGroup.id,
},
});
}
});
return;
}
const targetRole = role as OrganisationMemberRole;
if (currentOrganisationRole === targetRole) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'User already has this role',
});
}
if (userId === organisation.ownerUserId && targetRole !== OrganisationMemberRole.ADMIN) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Organisation owner must be an admin. Transfer ownership first.',
});
}
const currentMemberGroup = organisation.groups.find(
(group) => group.organisationRole === currentOrganisationRole,
);
const newMemberGroup = organisation.groups.find(
(group) => group.organisationRole === targetRole,
);
if (!currentMemberGroup) {
ctx.logger.error({
message: '[CRITICAL]: Missing internal group',
organisationId,
userId,
role: currentOrganisationRole,
});
throw new AppError(AppErrorCode.UNKNOWN_ERROR, {
message: 'Current member group not found',
});
}
if (!newMemberGroup) {
ctx.logger.error({
message: '[CRITICAL]: Missing internal group',
organisationId,
userId,
targetRole,
});
throw new AppError(AppErrorCode.UNKNOWN_ERROR, {
message: 'New member group not found',
});
}
await prisma.$transaction(async (tx) => {
await tx.organisationGroupMember.delete({
where: {
organisationMemberId_groupId: {
organisationMemberId: member.id,
groupId: currentMemberGroup.id,
},
},
});
await tx.organisationGroupMember.create({
data: {
id: generateDatabaseId('group_member'),
organisationMemberId: member.id,
groupId: newMemberGroup.id,
},
});
});
});

View File

@ -0,0 +1,30 @@
import { OrganisationMemberRole } from '@prisma/client';
import { z } from 'zod';
/**
* Admin-only role selection that includes OWNER as a special case.
* OWNER is not a database role but triggers ownership transfer.
*/
export const ZAdminRoleSelection = z.enum([
'OWNER',
OrganisationMemberRole.ADMIN,
OrganisationMemberRole.MANAGER,
OrganisationMemberRole.MEMBER,
]);
export type TAdminRoleSelection = z.infer<typeof ZAdminRoleSelection>;
export const ZUpdateOrganisationMemberRoleRequestSchema = z.object({
organisationId: z.string().min(1),
userId: z.number().min(1),
role: ZAdminRoleSelection,
});
export const ZUpdateOrganisationMemberRoleResponseSchema = z.void();
export type TUpdateOrganisationMemberRoleRequest = z.infer<
typeof ZUpdateOrganisationMemberRoleRequestSchema
>;
export type TUpdateOrganisationMemberRoleResponse = z.infer<
typeof ZUpdateOrganisationMemberRoleResponseSchema
>;

View File

@ -519,6 +519,7 @@ export const templateRouter = router({
directTemplateExternalId,
signedFieldValues,
templateUpdatedAt,
nextSigner,
} = input;
ctx.logger.info({
@ -541,6 +542,7 @@ export const templateRouter = router({
email: ctx.user.email,
}
: undefined,
nextSigner,
requestMetadata: ctx.metadata,
});
}),

View File

@ -90,6 +90,12 @@ export const ZCreateDocumentFromDirectTemplateRequestSchema = z.object({
directTemplateExternalId: z.string().optional(),
signedFieldValues: z.array(ZSignFieldWithTokenMutationSchema),
templateUpdatedAt: z.date(),
nextSigner: z
.object({
email: z.string().email().max(254),
name: z.string().min(1).max(255),
})
.optional(),
});
export const ZCreateDocumentFromTemplateRequestSchema = z.object({