Merge branch 'main' into fix/cc-recipient-order-last

This commit is contained in:
Lucas Smith
2026-06-22 14:51:11 +10:00
committed by GitHub
95 changed files with 2109 additions and 944 deletions
@@ -3,6 +3,7 @@ import path from 'node:path';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { createApiToken } from '@documenso/lib/server-only/public-api/create-api-token';
import { prisma } from '@documenso/prisma';
import { seedDraftDocument } from '@documenso/prisma/seed/documents';
import { seedTemplate } from '@documenso/prisma/seed/templates';
import { seedUser } from '@documenso/prisma/seed/users';
import type {
@@ -302,6 +303,95 @@ test.describe('document editor', () => {
expect(envelopes.length).toBeGreaterThanOrEqual(2);
});
test('duplicate document without recipients excludes recipients and fields', async ({ page }) => {
const { user, team } = await seedUser();
// Seed a draft document that has a recipient with a field.
const document = await seedDraftDocument(user, team.id, ['signer@test.documenso.com'], {
key: `dup-exclude-recipients-${Date.now()}`,
internalVersion: 2,
});
await apiSignin({
page,
email: user.email,
redirectPath: `/t/${team.url}/documents/${document.id}/edit`,
});
await expect(page.getByRole('heading', { name: 'Documents' })).toBeVisible();
// Open the duplicate dialog.
await page.locator('button[title="Duplicate Envelope"]').click();
await expect(page.getByRole('heading', { name: 'Duplicate Document' })).toBeVisible();
// Uncheck "Include Recipients" — this also disables and unchecks "Include Fields".
await page.getByLabel('Include Recipients').click();
await expect(page.getByLabel('Include Fields')).toBeDisabled();
// Duplicate.
await page.getByRole('button', { name: 'Duplicate' }).click();
await expectToastTextToBeVisible(page, 'Document Duplicated');
await expect(page).toHaveURL(/\/documents\/.*\/edit/);
// The duplicate should have neither recipients nor fields.
const duplicate = await prisma.envelope.findFirstOrThrow({
where: {
teamId: team.id,
type: EnvelopeType.DOCUMENT,
id: { not: document.id },
},
include: { recipients: true, fields: true },
orderBy: { createdAt: 'desc' },
});
expect(duplicate.recipients).toHaveLength(0);
expect(duplicate.fields).toHaveLength(0);
});
test('duplicate document without fields keeps recipients but excludes fields', async ({ page }) => {
const { user, team } = await seedUser();
// Seed a draft document that has a recipient with a field.
const document = await seedDraftDocument(user, team.id, ['signer@test.documenso.com'], {
key: `dup-exclude-fields-${Date.now()}`,
internalVersion: 2,
});
await apiSignin({
page,
email: user.email,
redirectPath: `/t/${team.url}/documents/${document.id}/edit`,
});
await expect(page.getByRole('heading', { name: 'Documents' })).toBeVisible();
// Open the duplicate dialog.
await page.locator('button[title="Duplicate Envelope"]').click();
await expect(page.getByRole('heading', { name: 'Duplicate Document' })).toBeVisible();
// Uncheck only "Include Fields" (recipients stay included).
await page.getByLabel('Include Fields').click();
// Duplicate.
await page.getByRole('button', { name: 'Duplicate' }).click();
await expectToastTextToBeVisible(page, 'Document Duplicated');
await expect(page).toHaveURL(/\/documents\/.*\/edit/);
// The duplicate should keep the recipient but have no fields.
const duplicate = await prisma.envelope.findFirstOrThrow({
where: {
teamId: team.id,
type: EnvelopeType.DOCUMENT,
id: { not: document.id },
},
include: { recipients: true, fields: true },
orderBy: { createdAt: 'desc' },
});
expect(duplicate.recipients).toHaveLength(1);
expect(duplicate.fields).toHaveLength(0);
});
test('download PDF dialog shows envelope items', async ({ page }) => {
await openDocumentEnvelopeEditor(page);
@@ -270,7 +270,7 @@ test('[ENVELOPE_EXPIRATION]: resending refreshes expiresAt', async ({ page }) =>
await page.getByLabel('test.documenso.com').first().click();
await page.getByRole('button', { name: 'Send reminder' }).click();
await expect(page.getByText('Document re-sent', { exact: true })).toBeVisible({
await expect(page.getByText('Document resent', { exact: true })).toBeVisible({
timeout: 10_000,
});
@@ -238,7 +238,7 @@ test('[TEAMS]: resend pending team document', async ({ page }) => {
await page.getByLabel('test.documenso.com').first().click();
await page.getByRole('button', { name: 'Send reminder' }).click();
await expectToastTextToBeVisible(page, 'Document re-sent');
await expectToastTextToBeVisible(page, 'Document resent');
});
test('[TEAMS]: delete draft team document', async ({ page }) => {
@@ -0,0 +1,105 @@
import { seedOrganisationMembers } from '@documenso/prisma/seed/organisations';
import { seedTeamMember } from '@documenso/prisma/seed/teams';
import { seedUser } from '@documenso/prisma/seed/users';
import { expect, test } from '@playwright/test';
import { OrganisationMemberRole, TeamMemberRole } from '@prisma/client';
import { apiSignin } from '../fixtures/authentication';
import { openDropdownMenu } from '../fixtures/generic';
/**
* Reproduces the "Team has no internal team groups" bug.
*
* When a team has member inheritance turned OFF, organisation admins/managers are
* still inherited into the team as team admins (shown with the "Group" source).
* These members are not part of the team's INTERNAL_TEAM group, so they cannot be
* removed via the team members page - attempting to do so threw a 500 ("Team has no
* internal team groups").
*
* Instead of crashing, the delete dialog must explain why the inherited member can't
* be removed and not offer a confirm button.
*/
test('[TEAMS]: explains why an inherited organisation member cannot be removed', async ({ page }) => {
// Team created with member inheritance OFF.
const { user: owner, organisation, team } = await seedUser({ inheritMembers: false });
const inheritedAdminEmail = `inherited-admin-${team.url}@test.documenso.com`;
// A second organisation admin is inherited into the team as a team admin (source "Group").
await seedOrganisationMembers({
organisationId: organisation.id,
members: [
{
name: 'Inherited Admin',
email: inheritedAdminEmail,
organisationRole: OrganisationMemberRole.ADMIN,
},
],
});
await apiSignin({
page,
email: owner.email,
redirectPath: `/t/${team.url}/settings/members`,
});
const inheritedMemberRow = page.getByRole('row').filter({ hasText: inheritedAdminEmail });
// Sanity check: the member is inherited from a group, not a direct team member.
await expect(inheritedMemberRow).toBeVisible();
await expect(inheritedMemberRow).toContainText('Group');
await openDropdownMenu(page, inheritedMemberRow.getByRole('button').last());
// The action stays enabled - opening it shows a dialog explaining why the inherited
// member can't be removed, rather than triggering the 500.
const removeMenuItem = page.getByRole('menuitem', { name: 'Remove' });
await expect(removeMenuItem).toBeEnabled();
await removeMenuItem.click();
await expect(page.getByText('inherited from a group').first()).toBeVisible();
// No confirm button is offered, so the broken removal can never be triggered.
await expect(page.getByRole('button', { name: 'Remove' })).toHaveCount(0);
});
/**
* Guards against over-disabling the remove action: a direct team member (one that
* belongs to the team's INTERNAL_TEAM group) must still be removable.
*/
test('[TEAMS]: can remove a direct team member', async ({ page }) => {
const { user: owner, team } = await seedUser({ inheritMembers: false });
const directMember = await seedTeamMember({
teamId: team.id,
name: 'Direct Member',
role: TeamMemberRole.MEMBER,
});
await apiSignin({
page,
email: owner.email,
redirectPath: `/t/${team.url}/settings/members`,
});
const directMemberRow = page.getByRole('row').filter({ hasText: directMember.email });
await expect(directMemberRow).toBeVisible();
await openDropdownMenu(page, directMemberRow.getByRole('button').last());
const removeMenuItem = page.getByRole('menuitem', { name: 'Remove' });
// The "Remove" action is enabled for direct members and removing them succeeds.
await expect(removeMenuItem).toBeEnabled();
await removeMenuItem.click();
await page.getByRole('button', { name: 'Remove' }).click();
await expect(page.getByText('You have successfully removed this user from the team.').first()).toBeVisible();
// The member is actually gone after reloading the members list.
await page.reload();
await expect(page.getByRole('row').filter({ hasText: owner.email })).toBeVisible();
await expect(page.getByRole('row').filter({ hasText: directMember.email })).toHaveCount(0);
});