Merge remote-tracking branch 'origin/main' into pr-2711

# Conflicts:
#	apps/remix/app/components/tables/envelopes-table-bulk-action-bar.tsx
#	apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents._index.tsx
#	packages/app-tests/e2e/documents/bulk-document-actions.spec.ts
This commit is contained in:
ephraimduncan
2026-07-02 06:48:23 +00:00
585 changed files with 38080 additions and 6339 deletions
@@ -1,9 +1,12 @@
import { seedDraftDocument } from '@documenso/prisma/seed/documents';
import { prisma } from '@documenso/prisma';
import { seedCompletedDocument, seedDraftDocument, seedPendingDocument } from '@documenso/prisma/seed/documents';
import { seedBlankFolder } from '@documenso/prisma/seed/folders';
import { seedTeam, seedTeamMember } from '@documenso/prisma/seed/teams';
import { seedUser } from '@documenso/prisma/seed/users';
import { type Download, expect, test } from '@playwright/test';
import { DocumentStatus, TeamMemberRole } from '@prisma/client';
import { apiSignin } from '../fixtures/authentication';
import { apiSignin, apiSignout } from '../fixtures/authentication';
import { expectToastTextToBeVisible } from '../fixtures/generic';
test.describe.configure({ mode: 'parallel' });
@@ -287,3 +290,147 @@ test('[BULK_ACTIONS]: can move documents from folder to home (root)', async ({ p
await page.goto(`/t/${sender.team.url}/documents/f/${folder.id}`);
await expect(page.getByRole('link', { name: 'Bulk Test Doc 1' })).not.toBeVisible();
});
// ─── Bulk cancel ─────────────────────────────────────────────────────────────
test('[BULK_ACTIONS]: can cancel multiple pending documents', async ({ page }) => {
const sender = await seedUser({ setTeamEmailAsOwner: true });
const { user: recipient } = await seedUser();
const [pending1, pending2] = await Promise.all([
seedPendingDocument(sender.user, sender.team.id, [recipient], {
createDocumentOptions: { title: 'Bulk Cancel Pending 1' },
}),
seedPendingDocument(sender.user, sender.team.id, [recipient], {
createDocumentOptions: { title: 'Bulk Cancel Pending 2' },
}),
]);
await apiSignin({
page,
email: sender.user.email,
redirectPath: `/t/${sender.team.url}/documents`,
});
await page.locator('tr', { hasText: 'Bulk Cancel Pending 1' }).getByRole('checkbox').click();
await page.locator('tr', { hasText: 'Bulk Cancel Pending 2' }).getByRole('checkbox').click();
await expect(page.getByText('2 selected')).toBeVisible();
// The bulk action bar Cancel button (distinct from the dialog's confirm button).
await page.getByRole('button', { name: 'Cancel', exact: true }).click();
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible();
await expect(dialog.getByRole('heading', { name: 'Cancel Documents' })).toBeVisible();
await expect(dialog.getByText('You are about to cancel 2 documents')).toBeVisible();
await dialog.getByRole('button', { name: 'Cancel documents' }).click();
await expectToastTextToBeVisible(page, 'Documents cancelled');
// Selection clears after a successful cancel.
await expect(page.getByText(/\d+ selected/)).not.toBeVisible();
// Both documents are now cancelled in the database.
for (const document of [pending1, pending2]) {
const envelope = await prisma.envelope.findFirstOrThrow({
where: { id: document.id },
select: { status: true, deletedAt: true },
});
expect(envelope.status).toBe(DocumentStatus.CANCELLED);
expect(envelope.deletedAt).toBeNull();
}
});
test('[BULK_ACTIONS]: bulk cancel only affects pending documents', async ({ page }) => {
const sender = await seedUser({ setTeamEmailAsOwner: true });
const { user: recipient } = await seedUser();
const pending = await seedPendingDocument(sender.user, sender.team.id, [recipient], {
createDocumentOptions: { title: 'Mixed Cancel Pending' },
});
const draft = await seedDraftDocument(sender.user, sender.team.id, [], {
createDocumentOptions: { title: 'Mixed Cancel Draft' },
});
const completed = await seedCompletedDocument(sender.user, sender.team.id, [recipient], {
createDocumentOptions: { title: 'Mixed Cancel Completed' },
});
await apiSignin({
page,
email: sender.user.email,
redirectPath: `/t/${sender.team.url}/documents`,
});
await page.locator('thead').getByRole('checkbox').click();
await expect(page.getByText('3 selected')).toBeVisible();
await page.getByRole('button', { name: 'Cancel', exact: true }).click();
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible();
await dialog.getByRole('button', { name: 'Cancel documents' }).click();
// Only one of the three was pending, so this is a partial result.
await expectToastTextToBeVisible(page, 'Documents partially cancelled');
const pendingEnvelope = await prisma.envelope.findFirstOrThrow({
where: { id: pending.id },
select: { status: true },
});
expect(pendingEnvelope.status).toBe(DocumentStatus.CANCELLED);
// The draft and completed documents are untouched.
const draftEnvelope = await prisma.envelope.findFirstOrThrow({
where: { id: draft.id },
select: { status: true },
});
expect(draftEnvelope.status).toBe(DocumentStatus.DRAFT);
const completedEnvelope = await prisma.envelope.findFirstOrThrow({
where: { id: completed.id },
select: { status: true },
});
expect(completedEnvelope.status).toBe(DocumentStatus.COMPLETED);
});
test('[BULK_ACTIONS]: a MEMBER cannot bulk cancel documents they do not own', async ({ page }) => {
const { team, owner } = await seedTeam();
const memberUser = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.MEMBER });
const { user: recipient } = await seedUser();
const ownerDocument = await seedPendingDocument(owner, team.id, [recipient], {
createDocumentOptions: { title: 'Member Cannot Cancel This', visibility: 'EVERYONE' },
});
await apiSignin({
page,
email: memberUser.email,
redirectPath: `/t/${team.url}/documents?status=PENDING`,
});
await page.locator('tr', { hasText: 'Member Cannot Cancel This' }).getByRole('checkbox').click();
await expect(page.getByText('1 selected')).toBeVisible();
await page.getByRole('button', { name: 'Cancel', exact: true }).click();
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible();
await dialog.getByRole('button', { name: 'Cancel documents' }).click();
// The server rejects the cancellation for a document the MEMBER does not own,
// so it reports zero cancelled (a partial result with the document in failedIds).
await expectToastTextToBeVisible(page, 'Documents partially cancelled');
// The document remains pending.
const envelope = await prisma.envelope.findFirstOrThrow({
where: { id: ownerDocument.id },
select: { status: true },
});
expect(envelope.status).toBe(DocumentStatus.PENDING);
await apiSignout({ page });
});
@@ -0,0 +1,342 @@
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { prisma } from '@documenso/prisma';
import { seedCancelledDocument, seedPendingDocument } from '@documenso/prisma/seed/documents';
import { seedTeam, seedTeamMember } from '@documenso/prisma/seed/teams';
import { seedUser } from '@documenso/prisma/seed/users';
import { expect, type Page, test } from '@playwright/test';
import { DocumentStatus, TeamMemberRole } from '@prisma/client';
import { apiSignin, apiSignout } from '../fixtures/authentication';
import { checkDocumentTabCount } from '../fixtures/documents';
import { expectToastTextToBeVisible, openDropdownMenu } from '../fixtures/generic';
test.describe.configure({ mode: 'serial' });
const seedCancelDocumentsTestRequirements = async () => {
const [sender, recipientA, recipientB] = await Promise.all([
seedUser({ setTeamEmailAsOwner: true }),
seedUser({ setTeamEmailAsOwner: true }),
seedUser({ setTeamEmailAsOwner: true }),
]);
const pendingDocument = await seedPendingDocument(sender.user, sender.team.id, [recipientA.user, recipientB.user], {
createDocumentOptions: { title: 'Document 1 - Pending' },
});
return {
sender,
recipients: [recipientA, recipientB],
pendingDocument,
};
};
const cancelDocumentViaUi = async (page: Page, documentTitle: string, reason?: string) => {
const documentActionBtn = page.locator('tr', { hasText: documentTitle }).getByTestId('document-table-action-btn');
await openDropdownMenu(page, documentActionBtn);
await expect(page.getByRole('menuitem', { name: 'Cancel' })).toBeVisible();
await page.getByRole('menuitem', { name: 'Cancel' }).click();
await expect(page.getByRole('heading', { name: 'Are you sure?' })).toBeVisible();
if (reason) {
await page.getByPlaceholder('Add an optional reason for cancelling this document').fill(reason);
}
await page.getByRole('button', { name: 'Cancel document' }).click();
};
test('[DOCUMENTS]: cancelling a pending document keeps it in the owner dashboard as cancelled', async ({ page }) => {
const { sender, pendingDocument } = await seedCancelDocumentsTestRequirements();
await apiSignin({
page,
email: sender.user.email,
redirectPath: `/t/${sender.team.url}/documents`,
});
await cancelDocumentViaUi(page, 'Document 1 - Pending', 'No longer required');
await expectToastTextToBeVisible(page, 'Document cancelled');
// The document must remain in the dashboard, unlike deleting a pending document.
await checkDocumentTabCount(page, 'Inbox', 0);
await checkDocumentTabCount(page, 'Pending', 0);
await checkDocumentTabCount(page, 'Cancelled', 1);
await checkDocumentTabCount(page, 'All', 1);
// The cancelled document is still listed.
await page.getByRole('tab', { name: 'Cancelled' }).click();
await expect(page.getByRole('link', { name: 'Document 1 - Pending' })).toBeVisible();
// The envelope status is persisted as CANCELLED.
const envelope = await prisma.envelope.findFirstOrThrow({
where: {
id: pendingDocument.id,
},
select: {
status: true,
completedAt: true,
deletedAt: true,
},
});
expect(envelope.status).toBe(DocumentStatus.CANCELLED);
expect(envelope.completedAt).not.toBeNull();
expect(envelope.deletedAt).toBeNull();
});
test('[DOCUMENTS]: cancelling a pending document retains it for recipients', async ({ page }) => {
const { sender, recipients } = await seedCancelDocumentsTestRequirements();
await apiSignin({
page,
email: sender.user.email,
redirectPath: `/t/${sender.team.url}/documents`,
});
await cancelDocumentViaUi(page, 'Document 1 - Pending');
await expectToastTextToBeVisible(page, 'Document cancelled');
await apiSignout({ page });
// Recipients should still be able to see the document as a record of distribution.
for (const recipient of recipients) {
await apiSignin({
page,
email: recipient.user.email,
redirectPath: `/t/${recipient.team.url}/documents`,
});
await expect(page.getByRole('link', { name: 'Document 1 - Pending' })).toBeVisible();
await apiSignout({ page });
}
});
test('[DOCUMENTS]: a cancelled document can be deleted, hiding it from the owner without removing it', async ({
page,
}) => {
const { sender, recipients, pendingDocument } = await seedCancelDocumentsTestRequirements();
await apiSignin({
page,
email: sender.user.email,
redirectPath: `/t/${sender.team.url}/documents`,
});
await cancelDocumentViaUi(page, 'Document 1 - Pending');
await expectToastTextToBeVisible(page, 'Document cancelled');
// Delete the now-cancelled document. Being terminal, it should soft delete (hide).
await page.getByRole('tab', { name: 'Cancelled' }).click();
const documentActionBtn = page
.locator('tr', { hasText: 'Document 1 - Pending' })
.getByTestId('document-table-action-btn');
await openDropdownMenu(page, documentActionBtn);
await expect(page.getByRole('menuitem', { name: 'Delete' })).toBeVisible();
await page.getByRole('menuitem', { name: 'Delete' }).click();
await page.getByPlaceholder("Type 'delete' to confirm").fill('delete');
await page.getByRole('button', { name: 'Delete' }).click();
await page.waitForTimeout(2500);
await expect(page.getByRole('row', { name: /Document 1 - Pending/ })).not.toBeVisible();
// The envelope is soft deleted, not hard deleted.
const envelope = await prisma.envelope.findFirstOrThrow({
where: {
id: pendingDocument.id,
},
select: {
status: true,
deletedAt: true,
},
});
expect(envelope.status).toBe(DocumentStatus.CANCELLED);
expect(envelope.deletedAt).not.toBeNull();
await apiSignout({ page });
// Recipients should still retain the document after the owner deletes it.
await apiSignin({
page,
email: recipients[0].user.email,
redirectPath: `/t/${recipients[0].team.url}/documents`,
});
await expect(page.getByRole('link', { name: 'Document 1 - Pending' })).toBeVisible();
});
// ─── Visibility: a cancelled document must respect team document visibility ───
test('[DOCUMENTS]: cancelled document with ADMIN visibility is hidden from a MEMBER', async ({ page }) => {
const { team, owner } = await seedTeam();
const adminUser = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.ADMIN });
const managerUser = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.MANAGER });
const memberUser = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.MEMBER });
await seedCancelledDocument(owner, team.id, [], {
createDocumentOptions: {
visibility: 'ADMIN',
title: 'Cancelled Admin Only Document',
},
});
// The MEMBER must NOT see the ADMIN-visibility cancelled document on any tab.
await apiSignin({
page,
email: memberUser.email,
redirectPath: `/t/${team.url}/documents?status=CANCELLED`,
});
await expect(page.getByRole('link', { name: 'Cancelled Admin Only Document', exact: true })).not.toBeVisible();
// Also confirm it doesn't leak via the ALL tab.
await page.goto(`${NEXT_PUBLIC_WEBAPP_URL()}/t/${team.url}/documents`);
await expect(page.getByRole('link', { name: 'Cancelled Admin Only Document', exact: true })).not.toBeVisible();
await apiSignout({ page });
// The MANAGER must NOT see an ADMIN-visibility document either.
await apiSignin({
page,
email: managerUser.email,
redirectPath: `/t/${team.url}/documents?status=CANCELLED`,
});
await expect(page.getByRole('link', { name: 'Cancelled Admin Only Document', exact: true })).not.toBeVisible();
await apiSignout({ page });
// The ADMIN must see it.
await apiSignin({
page,
email: adminUser.email,
redirectPath: `/t/${team.url}/documents?status=CANCELLED`,
});
await expect(page.getByRole('link', { name: 'Cancelled Admin Only Document', exact: true })).toBeVisible();
});
test('[DOCUMENTS]: cancelled document with MANAGER_AND_ABOVE visibility is hidden from a MEMBER', async ({ page }) => {
const { team, owner } = await seedTeam();
const managerUser = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.MANAGER });
const memberUser = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.MEMBER });
await seedCancelledDocument(owner, team.id, [], {
createDocumentOptions: {
visibility: 'MANAGER_AND_ABOVE',
title: 'Cancelled Manager Document',
},
});
// The MEMBER must NOT see it.
await apiSignin({
page,
email: memberUser.email,
redirectPath: `/t/${team.url}/documents?status=CANCELLED`,
});
await expect(page.getByRole('link', { name: 'Cancelled Manager Document', exact: true })).not.toBeVisible();
await apiSignout({ page });
// The MANAGER must see it.
await apiSignin({
page,
email: managerUser.email,
redirectPath: `/t/${team.url}/documents?status=CANCELLED`,
});
await expect(page.getByRole('link', { name: 'Cancelled Manager Document', exact: true })).toBeVisible();
});
test('[DOCUMENTS]: a recipient sees a cancelled document regardless of restricted visibility', async ({ page }) => {
const { team, owner } = await seedTeam();
// A MEMBER who is also a recipient on an ADMIN-visibility document.
const memberRecipient = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.MEMBER });
await seedCancelledDocument(owner, team.id, [memberRecipient], {
createDocumentOptions: {
visibility: 'ADMIN',
title: 'Cancelled Admin Doc With Recipient',
},
});
// Even though the document is ADMIN-only, the MEMBER is a recipient, so they
// must still see it (proof of distribution), matching completed-document behaviour.
await apiSignin({
page,
email: memberRecipient.email,
redirectPath: `/t/${team.url}/documents?status=CANCELLED`,
});
await expect(page.getByRole('link', { name: 'Cancelled Admin Doc With Recipient', exact: true })).toBeVisible();
});
// ─── UI gating: only privileged members see the Cancel action ────────────────
test('[DOCUMENTS]: a MEMBER does not see the Cancel action on a pending document', async ({ page }) => {
const { team, owner } = await seedTeam();
const memberUser = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.MEMBER });
const { user: recipient } = await seedUser();
await seedPendingDocument(owner, team.id, [recipient], {
createDocumentOptions: { title: 'Member Gating Pending Document', visibility: 'EVERYONE' },
});
await apiSignin({
page,
email: memberUser.email,
redirectPath: `/t/${team.url}/documents?status=PENDING`,
});
const documentActionBtn = page
.locator('tr', { hasText: 'Member Gating Pending Document' })
.getByTestId('document-table-action-btn');
await openDropdownMenu(page, documentActionBtn);
// The dropdown must render (Edit is always there) but Cancel must be absent.
await expect(page.getByRole('menuitem', { name: 'Edit' })).toBeVisible();
await expect(page.getByRole('menuitem', { name: 'Cancel' })).not.toBeVisible();
});
test('[DOCUMENTS]: a team ADMIN sees and can use the Cancel action on a document they do not own', async ({ page }) => {
const { team, owner } = await seedTeam();
const adminUser = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.ADMIN });
const { user: recipient } = await seedUser();
const document = await seedPendingDocument(owner, team.id, [recipient], {
createDocumentOptions: { title: 'Admin Cancellable Document', visibility: 'EVERYONE' },
});
await apiSignin({
page,
email: adminUser.email,
redirectPath: `/t/${team.url}/documents?status=PENDING`,
});
await cancelDocumentViaUi(page, 'Admin Cancellable Document');
await expectToastTextToBeVisible(page, 'Document cancelled');
const envelope = await prisma.envelope.findFirstOrThrow({
where: { id: document.id },
select: { status: true },
});
expect(envelope.status).toBe(DocumentStatus.CANCELLED);
});
@@ -0,0 +1,89 @@
import { cancelDocument } from '@documenso/lib/server-only/document/cancel-document';
import { deleteDocument } from '@documenso/lib/server-only/document/delete-document';
import { getEnvelopeWhereInput } from '@documenso/lib/server-only/envelope/get-envelope-by-id';
import { prisma } from '@documenso/prisma';
import { DocumentStatus, DocumentVisibility, TeamMemberRole } from '@documenso/prisma/client';
import { seedBlankDocument } from '@documenso/prisma/seed/documents';
import { seedTeamMember } from '@documenso/prisma/seed/teams';
import { seedUser } from '@documenso/prisma/seed/users';
import { expect, test } from '@playwright/test';
const requestMetadata = {
auth: null,
requestMetadata: {},
source: 'app' as const,
};
test.describe.configure({ mode: 'parallel' });
const canReadEnvelope = async (envelopeId: string, userId: number, teamId: number) => {
try {
await getEnvelopeWhereInput({
id: { type: 'envelopeId', id: envelopeId },
userId,
teamId,
type: null,
}).then(({ envelopeWhereInput }) => prisma.envelope.findFirstOrThrow({ where: envelopeWhereInput }));
return true;
} catch {
return false;
}
};
test('[DOCUMENTS]: a member cannot delete a document with restricted visibility', async () => {
const { user: owner, team } = await seedUser();
const member = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.MEMBER });
const envelope = await seedBlankDocument(owner, team.id, {
createDocumentOptions: {
visibility: DocumentVisibility.ADMIN,
status: DocumentStatus.DRAFT,
},
});
// The member cannot read an ADMIN-visibility document, so they must not be
// able to delete it either.
expect(await canReadEnvelope(envelope.id, member.id, team.id)).toBe(false);
await expect(
deleteDocument({
id: { type: 'envelopeId', id: envelope.id },
userId: member.id,
teamId: team.id,
requestMetadata,
}),
).rejects.toThrow();
const stillExists = await prisma.envelope.findUnique({ where: { id: envelope.id } });
expect(stillExists).not.toBeNull();
});
test('[DOCUMENTS]: a manager cannot cancel a document with restricted visibility', async () => {
const { user: owner, team } = await seedUser();
const manager = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.MANAGER });
const envelope = await seedBlankDocument(owner, team.id, {
createDocumentOptions: {
visibility: DocumentVisibility.ADMIN,
status: DocumentStatus.PENDING,
},
});
// A manager outranks a member but still cannot read an ADMIN-visibility
// document, so cancellation must be blocked despite the sufficient role.
expect(await canReadEnvelope(envelope.id, manager.id, team.id)).toBe(false);
await expect(
cancelDocument({
id: { type: 'envelopeId', id: envelope.id },
userId: manager.id,
teamId: team.id,
reason: 'test-cancel',
requestMetadata,
}),
).rejects.toThrow();
const after = await prisma.envelope.findUnique({ where: { id: envelope.id } });
expect(after?.status).toBe(DocumentStatus.PENDING);
});