mirror of
https://github.com/documenso/documenso.git
synced 2026-08-20 13:31:50 +10:00
Merge branch 'main' into fix/replace-hardcoded-grays-with-theme-tokens
This commit is contained in:
@@ -11,7 +11,7 @@ export const OpenAPIV1 = Object.assign(
|
||||
title: 'Documenso API',
|
||||
version: '1.0.0',
|
||||
description:
|
||||
'API V1 is deprecated, but will continue to be supported. For more details, see https://docs.documenso.com/developers/public-api. \n\nThe Documenso API for retrieving, creating, updating and deleting documents.',
|
||||
'API V1 has been deprecated. For more details, see https://docs.documenso.com/docs/developers/api/migrate-to-envelopes. \n\nThe Documenso API for retrieving, creating, updating and deleting documents.',
|
||||
},
|
||||
servers: [
|
||||
{
|
||||
|
||||
@@ -0,0 +1,439 @@
|
||||
import { seedPendingDocument } from '@documenso/prisma/seed/documents';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { customAlphabet } from 'nanoid';
|
||||
|
||||
import { apiSignin } from '../fixtures/authentication';
|
||||
import { openCommandMenu } from '../fixtures/command-menu';
|
||||
|
||||
test.describe.configure({ mode: 'parallel' });
|
||||
|
||||
const nanoid = customAlphabet('1234567890abcdef', 10);
|
||||
|
||||
const ADMIN_PROMPT_PLACEHOLDER = 'Search documents, users, organisations…';
|
||||
|
||||
test('[ADMIN][GLOBAL_SEARCH]: numeric query shows verified user result and navigates', async ({ page }) => {
|
||||
const { user: adminUser } = await seedUser({ isAdmin: true });
|
||||
const { user: targetUser } = await seedUser();
|
||||
|
||||
await apiSignin({ page, email: adminUser.email });
|
||||
|
||||
await openCommandMenu(page, ADMIN_PROMPT_PLACEHOLDER);
|
||||
|
||||
await page.getByPlaceholder(ADMIN_PROMPT_PLACEHOLDER).first().fill(String(targetUser.id));
|
||||
|
||||
await expect(page.getByText('Global Users', { exact: true })).toBeVisible();
|
||||
|
||||
// The category chips include the admin groups with their result counts.
|
||||
await expect(page.getByRole('button', { name: /Global Users/ })).toBeVisible();
|
||||
|
||||
const userOption = page.getByRole('option').filter({ hasText: targetUser.email }).first();
|
||||
|
||||
// Admin results are real links so they support native link behaviour such
|
||||
// as opening in a new tab.
|
||||
await expect(userOption.getByRole('link')).toHaveAttribute('href', `/admin/users/${targetUser.id}`);
|
||||
|
||||
await userOption.click();
|
||||
|
||||
await page.waitForURL(`/admin/users/${targetUser.id}`);
|
||||
});
|
||||
|
||||
test('[ADMIN][GLOBAL_SEARCH]: numeric query shows verified team result and navigates', async ({ page }) => {
|
||||
const { user: adminUser } = await seedUser({ isAdmin: true });
|
||||
const { team: targetTeam } = await seedUser();
|
||||
|
||||
await apiSignin({ page, email: adminUser.email });
|
||||
|
||||
await openCommandMenu(page, ADMIN_PROMPT_PLACEHOLDER);
|
||||
|
||||
await page.getByPlaceholder(ADMIN_PROMPT_PLACEHOLDER).first().fill(String(targetTeam.id));
|
||||
|
||||
await expect(page.getByText('Global Teams', { exact: true })).toBeVisible();
|
||||
|
||||
await page.getByRole('option').filter({ hasText: targetTeam.url }).first().click();
|
||||
|
||||
await page.waitForURL(`/admin/teams/${targetTeam.id}`);
|
||||
});
|
||||
|
||||
test('[ADMIN][GLOBAL_SEARCH]: text query shows document result and navigates', async ({ page }) => {
|
||||
const { user: adminUser } = await seedUser({ isAdmin: true });
|
||||
const { user: sender, team } = await seedUser();
|
||||
|
||||
const document = await seedPendingDocument(sender, team.id, [], {
|
||||
createDocumentOptions: { title: `admin-ui-search-${nanoid()}` },
|
||||
});
|
||||
|
||||
await apiSignin({ page, email: adminUser.email });
|
||||
|
||||
await openCommandMenu(page, ADMIN_PROMPT_PLACEHOLDER);
|
||||
|
||||
await page.getByPlaceholder(ADMIN_PROMPT_PLACEHOLDER).first().fill(document.title);
|
||||
|
||||
await expect(page.getByText('Global Documents', { exact: true })).toBeVisible();
|
||||
|
||||
await page.getByRole('option').filter({ hasText: document.secondaryId }).first().click();
|
||||
|
||||
await page.waitForURL(`/admin/documents/${document.id}`);
|
||||
});
|
||||
|
||||
test('[ADMIN][GLOBAL_SEARCH]: envelope_ prefixed query resolves exact document', async ({ page }) => {
|
||||
const { user: adminUser } = await seedUser({ isAdmin: true });
|
||||
const { user: sender, team } = await seedUser();
|
||||
|
||||
const document = await seedPendingDocument(sender, team.id, [], {
|
||||
createDocumentOptions: { title: `admin-ui-search-${nanoid()}` },
|
||||
});
|
||||
|
||||
await apiSignin({ page, email: adminUser.email });
|
||||
|
||||
await openCommandMenu(page, ADMIN_PROMPT_PLACEHOLDER);
|
||||
|
||||
await page.getByPlaceholder(ADMIN_PROMPT_PLACEHOLDER).first().fill(document.id);
|
||||
|
||||
await expect(page.getByText('Global Documents', { exact: true })).toBeVisible();
|
||||
await expect(page.getByRole('option').filter({ hasText: document.title }).first()).toBeVisible();
|
||||
});
|
||||
|
||||
test('[ADMIN][GLOBAL_SEARCH]: admin search requires more than 3 characters unless numeric', async ({ page }) => {
|
||||
const { user: adminUser } = await seedUser({ isAdmin: true });
|
||||
|
||||
const adminSearchRequests: string[] = [];
|
||||
|
||||
page.on('request', (request) => {
|
||||
if (request.url().includes('admin.search')) {
|
||||
adminSearchRequests.push(request.url());
|
||||
}
|
||||
});
|
||||
|
||||
await apiSignin({ page, email: adminUser.email });
|
||||
|
||||
await openCommandMenu(page, ADMIN_PROMPT_PLACEHOLDER);
|
||||
|
||||
const input = page.getByPlaceholder(ADMIN_PROMPT_PLACEHOLDER).first();
|
||||
|
||||
// A 3 character non-numeric query must not trigger the admin search. The
|
||||
// personal document search fires for any non-empty query, so its response
|
||||
// is the synchronization anchor proving the debounced queries have fired.
|
||||
const documentSearchResponse = page.waitForResponse((response) => response.url().includes('document.search'));
|
||||
|
||||
await input.fill('abc');
|
||||
|
||||
await documentSearchResponse;
|
||||
|
||||
await expect(page.getByText(/^Global /)).toHaveCount(0);
|
||||
expect(adminSearchRequests).toHaveLength(0);
|
||||
|
||||
// A numeric query fires regardless of length.
|
||||
const adminSearchRequest = page.waitForRequest((request) => request.url().includes('admin.search'));
|
||||
|
||||
await input.fill('7');
|
||||
|
||||
await adminSearchRequest;
|
||||
});
|
||||
|
||||
test('[ADMIN][GLOBAL_SEARCH]: search bar position stays fixed while searching', async ({ page }) => {
|
||||
const { user: adminUser } = await seedUser({ isAdmin: true });
|
||||
const { user: targetUser } = await seedUser();
|
||||
|
||||
await apiSignin({ page, email: adminUser.email });
|
||||
|
||||
await openCommandMenu(page, ADMIN_PROMPT_PLACEHOLDER);
|
||||
|
||||
const input = page.getByPlaceholder(ADMIN_PROMPT_PLACEHOLDER).first();
|
||||
|
||||
const initialY = (await input.boundingBox())?.y;
|
||||
|
||||
expect(initialY).toBeGreaterThan(0);
|
||||
|
||||
// The height of the prompt may change as results come and go, but the
|
||||
// search bar must never move.
|
||||
await input.fill(String(targetUser.id));
|
||||
|
||||
await expect(page.getByText('Global Users', { exact: true })).toBeVisible();
|
||||
|
||||
const resultsY = (await input.boundingBox())?.y;
|
||||
|
||||
expect(resultsY).toBe(initialY);
|
||||
|
||||
// The search bar must not move when there are no results at all.
|
||||
await input.fill('zzzz-no-such-thing-9x7q');
|
||||
|
||||
await expect(page.getByText('No results for')).toBeVisible();
|
||||
|
||||
const emptyY = (await input.boundingBox())?.y;
|
||||
|
||||
expect(emptyY).toBe(initialY);
|
||||
});
|
||||
|
||||
test('[ADMIN][GLOBAL_SEARCH]: default view shows the document page links outside a team context', async ({ page }) => {
|
||||
const { user: adminUser } = await seedUser({ isAdmin: true });
|
||||
|
||||
await apiSignin({ page, email: adminUser.email });
|
||||
|
||||
// Admin pages have no current team, the page links must still show.
|
||||
await page.goto('/admin/stats');
|
||||
|
||||
await openCommandMenu(page, ADMIN_PROMPT_PLACEHOLDER);
|
||||
|
||||
await expect(page.getByRole('option').filter({ hasText: 'All documents' })).toBeVisible();
|
||||
await expect(page.getByRole('option').filter({ hasText: 'Draft documents' })).toBeVisible();
|
||||
await expect(page.getByRole('option').filter({ hasText: 'All templates' })).toBeVisible();
|
||||
|
||||
// Chips only show for categories with actual results, not for the
|
||||
// hardcoded page links.
|
||||
await expect(page.getByRole('button', { name: /^Documents/ })).toHaveCount(0);
|
||||
await expect(page.getByRole('button', { name: /^Templates/ })).toHaveCount(0);
|
||||
await expect(page.getByRole('button', { name: /^Settings/ })).toBeVisible();
|
||||
});
|
||||
|
||||
test('[ADMIN][GLOBAL_SEARCH]: theme can be changed from the prompt', async ({ page }) => {
|
||||
const { user: adminUser } = await seedUser({ isAdmin: true });
|
||||
|
||||
await apiSignin({ page, email: adminUser.email });
|
||||
|
||||
await openCommandMenu(page, ADMIN_PROMPT_PLACEHOLDER);
|
||||
|
||||
await page.getByRole('option').filter({ hasText: 'Change theme' }).first().click();
|
||||
|
||||
// The sub page has a contextual placeholder and a back option.
|
||||
await expect(page.getByPlaceholder('Search themes…')).toBeVisible();
|
||||
await expect(page.getByRole('option').filter({ hasText: 'Back' }).first()).toBeVisible();
|
||||
|
||||
await expect(page.getByRole('option').filter({ hasText: 'Dark Mode' })).toBeVisible();
|
||||
|
||||
await page.getByRole('option').filter({ hasText: 'Dark Mode' }).first().click();
|
||||
|
||||
await expect(page.locator('html')).toHaveClass(/dark/);
|
||||
|
||||
// The back option returns to the root view.
|
||||
await page.getByRole('option').filter({ hasText: 'Back' }).first().click();
|
||||
|
||||
await expect(page.getByPlaceholder(ADMIN_PROMPT_PLACEHOLDER).first()).toBeVisible();
|
||||
});
|
||||
|
||||
test('[ADMIN][GLOBAL_SEARCH]: capped admin groups offer a view all link', async ({ page }) => {
|
||||
const { user: adminUser } = await seedUser({ isAdmin: true });
|
||||
|
||||
const namePrefix = `viewall-${nanoid()}`;
|
||||
|
||||
// Seed enough users sharing a name prefix to hit the 5 result cap.
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await seedUser({ name: `${namePrefix}-${i}` });
|
||||
}
|
||||
|
||||
await apiSignin({ page, email: adminUser.email });
|
||||
|
||||
await openCommandMenu(page, ADMIN_PROMPT_PLACEHOLDER);
|
||||
|
||||
await page.getByPlaceholder(ADMIN_PROMPT_PLACEHOLDER).first().fill(namePrefix);
|
||||
|
||||
await expect(page.getByText('Global Users', { exact: true })).toBeVisible();
|
||||
|
||||
const viewAllOption = page.getByRole('option').filter({ hasText: 'View all results' }).first();
|
||||
|
||||
await expect(viewAllOption.getByRole('link')).toHaveAttribute(
|
||||
'href',
|
||||
`/admin/users?search=${encodeURIComponent(namePrefix)}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('[ADMIN][GLOBAL_SEARCH]: first result is highlighted after every search', async ({ page }) => {
|
||||
const { user: adminUser } = await seedUser({ isAdmin: true });
|
||||
const { user: firstUser } = await seedUser();
|
||||
const { user: secondUser } = await seedUser();
|
||||
|
||||
await apiSignin({ page, email: adminUser.email });
|
||||
|
||||
await openCommandMenu(page, ADMIN_PROMPT_PLACEHOLDER);
|
||||
|
||||
const input = page.getByPlaceholder(ADMIN_PROMPT_PLACEHOLDER).first();
|
||||
|
||||
// First search selects the first result.
|
||||
await input.fill(String(firstUser.id));
|
||||
|
||||
await expect(page.getByRole('option').filter({ hasText: firstUser.email }).first()).toBeVisible();
|
||||
await expect(page.locator('[cmdk-item]').first()).toHaveAttribute('aria-selected', 'true');
|
||||
|
||||
// A subsequent search with entirely new results must select the first
|
||||
// result again.
|
||||
await input.fill(String(secondUser.id));
|
||||
|
||||
await expect(page.getByRole('option').filter({ hasText: secondUser.email }).first()).toBeVisible();
|
||||
await expect(page.locator('[cmdk-item]').first()).toHaveAttribute('aria-selected', 'true');
|
||||
});
|
||||
|
||||
test('[ADMIN][GLOBAL_SEARCH]: static items match fuzzy queries', async ({ page }) => {
|
||||
const { user: adminUser } = await seedUser({ isAdmin: true });
|
||||
|
||||
await apiSignin({ page, email: adminUser.email });
|
||||
|
||||
await openCommandMenu(page, ADMIN_PROMPT_PLACEHOLDER);
|
||||
|
||||
// "setg" is a non-contiguous abbreviation of "Settings".
|
||||
await page.getByPlaceholder(ADMIN_PROMPT_PLACEHOLDER).first().fill('setg');
|
||||
|
||||
// Wait for the debounced filter to apply first, "Draft documents" can
|
||||
// never match "setg" under either matching strategy.
|
||||
await expect(page.getByRole('option').filter({ hasText: 'Draft documents' })).toHaveCount(0);
|
||||
|
||||
await expect(page.getByRole('option').filter({ hasText: 'Settings' }).first()).toBeVisible();
|
||||
});
|
||||
|
||||
test('[ADMIN][GLOBAL_SEARCH]: page scrollbar is hidden while the prompt is open', async ({ page }) => {
|
||||
const { user: adminUser } = await seedUser({ isAdmin: true });
|
||||
|
||||
await apiSignin({ page, email: adminUser.email });
|
||||
|
||||
await openCommandMenu(page, ADMIN_PROMPT_PLACEHOLDER);
|
||||
|
||||
await expect
|
||||
.poll(async () => await page.evaluate(() => getComputedStyle(document.documentElement).overflow))
|
||||
.toBe('hidden');
|
||||
|
||||
await page.keyboard.press('Escape');
|
||||
|
||||
await expect
|
||||
.poll(async () => await page.evaluate(() => getComputedStyle(document.documentElement).overflow))
|
||||
.toBe('visible');
|
||||
});
|
||||
|
||||
test('[ADMIN][GLOBAL_SEARCH]: non-admin gets the prompt without the admin search', async ({ page }) => {
|
||||
const { user, team } = await seedUser({ isAdmin: false });
|
||||
|
||||
const document = await seedPendingDocument(user, team.id, []);
|
||||
|
||||
const adminSearchRequests: string[] = [];
|
||||
|
||||
page.on('request', (request) => {
|
||||
if (request.url().includes('admin.search')) {
|
||||
adminSearchRequests.push(request.url());
|
||||
}
|
||||
});
|
||||
|
||||
await apiSignin({ page, email: user.email });
|
||||
|
||||
// Non-admins get the same prompt with a non-admin placeholder.
|
||||
await openCommandMenu(page, 'Type a command or search...');
|
||||
|
||||
await expect(page.getByPlaceholder(ADMIN_PROMPT_PLACEHOLDER)).toHaveCount(0);
|
||||
|
||||
await page.getByPlaceholder('Type a command or search...').first().fill(document.title);
|
||||
|
||||
// Wait for the regular (non-admin) search to resolve so we know the
|
||||
// debounced queries have fired.
|
||||
await expect(page.getByRole('option', { name: document.title })).toBeVisible();
|
||||
|
||||
await expect(page.getByText(/^Global /)).toHaveCount(0);
|
||||
expect(adminSearchRequests).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('[ADMIN][GLOBAL_SEARCH]: typing on a sub page fires no search requests', async ({ page }) => {
|
||||
const { user: adminUser } = await seedUser({ isAdmin: true });
|
||||
|
||||
const searchRequests: string[] = [];
|
||||
|
||||
page.on('request', (request) => {
|
||||
if (/api\/trpc\/(document|template|admin)\.search/.test(request.url())) {
|
||||
searchRequests.push(request.url());
|
||||
}
|
||||
});
|
||||
|
||||
await apiSignin({ page, email: adminUser.email });
|
||||
|
||||
await openCommandMenu(page, ADMIN_PROMPT_PLACEHOLDER);
|
||||
|
||||
await page.getByRole('option').filter({ hasText: 'Change theme' }).first().click();
|
||||
|
||||
const input = page.getByPlaceholder('Search themes…');
|
||||
|
||||
await expect(input).toBeVisible();
|
||||
|
||||
// Long enough to pass the admin search threshold if it were enabled.
|
||||
await input.fill('dark');
|
||||
|
||||
// The client-side filter applying proves the typing registered.
|
||||
await expect(page.getByRole('option').filter({ hasText: 'Dark Mode' })).toBeVisible();
|
||||
await expect(page.getByRole('option').filter({ hasText: 'Light Mode' })).toHaveCount(0);
|
||||
|
||||
// Wait out the 200ms search debounce with a wide margin before asserting
|
||||
// that no requests fired: there is no response to anchor on when the
|
||||
// desired behaviour is "no requests at all".
|
||||
await page.waitForTimeout(750);
|
||||
|
||||
expect(searchRequests).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('[ADMIN][GLOBAL_SEARCH]: failed searches show an error state instead of no results', async ({ page }) => {
|
||||
const { user: adminUser } = await seedUser({ isAdmin: true });
|
||||
|
||||
await page.route(/api\/trpc\/(document|template|admin)\.search/, async (route) => {
|
||||
await route.fulfill({ status: 500, contentType: 'application/json', body: '{}' });
|
||||
});
|
||||
|
||||
await apiSignin({ page, email: adminUser.email });
|
||||
|
||||
await openCommandMenu(page, ADMIN_PROMPT_PLACEHOLDER);
|
||||
|
||||
await page.getByPlaceholder(ADMIN_PROMPT_PLACEHOLDER).first().fill('zzzz-no-such-thing-9x7q');
|
||||
|
||||
// A failed search must be honest about it, not claim there are no results.
|
||||
await expect(page.getByText('Something went wrong')).toBeVisible();
|
||||
await expect(page.getByText('No results for')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('[ADMIN][GLOBAL_SEARCH]: partial search failure still shows results with a notice', async ({ page }) => {
|
||||
const { user: adminUser, team } = await seedUser({ isAdmin: true });
|
||||
|
||||
const document = await seedPendingDocument(adminUser, team.id, [], {
|
||||
createDocumentOptions: { title: `partial-fail-${nanoid()}` },
|
||||
});
|
||||
|
||||
// Only the admin search fails: the personal searches succeed.
|
||||
await page.route(/api\/trpc\/admin\.search/, async (route) => {
|
||||
await route.fulfill({ status: 500, contentType: 'application/json', body: '{}' });
|
||||
});
|
||||
|
||||
await apiSignin({ page, email: adminUser.email });
|
||||
|
||||
await openCommandMenu(page, ADMIN_PROMPT_PLACEHOLDER);
|
||||
|
||||
await page.getByPlaceholder(ADMIN_PROMPT_PLACEHOLDER).first().fill(document.title);
|
||||
|
||||
// The successful personal document search must still render its results.
|
||||
await expect(page.getByRole('option', { name: document.title })).toBeVisible();
|
||||
|
||||
// The failed admin search must be flagged rather than silently dropped.
|
||||
await expect(page.getByText('Some searches failed')).toBeVisible();
|
||||
});
|
||||
|
||||
test('[ADMIN][GLOBAL_SEARCH]: over-length query skips the admin search without erroring', async ({ page }) => {
|
||||
const { user: adminUser } = await seedUser({ isAdmin: true });
|
||||
|
||||
const adminSearchRequests: string[] = [];
|
||||
|
||||
page.on('request', (request) => {
|
||||
if (request.url().includes('admin.search')) {
|
||||
adminSearchRequests.push(request.url());
|
||||
}
|
||||
});
|
||||
|
||||
await apiSignin({ page, email: adminUser.email });
|
||||
|
||||
await openCommandMenu(page, ADMIN_PROMPT_PLACEHOLDER);
|
||||
|
||||
// The admin search endpoint rejects queries longer than 100 characters, so
|
||||
// the client must not send them. The personal searches accept up to 1024
|
||||
// characters and still run, anchoring the debounced query flush.
|
||||
const documentSearchResponse = page.waitForResponse((response) => response.url().includes('document.search'));
|
||||
|
||||
await page.getByPlaceholder(ADMIN_PROMPT_PLACEHOLDER).first().fill('a'.repeat(150));
|
||||
|
||||
await documentSearchResponse;
|
||||
|
||||
// The personal searches ran and found nothing: the honest empty state, with
|
||||
// no error in sight.
|
||||
await expect(page.getByText('No results for')).toBeVisible();
|
||||
await expect(page.getByText('Something went wrong')).toHaveCount(0);
|
||||
|
||||
expect(adminSearchRequests).toHaveLength(0);
|
||||
});
|
||||
@@ -338,7 +338,7 @@ test('[ADMIN]: verify role hierarchy after promotion', async ({ page }) => {
|
||||
});
|
||||
|
||||
// Verify they can access organisation settings (owner permission)
|
||||
await expect(page.getByText('Organisation Settings')).toBeVisible();
|
||||
await expect(page.getByTestId('unified-settings-sidebar')).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'Delete' })).toBeVisible();
|
||||
});
|
||||
|
||||
@@ -524,7 +524,7 @@ test('[ADMIN]: verify organisation access after ownership change', async ({ page
|
||||
});
|
||||
|
||||
// Should be able to access organisation settings
|
||||
await expect(page.getByText('Organisation Settings')).toBeVisible();
|
||||
await expect(page.getByTestId('unified-settings-sidebar')).toBeVisible();
|
||||
await expect(page.getByLabel('Organisation Name*')).toBeVisible();
|
||||
await expect(page.getByLabel('Organisation Name*')).toBeEnabled();
|
||||
|
||||
@@ -539,5 +539,5 @@ test('[ADMIN]: verify organisation access after ownership change', async ({ page
|
||||
});
|
||||
|
||||
// Should still be able to access settings (as they should now be an admin)
|
||||
await expect(page.getByText('Organisation Settings')).toBeVisible();
|
||||
await expect(page.getByTestId('unified-settings-sidebar')).toBeVisible();
|
||||
});
|
||||
|
||||
@@ -0,0 +1,249 @@
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import { seedPendingDocument } from '@documenso/prisma/seed/documents';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
import type { Page } from '@playwright/test';
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { customAlphabet } from 'nanoid';
|
||||
|
||||
import { apiSignin } from '../../../fixtures/authentication';
|
||||
|
||||
const nanoid = customAlphabet('1234567890abcdef', 10);
|
||||
|
||||
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
|
||||
|
||||
test.describe.configure({ mode: 'parallel' });
|
||||
|
||||
type AdminSearchGroup = {
|
||||
type: string;
|
||||
results: Array<{ label: string; sublabel?: string; path: string; value: string }>;
|
||||
};
|
||||
|
||||
const callAdminSearch = async (page: Page, query: string) => {
|
||||
const inputParam = encodeURIComponent(JSON.stringify({ json: { query } }));
|
||||
const url = `${WEBAPP_BASE_URL}/api/trpc/admin.search?input=${inputParam}`;
|
||||
|
||||
const res = await page.context().request.get(url);
|
||||
|
||||
return {
|
||||
res,
|
||||
groups: res.ok()
|
||||
? // eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
((await res.json()).result.data.json.groups as AdminSearchGroup[])
|
||||
: null,
|
||||
};
|
||||
};
|
||||
|
||||
const findGroup = (groups: AdminSearchGroup[] | null, type: string) =>
|
||||
(groups ?? []).find((group) => group.type === type);
|
||||
|
||||
// ─── Access control ──────────────────────────────────────────────────────────
|
||||
|
||||
test('[ADMIN][TRPC][SEARCH]: unauthenticated request is rejected with 401', async ({ page }) => {
|
||||
const { res } = await callAdminSearch(page, 'anything');
|
||||
|
||||
expect(res.ok()).toBeFalsy();
|
||||
expect(res.status()).toBe(401);
|
||||
});
|
||||
|
||||
test('[ADMIN][TRPC][SEARCH]: non-admin authenticated user is rejected with 401', async ({ page }) => {
|
||||
const { user: nonAdminUser } = await seedUser({ isAdmin: false });
|
||||
|
||||
await apiSignin({ page, email: nonAdminUser.email });
|
||||
|
||||
const { res } = await callAdminSearch(page, 'anything');
|
||||
|
||||
expect(res.ok()).toBeFalsy();
|
||||
expect(res.status()).toBe(401);
|
||||
});
|
||||
|
||||
// ─── Numeric queries: verified ID lookups ────────────────────────────────────
|
||||
|
||||
test('[ADMIN][TRPC][SEARCH]: numeric query returns verified user and team rows', async ({ page }) => {
|
||||
const { user: adminUser } = await seedUser({ isAdmin: true });
|
||||
const { user: targetUser, team: targetTeam } = await seedUser();
|
||||
|
||||
await apiSignin({ page, email: adminUser.email });
|
||||
|
||||
// Search by user ID.
|
||||
const userSearch = await callAdminSearch(page, String(targetUser.id));
|
||||
|
||||
expect(userSearch.res.ok()).toBeTruthy();
|
||||
|
||||
const userGroup = findGroup(userSearch.groups, 'user');
|
||||
expect(userGroup).toBeDefined();
|
||||
expect(userGroup?.results).toHaveLength(1);
|
||||
expect(userGroup?.results[0].path).toBe(`/admin/users/${targetUser.id}`);
|
||||
expect(userGroup?.results[0].sublabel).toContain(targetUser.email);
|
||||
|
||||
// The cmdk `value` contract: value must contain the raw query.
|
||||
expect(userGroup?.results[0].value).toContain(String(targetUser.id));
|
||||
|
||||
// Search by team ID.
|
||||
const teamSearch = await callAdminSearch(page, String(targetTeam.id));
|
||||
|
||||
expect(teamSearch.res.ok()).toBeTruthy();
|
||||
|
||||
const teamGroup = findGroup(teamSearch.groups, 'team');
|
||||
expect(teamGroup).toBeDefined();
|
||||
expect(teamGroup?.results).toHaveLength(1);
|
||||
expect(teamGroup?.results[0].path).toBe(`/admin/teams/${targetTeam.id}`);
|
||||
expect(teamGroup?.results[0].label).toBe(targetTeam.name);
|
||||
});
|
||||
|
||||
test('[ADMIN][TRPC][SEARCH]: numeric query returns verified document and recipient rows', async ({ page }) => {
|
||||
const { user: adminUser } = await seedUser({ isAdmin: true });
|
||||
const { user: sender, team } = await seedUser();
|
||||
const { user: recipientUser } = await seedUser();
|
||||
|
||||
const document = await seedPendingDocument(sender, team.id, [recipientUser]);
|
||||
const legacyDocumentId = document.secondaryId.replace('document_', '');
|
||||
const recipient = document.recipients[0];
|
||||
|
||||
await apiSignin({ page, email: adminUser.email });
|
||||
|
||||
// Search by legacy document ID (bare number).
|
||||
const documentSearch = await callAdminSearch(page, legacyDocumentId);
|
||||
|
||||
expect(documentSearch.res.ok()).toBeTruthy();
|
||||
|
||||
const documentGroup = findGroup(documentSearch.groups, 'document');
|
||||
expect(documentGroup).toBeDefined();
|
||||
expect(documentGroup?.results).toHaveLength(1);
|
||||
expect(documentGroup?.results[0].path).toBe(`/admin/documents/${document.id}`);
|
||||
expect(documentGroup?.results[0].label).toBe(document.title);
|
||||
|
||||
// Search by recipient ID: links to the parent document.
|
||||
const recipientSearch = await callAdminSearch(page, String(recipient.id));
|
||||
|
||||
expect(recipientSearch.res.ok()).toBeTruthy();
|
||||
|
||||
const recipientGroup = findGroup(recipientSearch.groups, 'recipient');
|
||||
expect(recipientGroup).toBeDefined();
|
||||
expect(recipientGroup?.results).toHaveLength(1);
|
||||
expect(recipientGroup?.results[0].path).toBe(`/admin/documents/${document.id}`);
|
||||
expect(recipientGroup?.results[0].label).toBe(recipient.email);
|
||||
expect(recipientGroup?.results[0].sublabel).toBe(`#${recipient.id} · ${recipient.name} · ${document.title}`);
|
||||
|
||||
// Search by the full document_<id> secondary ID: exercises the prefix branch.
|
||||
const secondaryIdSearch = await callAdminSearch(page, document.secondaryId);
|
||||
|
||||
expect(secondaryIdSearch.res.ok()).toBeTruthy();
|
||||
|
||||
const secondaryIdGroup = findGroup(secondaryIdSearch.groups, 'document');
|
||||
expect(secondaryIdGroup).toBeDefined();
|
||||
expect(secondaryIdGroup?.results[0].path).toBe(`/admin/documents/${document.id}`);
|
||||
});
|
||||
|
||||
test('[ADMIN][TRPC][SEARCH]: numeric query with no matches returns no groups', async ({ page }) => {
|
||||
const { user: adminUser } = await seedUser({ isAdmin: true });
|
||||
|
||||
await apiSignin({ page, email: adminUser.email });
|
||||
|
||||
const { res, groups } = await callAdminSearch(page, '999999999');
|
||||
|
||||
expect(res.ok()).toBeTruthy();
|
||||
expect(groups).toEqual([]);
|
||||
});
|
||||
|
||||
test('[ADMIN][TRPC][SEARCH]: oversized number does not error and falls back to text search', async ({ page }) => {
|
||||
const { user: adminUser } = await seedUser({ isAdmin: true });
|
||||
const { user: sender, team } = await seedUser();
|
||||
|
||||
// 99999999999999 exceeds Int4, so it cannot be an ID lookup: it must be
|
||||
// treated as text (and must not 500).
|
||||
const oversizedNumber = '99999999999999';
|
||||
|
||||
const document = await seedPendingDocument(sender, team.id, [], {
|
||||
createDocumentOptions: { title: `${oversizedNumber}-${nanoid()}` },
|
||||
});
|
||||
|
||||
await apiSignin({ page, email: adminUser.email });
|
||||
|
||||
const { res, groups } = await callAdminSearch(page, oversizedNumber);
|
||||
|
||||
expect(res.ok()).toBeTruthy();
|
||||
|
||||
const documentGroup = findGroup(groups, 'document');
|
||||
expect(documentGroup).toBeDefined();
|
||||
expect(documentGroup?.results.map((result) => result.path)).toContain(`/admin/documents/${document.id}`);
|
||||
});
|
||||
|
||||
// ─── Prefixed ID queries: exact lookups ──────────────────────────────────────
|
||||
|
||||
test('[ADMIN][TRPC][SEARCH]: envelope_ and org_ prefixes resolve exact matches', async ({ page }) => {
|
||||
const { user: adminUser } = await seedUser({ isAdmin: true });
|
||||
const { user: sender, organisation, team } = await seedUser();
|
||||
|
||||
const document = await seedPendingDocument(sender, team.id, []);
|
||||
|
||||
await apiSignin({ page, email: adminUser.email });
|
||||
|
||||
// envelope_<id> resolves the document.
|
||||
const envelopeSearch = await callAdminSearch(page, document.id);
|
||||
|
||||
expect(envelopeSearch.res.ok()).toBeTruthy();
|
||||
|
||||
const documentGroup = findGroup(envelopeSearch.groups, 'document');
|
||||
expect(documentGroup).toBeDefined();
|
||||
expect(documentGroup?.results[0].path).toBe(`/admin/documents/${document.id}`);
|
||||
|
||||
// Only the document group is returned for a recognized prefix.
|
||||
expect(envelopeSearch.groups).toHaveLength(1);
|
||||
|
||||
// org_<id> resolves the organisation.
|
||||
const orgSearch = await callAdminSearch(page, organisation.id);
|
||||
|
||||
expect(orgSearch.res.ok()).toBeTruthy();
|
||||
|
||||
const orgGroup = findGroup(orgSearch.groups, 'organisation');
|
||||
expect(orgGroup).toBeDefined();
|
||||
expect(orgGroup?.results[0].path).toBe(`/admin/organisations/${organisation.id}`);
|
||||
expect(orgGroup?.results[0].label).toBe(organisation.name);
|
||||
|
||||
// Only the organisation group is returned for a recognized prefix.
|
||||
expect(orgSearch.groups).toHaveLength(1);
|
||||
});
|
||||
|
||||
// ─── Free text queries ───────────────────────────────────────────────────────
|
||||
|
||||
test('[ADMIN][TRPC][SEARCH]: text query matches documents by title and users by email', async ({ page }) => {
|
||||
const { user: adminUser } = await seedUser({ isAdmin: true });
|
||||
const { user: sender, team } = await seedUser();
|
||||
|
||||
// A unique title: the default seeded title is shared across the whole suite,
|
||||
// and global search only returns the newest few matches.
|
||||
const document = await seedPendingDocument(sender, team.id, [], {
|
||||
createDocumentOptions: { title: `admin-search-${nanoid()}` },
|
||||
});
|
||||
|
||||
await apiSignin({ page, email: adminUser.email });
|
||||
|
||||
// Search by document title.
|
||||
const titleSearch = await callAdminSearch(page, document.title);
|
||||
|
||||
expect(titleSearch.res.ok()).toBeTruthy();
|
||||
|
||||
const documentGroup = findGroup(titleSearch.groups, 'document');
|
||||
expect(documentGroup).toBeDefined();
|
||||
expect(documentGroup?.results.map((result) => result.path)).toContain(`/admin/documents/${document.id}`);
|
||||
|
||||
// Search by user email (emails are unique nanoid-based, so this is specific).
|
||||
const emailSearch = await callAdminSearch(page, sender.email);
|
||||
|
||||
expect(emailSearch.res.ok()).toBeTruthy();
|
||||
|
||||
const userGroup = findGroup(emailSearch.groups, 'user');
|
||||
expect(userGroup).toBeDefined();
|
||||
expect(userGroup?.results[0].path).toBe(`/admin/users/${sender.id}`);
|
||||
});
|
||||
|
||||
test('[ADMIN][TRPC][SEARCH]: gibberish query returns no groups', async ({ page }) => {
|
||||
const { user: adminUser } = await seedUser({ isAdmin: true });
|
||||
|
||||
await apiSignin({ page, email: adminUser.email });
|
||||
|
||||
const { res, groups } = await callAdminSearch(page, 'zzzz-no-such-thing-9x7q');
|
||||
|
||||
expect(res.ok()).toBeTruthy();
|
||||
expect(groups).toEqual([]);
|
||||
});
|
||||
@@ -2,10 +2,20 @@ import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import { createApiToken } from '@documenso/lib/server-only/public-api/create-api-token';
|
||||
import { mapSecondaryIdToDocumentId } from '@documenso/lib/utils/envelope';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { FieldType, RecipientRole } from '@documenso/prisma/client';
|
||||
import {
|
||||
DocumentSigningOrder,
|
||||
DocumentStatus,
|
||||
FieldType,
|
||||
RecipientRole,
|
||||
SendStatus,
|
||||
SigningStatus,
|
||||
} from '@documenso/prisma/client';
|
||||
import { seedBlankDocument, seedPendingDocumentWithFullFields } from '@documenso/prisma/seed/documents';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { nanoid } from 'nanoid';
|
||||
|
||||
import { signSignaturePad } from '../../fixtures/signature';
|
||||
|
||||
test.describe('Document API', () => {
|
||||
test('sendDocument: should respect sendCompletionEmails setting', async ({ request }) => {
|
||||
@@ -432,4 +442,194 @@ test.describe('Document API', () => {
|
||||
expect(response.ok()).toBeTruthy();
|
||||
expect(response.status()).toBe(200);
|
||||
});
|
||||
|
||||
test('sendDocument: should complete document immediately when all recipients are CC', async ({ request }) => {
|
||||
const { user, team } = await seedUser();
|
||||
|
||||
// Create a blank document and get it with envelope items
|
||||
const blankDocument = await seedBlankDocument(user, team.id);
|
||||
const document = await prisma.envelope.findUniqueOrThrow({
|
||||
where: { id: blankDocument.id },
|
||||
include: { envelopeItems: true },
|
||||
});
|
||||
|
||||
// Add two CC recipients without any fields, mirroring the production
|
||||
// state where CC recipients are created pre-signed.
|
||||
for (const email of ['cc1@example.com', 'cc2@example.com']) {
|
||||
await prisma.recipient.create({
|
||||
data: {
|
||||
email,
|
||||
name: 'Test CC',
|
||||
role: RecipientRole.CC,
|
||||
signingStatus: SigningStatus.SIGNED,
|
||||
sendStatus: SendStatus.SENT,
|
||||
token: nanoid(),
|
||||
envelopeId: document.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const { token } = await createApiToken({
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
tokenName: 'test',
|
||||
expiresIn: null,
|
||||
});
|
||||
|
||||
const response = await request.post(
|
||||
`${NEXT_PUBLIC_WEBAPP_URL()}/api/v1/documents/${mapSecondaryIdToDocumentId(document.secondaryId)}/send`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
data: {},
|
||||
},
|
||||
);
|
||||
|
||||
expect(response.ok()).toBeTruthy();
|
||||
expect(response.status()).toBe(200);
|
||||
|
||||
// The document seals asynchronously and completes without anyone signing.
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const updatedDocument = await prisma.envelope.findFirstOrThrow({
|
||||
where: { id: document.id },
|
||||
});
|
||||
|
||||
return updatedDocument.status;
|
||||
},
|
||||
{ timeout: 30_000 },
|
||||
)
|
||||
.toBe(DocumentStatus.COMPLETED);
|
||||
});
|
||||
|
||||
test('sendDocument: should not block initial sequential send when CC recipient is first in signing order', async ({
|
||||
request,
|
||||
page,
|
||||
}) => {
|
||||
const { user, team } = await seedUser();
|
||||
|
||||
// Create a blank document and get it with envelope items
|
||||
const blankDocument = await seedBlankDocument(user, team.id);
|
||||
const document = await prisma.envelope.findUniqueOrThrow({
|
||||
where: { id: blankDocument.id },
|
||||
include: { envelopeItems: true },
|
||||
});
|
||||
|
||||
await prisma.documentMeta.update({
|
||||
where: { id: document.documentMetaId },
|
||||
data: { signingOrder: DocumentSigningOrder.SEQUENTIAL },
|
||||
});
|
||||
|
||||
// CC recipient first in the signing order, mirroring the production
|
||||
// state where CC recipients are created pre-signed.
|
||||
await prisma.recipient.create({
|
||||
data: {
|
||||
email: 'cc@example.com',
|
||||
name: 'Test CC',
|
||||
role: RecipientRole.CC,
|
||||
signingOrder: 1,
|
||||
signingStatus: SigningStatus.SIGNED,
|
||||
sendStatus: SendStatus.SENT,
|
||||
token: nanoid(),
|
||||
envelopeId: document.id,
|
||||
},
|
||||
});
|
||||
|
||||
const [signerA, signerB] = await Promise.all(
|
||||
[
|
||||
{ email: 'signer-a@example.com', name: 'Signer A', signingOrder: 2 },
|
||||
{ email: 'signer-b@example.com', name: 'Signer B', signingOrder: 3 },
|
||||
].map(async ({ email, name, signingOrder }) =>
|
||||
prisma.recipient.create({
|
||||
data: {
|
||||
email,
|
||||
name,
|
||||
role: RecipientRole.SIGNER,
|
||||
signingOrder,
|
||||
token: nanoid(),
|
||||
envelopeId: document.id,
|
||||
fields: {
|
||||
create: {
|
||||
type: FieldType.SIGNATURE,
|
||||
page: 1,
|
||||
positionX: signingOrder * 10,
|
||||
positionY: 10,
|
||||
width: 5,
|
||||
height: 5,
|
||||
customText: '',
|
||||
inserted: false,
|
||||
envelopeId: document.id,
|
||||
envelopeItemId: document.envelopeItems[0].id,
|
||||
fieldMeta: { type: 'signature', fontSize: 14 },
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
const { token } = await createApiToken({
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
tokenName: 'test',
|
||||
expiresIn: null,
|
||||
});
|
||||
|
||||
const response = await request.post(
|
||||
`${NEXT_PUBLIC_WEBAPP_URL()}/api/v1/documents/${mapSecondaryIdToDocumentId(document.secondaryId)}/send`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
data: {},
|
||||
},
|
||||
);
|
||||
|
||||
expect(response.ok()).toBeTruthy();
|
||||
expect(response.status()).toBe(200);
|
||||
|
||||
// The CC recipient at order 1 must not block signer A at order 2.
|
||||
await page.goto(`/sign/${signerA.token}`);
|
||||
await expect(page).not.toHaveURL(`/sign/${signerA.token}/waiting`);
|
||||
await expect(page.getByRole('heading', { name: 'Sign Document' })).toBeVisible();
|
||||
|
||||
// Signer B at order 3 must still wait for signer A.
|
||||
await page.goto(`/sign/${signerB.token}`);
|
||||
await expect(page).toHaveURL(`/sign/${signerB.token}/waiting`);
|
||||
|
||||
// Sign as signer A then signer B.
|
||||
for (const signer of [signerA, signerB]) {
|
||||
await page.goto(`/sign/${signer.token}`);
|
||||
await expect(page.getByRole('heading', { name: 'Sign Document' })).toBeVisible();
|
||||
await signSignaturePad(page);
|
||||
|
||||
const signerField = await prisma.field.findFirstOrThrow({
|
||||
where: { recipientId: signer.id },
|
||||
});
|
||||
|
||||
await page.locator(`#field-${signerField.id}`).getByRole('button').click();
|
||||
|
||||
await page.getByRole('button', { name: 'Complete' }).click();
|
||||
await page.getByRole('button', { name: 'Sign' }).click();
|
||||
await page.waitForURL(`/sign/${signer.token}/complete`);
|
||||
}
|
||||
|
||||
// The document completes without any action from the CC recipient.
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const updatedDocument = await prisma.envelope.findFirstOrThrow({
|
||||
where: { id: document.id },
|
||||
});
|
||||
|
||||
return updatedDocument.status;
|
||||
},
|
||||
{ timeout: 30_000 },
|
||||
)
|
||||
.toBe(DocumentStatus.COMPLETED);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -50,7 +50,7 @@ import type { Organisation, Team, User } from '@prisma/client';
|
||||
*
|
||||
* --- GLOBAL LIMIT AWARENESS ---
|
||||
* apps/remix/server/router.ts applies a GLOBAL per-IP limiter to /api/v1/*:
|
||||
* apiV1RateLimit = 100 requests / 1 minute (action `api.v1`, see rate-limits.ts).
|
||||
* apiV1RateLimit = 1000 requests / 1 minute (action `api.v1`, see rate-limits.ts).
|
||||
* Every per-org limit/quota configured here is kept FAR below that ceiling (single
|
||||
* digits) and the suite runs serially so the shared-IP global bucket is never the
|
||||
* thing that trips. A global-limit 429 is shaped `{ error }` whereas an org-limit
|
||||
@@ -62,7 +62,7 @@ const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
|
||||
const baseUrl = `${WEBAPP_BASE_URL}/api/v1`;
|
||||
|
||||
// Run serially: all workers share one IP, and the global /api/v1 limiter is
|
||||
// per-IP. Serial execution keeps the shared global bucket well under 100/min.
|
||||
// per-IP. Serial execution keeps the shared global bucket well under 1000/min.
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
|
||||
// This suite is only meaningful with real rate limiting enabled. CI sets the
|
||||
@@ -125,7 +125,7 @@ const setClaimLimits = async (team: Team, limits: ClaimLimits) => {
|
||||
* GLOBAL /api/v1 IP bucket so a fresh scenario starts from zero.
|
||||
*
|
||||
* - The org windowed limiter keys its rows `ip:org:<id>`.
|
||||
* - The GLOBAL limiter (apps/remix/server/router.ts -> apiV1RateLimit, 100/min
|
||||
* - The GLOBAL limiter (apps/remix/server/router.ts -> apiV1RateLimit, 1000/min
|
||||
* per IP, action `api.v1`) is shared by EVERY v1 request from this test client.
|
||||
* Across the suite (and especially across repeated local runs within the same
|
||||
* minute) that shared bucket would otherwise fill up and trip BEFORE the org
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
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 { DocumentStatus, DocumentVisibility, TeamMemberRole } from '@documenso/prisma/client';
|
||||
import {
|
||||
DocumentStatus,
|
||||
DocumentVisibility,
|
||||
RecipientRole,
|
||||
SigningStatus,
|
||||
TeamMemberRole,
|
||||
} from '@documenso/prisma/client';
|
||||
import {
|
||||
seedBlankDocument,
|
||||
seedCompletedDocument,
|
||||
@@ -1560,3 +1566,307 @@ test.describe('Find Documents API - Adversarial: Cross-Team templateId', () => {
|
||||
expect(ownTemplate!.data[0].title).toBe('TeamA Doc from Template');
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Find Documents API - Expired Recipient Filter', () => {
|
||||
const PAST = new Date(Date.now() - 24 * 60 * 60 * 1000);
|
||||
const FUTURE = new Date(Date.now() + 24 * 60 * 60 * 1000);
|
||||
|
||||
test('hasExpiredRecipients=true returns only docs with an expired, unsigned, non-CC recipient', async ({
|
||||
request,
|
||||
}) => {
|
||||
const { user, team } = await seedUser();
|
||||
const { user: recipient } = await seedUser();
|
||||
|
||||
const { token } = await createApiToken({
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
tokenName: 'expired-token',
|
||||
expiresIn: null,
|
||||
});
|
||||
|
||||
const expiredDoc = await seedPendingDocument(user, team.id, [recipient], {
|
||||
createDocumentOptions: { title: 'Expired Recipient Doc' },
|
||||
});
|
||||
await prisma.recipient.updateMany({
|
||||
where: { envelopeId: expiredDoc.id },
|
||||
data: { expiresAt: PAST },
|
||||
});
|
||||
|
||||
const activeDoc = await seedPendingDocument(user, team.id, [recipient], {
|
||||
createDocumentOptions: { title: 'Active Recipient Doc' },
|
||||
});
|
||||
await prisma.recipient.updateMany({
|
||||
where: { envelopeId: activeDoc.id },
|
||||
data: { expiresAt: FUTURE },
|
||||
});
|
||||
|
||||
await seedPendingDocument(user, team.id, [recipient], {
|
||||
createDocumentOptions: { title: 'No Expiry Doc' },
|
||||
});
|
||||
|
||||
const { json } = await findDocuments(request, token, { hasExpiredRecipients: 'true' });
|
||||
const titles = json!.data.map((d) => d.title);
|
||||
expect(titles).toContain('Expired Recipient Doc');
|
||||
expect(titles).not.toContain('Active Recipient Doc');
|
||||
expect(titles).not.toContain('No Expiry Doc');
|
||||
expect(json!.count).toBe(1);
|
||||
});
|
||||
|
||||
test('hasExpiredRecipients=false (and omitted) does not filter by expiry', async ({ request }) => {
|
||||
const { user, team } = await seedUser();
|
||||
const { user: recipient } = await seedUser();
|
||||
|
||||
const { token } = await createApiToken({
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
tokenName: 'expired-false-token',
|
||||
expiresIn: null,
|
||||
});
|
||||
|
||||
const expiredDoc = await seedPendingDocument(user, team.id, [recipient], {
|
||||
createDocumentOptions: { title: 'Expired Doc' },
|
||||
});
|
||||
await prisma.recipient.updateMany({
|
||||
where: { envelopeId: expiredDoc.id },
|
||||
data: { expiresAt: PAST },
|
||||
});
|
||||
|
||||
await seedPendingDocument(user, team.id, [recipient], {
|
||||
createDocumentOptions: { title: 'Active Doc' },
|
||||
});
|
||||
|
||||
// "false" must NOT be coerced to true — both docs should be returned.
|
||||
const { json: falseJson } = await findDocuments(request, token, { hasExpiredRecipients: 'false' });
|
||||
expect(falseJson!.count).toBe(2);
|
||||
|
||||
const { json: omittedJson } = await findDocuments(request, token);
|
||||
expect(omittedJson!.count).toBe(2);
|
||||
});
|
||||
|
||||
test('excludes signed and CC recipients from the expired filter', async ({ request }) => {
|
||||
const { user, team } = await seedUser();
|
||||
const { user: recipient } = await seedUser();
|
||||
|
||||
const { token } = await createApiToken({
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
tokenName: 'expired-exclude-token',
|
||||
expiresIn: null,
|
||||
});
|
||||
|
||||
const signedDoc = await seedPendingDocument(user, team.id, [recipient], {
|
||||
createDocumentOptions: { title: 'Expired but Signed' },
|
||||
});
|
||||
await prisma.recipient.updateMany({
|
||||
where: { envelopeId: signedDoc.id },
|
||||
data: { expiresAt: PAST, signingStatus: SigningStatus.SIGNED },
|
||||
});
|
||||
|
||||
const ccDoc = await seedPendingDocument(user, team.id, [recipient], {
|
||||
createDocumentOptions: { title: 'Expired but CC' },
|
||||
});
|
||||
await prisma.recipient.updateMany({
|
||||
where: { envelopeId: ccDoc.id },
|
||||
data: { expiresAt: PAST, role: RecipientRole.CC },
|
||||
});
|
||||
|
||||
const validDoc = await seedPendingDocument(user, team.id, [recipient], {
|
||||
createDocumentOptions: { title: 'Expired Unsigned Signer' },
|
||||
});
|
||||
await prisma.recipient.updateMany({
|
||||
where: { envelopeId: validDoc.id },
|
||||
data: { expiresAt: PAST },
|
||||
});
|
||||
|
||||
const { json } = await findDocuments(request, token, { hasExpiredRecipients: 'true' });
|
||||
const titles = json!.data.map((d) => d.title);
|
||||
expect(titles).toContain('Expired Unsigned Signer');
|
||||
expect(titles).not.toContain('Expired but Signed');
|
||||
expect(titles).not.toContain('Expired but CC');
|
||||
expect(json!.count).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Adversarial: Expired Recipient Filter cross-tenant isolation ────────────
|
||||
// The expired filter adds an EXISTS subquery over Recipient. These tests ensure
|
||||
// that predicate never widens visibility past the caller's team/access scope.
|
||||
|
||||
test.describe('Find Documents API - Adversarial: Cross-Team Expired Recipient Filter', () => {
|
||||
const PAST = new Date(Date.now() - 24 * 60 * 60 * 1000);
|
||||
|
||||
test('token scoped to team A must NOT see team B docs with expired recipients', async ({ request }) => {
|
||||
const { user: userA, team: teamA } = await seedUser();
|
||||
const { user: userB, team: teamB } = await seedUser();
|
||||
const { user: recipient } = await seedUser();
|
||||
|
||||
const { token: tokenA } = await createApiToken({
|
||||
userId: userA.id,
|
||||
teamId: teamA.id,
|
||||
tokenName: 'teamA-expired-token',
|
||||
expiresIn: null,
|
||||
});
|
||||
|
||||
// Team A: one expired doc the caller is legitimately allowed to see.
|
||||
const teamADoc = await seedPendingDocument(userA, teamA.id, [recipient], {
|
||||
createDocumentOptions: { title: 'TeamA Expired Doc' },
|
||||
});
|
||||
await prisma.recipient.updateMany({
|
||||
where: { envelopeId: teamADoc.id },
|
||||
data: { expiresAt: PAST },
|
||||
});
|
||||
|
||||
// Team B: an expired doc that must remain invisible to team A's token.
|
||||
const teamBDoc = await seedPendingDocument(userB, teamB.id, [recipient], {
|
||||
createDocumentOptions: { title: 'TeamB Expired Doc' },
|
||||
});
|
||||
await prisma.recipient.updateMany({
|
||||
where: { envelopeId: teamBDoc.id },
|
||||
data: { expiresAt: PAST },
|
||||
});
|
||||
|
||||
const { json } = await findDocuments(request, tokenA, { hasExpiredRecipients: 'true' });
|
||||
const titles = json!.data.map((d) => d.title);
|
||||
expect(titles).toContain('TeamA Expired Doc');
|
||||
expect(titles).not.toContain('TeamB Expired Doc');
|
||||
expect(json!.count).toBe(1);
|
||||
});
|
||||
|
||||
test('shared recipient email across teams does not leak the other team expired docs', async ({ request }) => {
|
||||
// A recipient with the SAME email is on expired docs in both teams. The
|
||||
// filter must still scope strictly to the token's team.
|
||||
const { user: userA, team: teamA } = await seedUser();
|
||||
const { user: userB, team: teamB } = await seedUser();
|
||||
const { user: sharedRecipient } = await seedUser();
|
||||
|
||||
const { token: tokenB } = await createApiToken({
|
||||
userId: userB.id,
|
||||
teamId: teamB.id,
|
||||
tokenName: 'teamB-expired-token',
|
||||
expiresIn: null,
|
||||
});
|
||||
|
||||
const teamADoc = await seedPendingDocument(userA, teamA.id, [sharedRecipient], {
|
||||
createDocumentOptions: { title: 'TeamA Shared-Recipient Expired' },
|
||||
});
|
||||
await prisma.recipient.updateMany({
|
||||
where: { envelopeId: teamADoc.id },
|
||||
data: { expiresAt: PAST },
|
||||
});
|
||||
|
||||
const teamBDoc = await seedPendingDocument(userB, teamB.id, [sharedRecipient], {
|
||||
createDocumentOptions: { title: 'TeamB Shared-Recipient Expired' },
|
||||
});
|
||||
await prisma.recipient.updateMany({
|
||||
where: { envelopeId: teamBDoc.id },
|
||||
data: { expiresAt: PAST },
|
||||
});
|
||||
|
||||
const { json } = await findDocuments(request, tokenB, { hasExpiredRecipients: 'true' });
|
||||
const titles = json!.data.map((d) => d.title);
|
||||
expect(titles).toContain('TeamB Shared-Recipient Expired');
|
||||
expect(titles).not.toContain('TeamA Shared-Recipient Expired');
|
||||
expect(json!.count).toBe(1);
|
||||
});
|
||||
|
||||
test('x-team-id spoofing with status=EXPIRED is rejected for a non-member', async ({ page }) => {
|
||||
const { team: teamA, owner: ownerA } = await seedTeam();
|
||||
const { team: teamB, owner: ownerB } = await seedTeam();
|
||||
const { user: recipient } = await seedUser();
|
||||
|
||||
const teamADoc = await seedPendingDocument(ownerA, teamA.id, [recipient], {
|
||||
createDocumentOptions: { title: 'TeamA Expired Secret' },
|
||||
});
|
||||
await prisma.recipient.updateMany({
|
||||
where: { envelopeId: teamADoc.id },
|
||||
data: { expiresAt: PAST },
|
||||
});
|
||||
|
||||
// ownerB is NOT a member of teamA.
|
||||
await apiSignin({ page, email: ownerB.email });
|
||||
|
||||
const res = await trpcQuery(page, 'document.findDocumentsInternal', teamA.id, {
|
||||
status: 'EXPIRED',
|
||||
page: 1,
|
||||
perPage: 100,
|
||||
});
|
||||
|
||||
expect(res.ok()).toBeFalsy();
|
||||
expect(res.status()).toBe(404);
|
||||
});
|
||||
|
||||
test('EXPIRED pseudo-status via session only returns the caller team expired docs (positive control)', async ({
|
||||
page,
|
||||
}) => {
|
||||
const { team: teamA, owner: ownerA } = await seedTeam();
|
||||
const { team: teamB, owner: ownerB } = await seedTeam();
|
||||
const { user: recipient } = await seedUser();
|
||||
|
||||
const teamADoc = await seedPendingDocument(ownerA, teamA.id, [recipient], {
|
||||
createDocumentOptions: { title: 'TeamA Expired Visible' },
|
||||
});
|
||||
await prisma.recipient.updateMany({
|
||||
where: { envelopeId: teamADoc.id },
|
||||
data: { expiresAt: PAST },
|
||||
});
|
||||
|
||||
const teamBDoc = await seedPendingDocument(ownerB, teamB.id, [recipient], {
|
||||
createDocumentOptions: { title: 'TeamB Expired Hidden' },
|
||||
});
|
||||
await prisma.recipient.updateMany({
|
||||
where: { envelopeId: teamBDoc.id },
|
||||
data: { expiresAt: PAST },
|
||||
});
|
||||
|
||||
await apiSignin({ page, email: ownerA.email });
|
||||
|
||||
const res = await trpcQuery(page, 'document.findDocumentsInternal', teamA.id, {
|
||||
status: 'EXPIRED',
|
||||
page: 1,
|
||||
perPage: 100,
|
||||
});
|
||||
|
||||
expect(res.ok()).toBeTruthy();
|
||||
const data = await res.json();
|
||||
const docs = data.result.data.json.data;
|
||||
const titles = docs.map((d: { title: string }) => d.title);
|
||||
expect(titles).toContain('TeamA Expired Visible');
|
||||
expect(titles).not.toContain('TeamB Expired Hidden');
|
||||
});
|
||||
|
||||
test('EXPIRED stats count is scoped to the caller team and excludes other-team expired docs', async ({ page }) => {
|
||||
const { team: teamA, owner: ownerA } = await seedTeam();
|
||||
const { team: teamB, owner: ownerB } = await seedTeam();
|
||||
const { user: recipient } = await seedUser();
|
||||
|
||||
// One expired doc in team A.
|
||||
const teamADoc = await seedPendingDocument(ownerA, teamA.id, [recipient], {
|
||||
createDocumentOptions: { title: 'TeamA Expired For Stats' },
|
||||
});
|
||||
await prisma.recipient.updateMany({
|
||||
where: { envelopeId: teamADoc.id },
|
||||
data: { expiresAt: PAST },
|
||||
});
|
||||
|
||||
// Two expired docs in team B — must NOT bleed into team A's EXPIRED count.
|
||||
for (const title of ['TeamB Expired For Stats 1', 'TeamB Expired For Stats 2']) {
|
||||
const doc = await seedPendingDocument(ownerB, teamB.id, [recipient], {
|
||||
createDocumentOptions: { title },
|
||||
});
|
||||
await prisma.recipient.updateMany({
|
||||
where: { envelopeId: doc.id },
|
||||
data: { expiresAt: PAST },
|
||||
});
|
||||
}
|
||||
|
||||
await apiSignin({ page, email: ownerA.email });
|
||||
|
||||
const res = await trpcQuery(page, 'document.findDocumentsInternal', teamA.id, {
|
||||
page: 1,
|
||||
perPage: 100,
|
||||
});
|
||||
|
||||
expect(res.ok()).toBeTruthy();
|
||||
const data = await res.json();
|
||||
expect(data.result.data.json.stats.EXPIRED).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1055,3 +1055,120 @@ test.describe('Find Envelopes API - Cross-User Isolation', () => {
|
||||
expect(titles).not.toContain('Member Org Team Env');
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Find Envelopes API - Expired Recipient Filter', () => {
|
||||
test('hasExpiredRecipients=true returns only envelopes with an expired, unsigned recipient', async ({ request }) => {
|
||||
const { user, team } = await seedUser();
|
||||
const { user: recipient } = await seedUser();
|
||||
|
||||
const { token } = await createApiToken({
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
tokenName: 'env-expired-token',
|
||||
expiresIn: null,
|
||||
});
|
||||
|
||||
const expiredEnvelope = await seedPendingDocument(user, team.id, [recipient], {
|
||||
createDocumentOptions: { title: 'Expired Envelope' },
|
||||
});
|
||||
await prisma.recipient.updateMany({
|
||||
where: { envelopeId: expiredEnvelope.id },
|
||||
data: { expiresAt: new Date(Date.now() - 24 * 60 * 60 * 1000) },
|
||||
});
|
||||
|
||||
await seedPendingDocument(user, team.id, [recipient], {
|
||||
createDocumentOptions: { title: 'Active Envelope' },
|
||||
});
|
||||
|
||||
const { json } = await findEnvelopes(request, token, {
|
||||
type: EnvelopeType.DOCUMENT,
|
||||
hasExpiredRecipients: 'true',
|
||||
});
|
||||
const titles = json!.data.map((d) => d.title);
|
||||
expect(titles).toContain('Expired Envelope');
|
||||
expect(titles).not.toContain('Active Envelope');
|
||||
expect(json!.count).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Adversarial: Expired Recipient Filter cross-tenant isolation ────────────
|
||||
|
||||
test.describe('Find Envelopes API - Adversarial: Cross-Team Expired Recipient Filter', () => {
|
||||
const PAST = new Date(Date.now() - 24 * 60 * 60 * 1000);
|
||||
|
||||
test('token scoped to team A must NOT see team B envelopes with expired recipients', async ({ request }) => {
|
||||
const { user: userA, team: teamA } = await seedUser();
|
||||
const { user: userB, team: teamB } = await seedUser();
|
||||
const { user: recipient } = await seedUser();
|
||||
|
||||
const { token: tokenA } = await createApiToken({
|
||||
userId: userA.id,
|
||||
teamId: teamA.id,
|
||||
tokenName: 'env-teamA-expired-token',
|
||||
expiresIn: null,
|
||||
});
|
||||
|
||||
const teamAEnvelope = await seedPendingDocument(userA, teamA.id, [recipient], {
|
||||
createDocumentOptions: { title: 'TeamA Expired Envelope' },
|
||||
});
|
||||
await prisma.recipient.updateMany({
|
||||
where: { envelopeId: teamAEnvelope.id },
|
||||
data: { expiresAt: PAST },
|
||||
});
|
||||
|
||||
const teamBEnvelope = await seedPendingDocument(userB, teamB.id, [recipient], {
|
||||
createDocumentOptions: { title: 'TeamB Expired Envelope' },
|
||||
});
|
||||
await prisma.recipient.updateMany({
|
||||
where: { envelopeId: teamBEnvelope.id },
|
||||
data: { expiresAt: PAST },
|
||||
});
|
||||
|
||||
const { json } = await findEnvelopes(request, tokenA, {
|
||||
type: EnvelopeType.DOCUMENT,
|
||||
hasExpiredRecipients: 'true',
|
||||
});
|
||||
const titles = json!.data.map((d) => d.title);
|
||||
expect(titles).toContain('TeamA Expired Envelope');
|
||||
expect(titles).not.toContain('TeamB Expired Envelope');
|
||||
expect(json!.count).toBe(1);
|
||||
});
|
||||
|
||||
test('shared recipient email across teams does not leak the other team expired envelopes', async ({ request }) => {
|
||||
const { user: userA, team: teamA } = await seedUser();
|
||||
const { user: userB, team: teamB } = await seedUser();
|
||||
const { user: sharedRecipient } = await seedUser();
|
||||
|
||||
const { token: tokenB } = await createApiToken({
|
||||
userId: userB.id,
|
||||
teamId: teamB.id,
|
||||
tokenName: 'env-teamB-expired-token',
|
||||
expiresIn: null,
|
||||
});
|
||||
|
||||
const teamAEnvelope = await seedPendingDocument(userA, teamA.id, [sharedRecipient], {
|
||||
createDocumentOptions: { title: 'TeamA Shared Expired Envelope' },
|
||||
});
|
||||
await prisma.recipient.updateMany({
|
||||
where: { envelopeId: teamAEnvelope.id },
|
||||
data: { expiresAt: PAST },
|
||||
});
|
||||
|
||||
const teamBEnvelope = await seedPendingDocument(userB, teamB.id, [sharedRecipient], {
|
||||
createDocumentOptions: { title: 'TeamB Shared Expired Envelope' },
|
||||
});
|
||||
await prisma.recipient.updateMany({
|
||||
where: { envelopeId: teamBEnvelope.id },
|
||||
data: { expiresAt: PAST },
|
||||
});
|
||||
|
||||
const { json } = await findEnvelopes(request, tokenB, {
|
||||
type: EnvelopeType.DOCUMENT,
|
||||
hasExpiredRecipients: 'true',
|
||||
});
|
||||
const titles = json!.data.map((d) => d.title);
|
||||
expect(titles).toContain('TeamB Shared Expired Envelope');
|
||||
expect(titles).not.toContain('TeamA Shared Expired Envelope');
|
||||
expect(json!.count).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -37,7 +37,7 @@ import type { Organisation, Team, User } from '@prisma/client';
|
||||
*
|
||||
* --- GLOBAL LIMIT AWARENESS ---
|
||||
* apps/remix/server/router.ts applies a GLOBAL per-IP limiter to /api/v2/*:
|
||||
* apiV2RateLimit = 100 requests / 1 minute (see rate-limits.ts).
|
||||
* apiV2RateLimit = 1000 requests / 1 minute (see rate-limits.ts).
|
||||
* Every per-org limit/quota configured here is kept FAR below that ceiling (single
|
||||
* digits) and the suite runs serially so the shared-IP global bucket is never the
|
||||
* thing that trips. A global-limit 429 is shaped `{ error }` whereas an org-limit
|
||||
@@ -49,7 +49,7 @@ const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
|
||||
const baseUrl = `${WEBAPP_BASE_URL}/api/v2-beta`;
|
||||
|
||||
// Run serially: all workers share one IP, and the global /api/v2 limiter is
|
||||
// per-IP. Serial execution keeps the shared global bucket well under 100/min.
|
||||
// per-IP. Serial execution keeps the shared global bucket well under 1000/min.
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
|
||||
// This suite is only meaningful with real rate limiting enabled. CI sets the
|
||||
|
||||
+118
@@ -0,0 +1,118 @@
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import { seedDraftDocument, seedPendingDocument } from '@documenso/prisma/seed/documents';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { apiSignin } from '../../../fixtures/authentication';
|
||||
|
||||
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
|
||||
|
||||
test.describe.configure({
|
||||
mode: 'parallel',
|
||||
});
|
||||
|
||||
const downloadUrl = (envelopeId: string, envelopeItemId: string, version: 'original' | 'signed' | 'pending') =>
|
||||
`${WEBAPP_BASE_URL}/api/files/envelope/${envelopeId}/envelopeItem/${envelopeItemId}/download/${version}`;
|
||||
|
||||
const seedOwnerWithDraft = async () => {
|
||||
const owner = await seedUser();
|
||||
|
||||
const draft = await seedDraftDocument(owner.user, owner.team.id, [], {
|
||||
createDocumentOptions: { title: 'File Download Auth Test' },
|
||||
});
|
||||
|
||||
return { owner, draft, draftItem: draft.envelopeItems[0] };
|
||||
};
|
||||
|
||||
test.describe('Envelope item file download endpoint authorization', () => {
|
||||
test('rejects an unauthenticated download request', async ({ request }) => {
|
||||
const { draft, draftItem } = await seedOwnerWithDraft();
|
||||
|
||||
const res = await request.get(downloadUrl(draft.id, draftItem.id, 'original'));
|
||||
|
||||
expect(res.ok()).toBeFalsy();
|
||||
expect(res.status()).toBe(401);
|
||||
});
|
||||
|
||||
test('rejects a download request from a user outside the organisation', async ({ page }) => {
|
||||
const { draft, draftItem } = await seedOwnerWithDraft();
|
||||
const { user: outsider } = await seedUser();
|
||||
|
||||
await apiSignin({ page, email: outsider.email });
|
||||
|
||||
const res = await page.request.get(downloadUrl(draft.id, draftItem.id, 'original'));
|
||||
|
||||
expect(res.ok()).toBeFalsy();
|
||||
expect(res.status()).toBe(403);
|
||||
});
|
||||
|
||||
test('returns 404 for a nonexistent envelope', async ({ page }) => {
|
||||
const { user } = await seedUser();
|
||||
|
||||
await apiSignin({ page, email: user.email });
|
||||
|
||||
const res = await page.request.get(
|
||||
downloadUrl('envelope_does_not_exist', 'envelope_item_does_not_exist', 'original'),
|
||||
);
|
||||
|
||||
expect(res.ok()).toBeFalsy();
|
||||
expect(res.status()).toBe(404);
|
||||
});
|
||||
|
||||
test('rejects a pending version download for a draft envelope', async ({ page }) => {
|
||||
const { owner, draft, draftItem } = await seedOwnerWithDraft();
|
||||
|
||||
await apiSignin({ page, email: owner.user.email });
|
||||
|
||||
const res = await page.request.get(downloadUrl(draft.id, draftItem.id, 'pending'));
|
||||
|
||||
expect(res.ok()).toBeFalsy();
|
||||
expect(res.status()).toBe(400);
|
||||
});
|
||||
|
||||
test('rejects a pending version download for a legacy envelope', async ({ page }) => {
|
||||
const owner = await seedUser();
|
||||
const { user: recipient } = await seedUser();
|
||||
|
||||
// Default internalVersion is 1 (legacy).
|
||||
const pendingDocument = await seedPendingDocument(owner.user, owner.team.id, [recipient], {
|
||||
createDocumentOptions: { title: 'Legacy Pending Download Test' },
|
||||
});
|
||||
|
||||
const envelopeItem = pendingDocument.envelopeItems[0];
|
||||
|
||||
await apiSignin({ page, email: owner.user.email });
|
||||
|
||||
const res = await page.request.get(downloadUrl(pendingDocument.id, envelopeItem.id, 'pending'));
|
||||
|
||||
expect(res.ok()).toBeFalsy();
|
||||
expect(res.status()).toBe(400);
|
||||
});
|
||||
|
||||
test('allows the owner to download their own document', async ({ page }) => {
|
||||
const { owner, draft, draftItem } = await seedOwnerWithDraft();
|
||||
|
||||
await apiSignin({ page, email: owner.user.email });
|
||||
|
||||
const res = await page.request.get(downloadUrl(draft.id, draftItem.id, 'original'));
|
||||
|
||||
expect(res.ok()).toBeTruthy();
|
||||
expect(res.headers()['content-type']).toContain('application/pdf');
|
||||
|
||||
const body = await res.body();
|
||||
|
||||
// %PDF magic bytes.
|
||||
expect(Array.from(body.subarray(0, 4))).toEqual([0x25, 0x50, 0x44, 0x46]);
|
||||
});
|
||||
|
||||
test('rejects a recipient-token download with an invalid token', async ({ request }) => {
|
||||
const { draftItem } = await seedOwnerWithDraft();
|
||||
|
||||
const res = await request.get(
|
||||
`${WEBAPP_BASE_URL}/api/files/token/invalid-token-12345/envelopeItem/${draftItem.id}/download/original`,
|
||||
);
|
||||
|
||||
expect(res.ok()).toBeFalsy();
|
||||
expect(res.status()).toBe(404);
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,9 @@ import { seedUser } from '@documenso/prisma/seed/users';
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { apiSignin } from '../fixtures/authentication';
|
||||
import { openCommandMenu } from '../fixtures/command-menu';
|
||||
|
||||
const COMMAND_MENU_PLACEHOLDER = 'Type a command or search...';
|
||||
|
||||
test('[COMMAND_MENU]: should see sent documents', async ({ page }) => {
|
||||
const { user, team } = await seedUser();
|
||||
@@ -14,9 +17,9 @@ test('[COMMAND_MENU]: should see sent documents', async ({ page }) => {
|
||||
email: user.email,
|
||||
});
|
||||
|
||||
await page.keyboard.press('Meta+K');
|
||||
await openCommandMenu(page, COMMAND_MENU_PLACEHOLDER);
|
||||
|
||||
await page.getByPlaceholder('Type a command or search...').first().fill(document.title);
|
||||
await page.getByPlaceholder(COMMAND_MENU_PLACEHOLDER).first().fill(document.title);
|
||||
await expect(page.getByRole('option', { name: document.title })).toBeVisible();
|
||||
});
|
||||
|
||||
@@ -30,9 +33,9 @@ test('[COMMAND_MENU]: should see received documents', async ({ page }) => {
|
||||
email: recipient.email,
|
||||
});
|
||||
|
||||
await page.keyboard.press('Meta+K');
|
||||
await openCommandMenu(page, COMMAND_MENU_PLACEHOLDER);
|
||||
|
||||
await page.getByPlaceholder('Type a command or search...').first().fill(document.title);
|
||||
await page.getByPlaceholder(COMMAND_MENU_PLACEHOLDER).first().fill(document.title);
|
||||
await expect(page.getByRole('option', { name: document.title })).toBeVisible();
|
||||
});
|
||||
|
||||
@@ -46,8 +49,8 @@ test('[COMMAND_MENU]: should be able to search by recipient', async ({ page }) =
|
||||
email: user.email,
|
||||
});
|
||||
|
||||
await page.keyboard.press('Meta+K');
|
||||
await openCommandMenu(page, COMMAND_MENU_PLACEHOLDER);
|
||||
|
||||
await page.getByPlaceholder('Type a command or search...').first().fill(recipient.email);
|
||||
await page.getByPlaceholder(COMMAND_MENU_PLACEHOLDER).first().fill(recipient.email);
|
||||
await expect(page.getByRole('option', { name: document.title })).toBeVisible();
|
||||
});
|
||||
|
||||
@@ -2,7 +2,14 @@ import { prisma } from '@documenso/prisma';
|
||||
import { seedPendingDocumentWithFullFields } from '@documenso/prisma/seed/documents';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { DocumentSigningOrder, DocumentStatus, FieldType, RecipientRole, SigningStatus } from '@prisma/client';
|
||||
import {
|
||||
DocumentSigningOrder,
|
||||
DocumentStatus,
|
||||
FieldType,
|
||||
RecipientRole,
|
||||
SendStatus,
|
||||
SigningStatus,
|
||||
} from '@prisma/client';
|
||||
|
||||
import { signDirectSignaturePad, signSignaturePad } from '../fixtures/signature';
|
||||
|
||||
@@ -370,3 +377,221 @@ test('[NEXT_RECIPIENT_DICTATION]: should allow assistant to dictate next signer'
|
||||
expect(thirdRecipient.role).toBe(RecipientRole.SIGNER);
|
||||
}).toPass();
|
||||
});
|
||||
|
||||
test('[NEXT_RECIPIENT_DICTATION]: should skip CC recipient when dictating next signer', async ({ page }) => {
|
||||
const { user, team } = await seedUser();
|
||||
const { user: firstSigner } = await seedUser();
|
||||
const { user: ccUser } = await seedUser();
|
||||
const { user: secondSigner } = await seedUser();
|
||||
|
||||
const { recipients, document } = await seedPendingDocumentWithFullFields({
|
||||
owner: user,
|
||||
teamId: team.id,
|
||||
recipients: [firstSigner, ccUser, secondSigner],
|
||||
recipientsCreateOptions: [
|
||||
{ signingOrder: 1 },
|
||||
{
|
||||
// CC recipients are created pre-signed, mirroring production behaviour.
|
||||
signingOrder: 2,
|
||||
role: RecipientRole.CC,
|
||||
signingStatus: SigningStatus.SIGNED,
|
||||
sendStatus: SendStatus.SENT,
|
||||
},
|
||||
{ signingOrder: 3 },
|
||||
],
|
||||
updateDocumentOptions: {
|
||||
documentMeta: {
|
||||
upsert: {
|
||||
create: {
|
||||
allowDictateNextSigner: true,
|
||||
signingOrder: DocumentSigningOrder.SEQUENTIAL,
|
||||
},
|
||||
update: {
|
||||
allowDictateNextSigner: true,
|
||||
signingOrder: DocumentSigningOrder.SEQUENTIAL,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const firstRecipient = recipients.find((r) => r.email === firstSigner.email);
|
||||
const ccRecipient = recipients.find((r) => r.email === ccUser.email);
|
||||
|
||||
if (!firstRecipient || !ccRecipient) {
|
||||
throw new Error('Recipients not found');
|
||||
}
|
||||
|
||||
// CC recipients cannot have fields.
|
||||
await prisma.field.deleteMany({
|
||||
where: {
|
||||
recipientId: ccRecipient.id,
|
||||
},
|
||||
});
|
||||
|
||||
const { token, fields } = firstRecipient;
|
||||
|
||||
const signUrl = `/sign/${token}`;
|
||||
|
||||
await page.goto(signUrl);
|
||||
await expect(page.getByRole('heading', { name: 'Sign Document' })).toBeVisible();
|
||||
|
||||
await signSignaturePad(page);
|
||||
|
||||
// Fill in all fields
|
||||
for (const field of fields) {
|
||||
await page.locator(`#field-${field.id}`).getByRole('button').click();
|
||||
|
||||
if (field.type === FieldType.TEXT) {
|
||||
await page.locator('#custom-text').fill('TEXT');
|
||||
await page.getByRole('button', { name: 'Save' }).click();
|
||||
}
|
||||
|
||||
await expect(page.locator(`#field-${field.id}`)).toHaveAttribute('data-inserted', 'true');
|
||||
}
|
||||
|
||||
// Complete signing and verify the offered next recipient
|
||||
await page.getByRole('button', { name: 'Complete' }).click();
|
||||
|
||||
await expect(page.getByRole('dialog')).toBeVisible();
|
||||
await expect(page.getByText('Next Recipient Name')).toBeVisible();
|
||||
|
||||
// The dictation dialog must offer the second signer, not the CC recipient.
|
||||
const dialog = page.getByRole('dialog');
|
||||
await expect(dialog.getByLabel('Name')).toHaveValue(secondSigner.name ?? '');
|
||||
await expect(dialog.getByLabel('Email')).toHaveValue(secondSigner.email);
|
||||
|
||||
// Submit and verify completion
|
||||
await page.getByRole('button', { name: 'Sign' }).click();
|
||||
await page.waitForURL(`${signUrl}/complete`);
|
||||
|
||||
// Verify document and recipient states
|
||||
const updatedDocument = await prisma.envelope.findUniqueOrThrow({
|
||||
where: { id: document.id },
|
||||
include: {
|
||||
recipients: {
|
||||
orderBy: { signingOrder: 'asc' },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Document should still be pending as the second signer has not signed
|
||||
expect(updatedDocument.status).toBe(DocumentStatus.PENDING);
|
||||
|
||||
// The CC recipient must remain untouched
|
||||
const updatedCcRecipient = updatedDocument.recipients[1];
|
||||
expect(updatedCcRecipient.email).toBe(ccUser.email);
|
||||
expect(updatedCcRecipient.role).toBe(RecipientRole.CC);
|
||||
expect(updatedCcRecipient.signingStatus).toBe(SigningStatus.SIGNED);
|
||||
|
||||
// The second signer must remain the next pending recipient
|
||||
const updatedSecondRecipient = updatedDocument.recipients[2];
|
||||
expect(updatedSecondRecipient.email).toBe(secondSigner.email);
|
||||
expect(updatedSecondRecipient.signingOrder).toBe(3);
|
||||
expect(updatedSecondRecipient.signingStatus).toBe(SigningStatus.NOT_SIGNED);
|
||||
});
|
||||
|
||||
test('[NEXT_RECIPIENT_DICTATION]: should not offer dictation when CC recipient is last', async ({ page }) => {
|
||||
const { user, team } = await seedUser();
|
||||
const { user: firstSigner } = await seedUser();
|
||||
const { user: secondSigner } = await seedUser();
|
||||
const { user: ccUser } = await seedUser();
|
||||
|
||||
const { recipients, document } = await seedPendingDocumentWithFullFields({
|
||||
owner: user,
|
||||
teamId: team.id,
|
||||
recipients: [firstSigner, secondSigner, ccUser],
|
||||
recipientsCreateOptions: [
|
||||
{ signingOrder: 1 },
|
||||
{ signingOrder: 2 },
|
||||
{
|
||||
// CC recipients are created pre-signed, mirroring production behaviour.
|
||||
signingOrder: 3,
|
||||
role: RecipientRole.CC,
|
||||
signingStatus: SigningStatus.SIGNED,
|
||||
sendStatus: SendStatus.SENT,
|
||||
},
|
||||
],
|
||||
updateDocumentOptions: {
|
||||
documentMeta: {
|
||||
upsert: {
|
||||
create: {
|
||||
allowDictateNextSigner: true,
|
||||
signingOrder: DocumentSigningOrder.SEQUENTIAL,
|
||||
},
|
||||
update: {
|
||||
allowDictateNextSigner: true,
|
||||
signingOrder: DocumentSigningOrder.SEQUENTIAL,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const firstRecipient = recipients.find((r) => r.email === firstSigner.email);
|
||||
const secondRecipient = recipients.find((r) => r.email === secondSigner.email);
|
||||
const ccRecipient = recipients.find((r) => r.email === ccUser.email);
|
||||
|
||||
if (!firstRecipient || !secondRecipient || !ccRecipient) {
|
||||
throw new Error('Recipients not found');
|
||||
}
|
||||
|
||||
// CC recipients cannot have fields.
|
||||
await prisma.field.deleteMany({
|
||||
where: {
|
||||
recipientId: ccRecipient.id,
|
||||
},
|
||||
});
|
||||
|
||||
// Sign as both signers in order.
|
||||
for (const recipient of [firstRecipient, secondRecipient]) {
|
||||
const { token, fields } = recipient;
|
||||
|
||||
const signUrl = `/sign/${token}`;
|
||||
|
||||
await page.goto(signUrl);
|
||||
await expect(page.getByRole('heading', { name: 'Sign Document' })).toBeVisible();
|
||||
|
||||
await signSignaturePad(page);
|
||||
|
||||
// Fill in all fields
|
||||
for (const field of fields) {
|
||||
await page.locator(`#field-${field.id}`).getByRole('button').click();
|
||||
|
||||
if (field.type === FieldType.TEXT) {
|
||||
await page.locator('#custom-text').fill('TEXT');
|
||||
await page.getByRole('button', { name: 'Save' }).click();
|
||||
}
|
||||
|
||||
await expect(page.locator(`#field-${field.id}`)).toHaveAttribute('data-inserted', 'true');
|
||||
}
|
||||
|
||||
// Complete signing
|
||||
await page.getByRole('button', { name: 'Complete' }).click();
|
||||
|
||||
await expect(page.getByRole('dialog')).toBeVisible();
|
||||
|
||||
if (recipient.id === secondRecipient.id) {
|
||||
// The last actionable signer must not be offered the CC recipient.
|
||||
await expect(page.getByText('Next Recipient Name')).not.toBeVisible();
|
||||
}
|
||||
|
||||
// Submit and verify completion
|
||||
await page.getByRole('button', { name: 'Sign' }).click();
|
||||
await page.waitForURL(`${signUrl}/complete`);
|
||||
}
|
||||
|
||||
// The document completes without any action from the CC recipient.
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const finalDocument = await prisma.envelope.findUniqueOrThrow({
|
||||
where: { id: document.id },
|
||||
});
|
||||
|
||||
return finalDocument.status;
|
||||
},
|
||||
{ timeout: 30_000 },
|
||||
)
|
||||
.toBe(DocumentStatus.COMPLETED);
|
||||
});
|
||||
|
||||
@@ -4,7 +4,14 @@ import { prisma } from '@documenso/prisma';
|
||||
import { seedBlankDocument, seedPendingDocumentWithFullFields } from '@documenso/prisma/seed/documents';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { DocumentSigningOrder, DocumentStatus, FieldType, RecipientRole, SigningStatus } from '@prisma/client';
|
||||
import {
|
||||
DocumentSigningOrder,
|
||||
DocumentStatus,
|
||||
FieldType,
|
||||
RecipientRole,
|
||||
SendStatus,
|
||||
SigningStatus,
|
||||
} from '@prisma/client';
|
||||
import { DateTime } from 'luxon';
|
||||
|
||||
import { apiSignin } from '../fixtures/authentication';
|
||||
@@ -225,15 +232,20 @@ test('[DOCUMENT_FLOW]: should be able to create a document with multiple recipie
|
||||
await page.getByLabel('Receives copy').click();
|
||||
await page.getByRole('button', { name: 'Add Signer' }).click();
|
||||
|
||||
await page.getByLabel('Email').nth(2).fill('user3@example.com');
|
||||
await page.getByLabel('Name').nth(2).fill('User 3');
|
||||
await page.getByRole('combobox').nth(2).click();
|
||||
// CC recipients are kept last, so new rows are inserted above the CC row.
|
||||
await expect(page.getByLabel('Email')).toHaveCount(3);
|
||||
|
||||
await page.getByLabel('Email').nth(1).fill('user3@example.com');
|
||||
await page.getByLabel('Name').nth(1).fill('User 3');
|
||||
await page.getByRole('combobox').nth(1).click();
|
||||
await page.getByLabel('Needs to approve').click();
|
||||
await page.getByRole('button', { name: 'Add Signer' }).click();
|
||||
|
||||
await page.getByLabel('Email').nth(3).fill('user4@example.com');
|
||||
await page.getByLabel('Name').nth(3).fill('User 4');
|
||||
await page.getByRole('combobox').nth(3).click();
|
||||
await expect(page.getByLabel('Email')).toHaveCount(4);
|
||||
|
||||
await page.getByLabel('Email').nth(2).fill('user4@example.com');
|
||||
await page.getByLabel('Name').nth(2).fill('User 4');
|
||||
await page.getByRole('combobox').nth(2).click();
|
||||
await page.getByLabel('Needs to view').click();
|
||||
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
@@ -661,3 +673,182 @@ test('[DOCUMENT_FLOW]: should prevent out-of-order signing in sequential mode',
|
||||
await expect(page).not.toHaveURL(`/sign/${activeRecipient?.token}/waiting`);
|
||||
await expect(page.getByRole('heading', { name: 'Sign Document' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('[DOCUMENT_FLOW]: should skip CC recipients in sequential signing order', async ({ page }) => {
|
||||
const { user, team } = await seedUser();
|
||||
|
||||
const { document, recipients } = await seedPendingDocumentWithFullFields({
|
||||
teamId: team.id,
|
||||
owner: user,
|
||||
recipients: ['signer1@example.com', 'cc@example.com', 'signer2@example.com'],
|
||||
fields: [FieldType.SIGNATURE],
|
||||
recipientsCreateOptions: [
|
||||
{ signingOrder: 1 },
|
||||
{
|
||||
// CC recipients are created pre-signed, mirroring production behaviour.
|
||||
signingOrder: 2,
|
||||
role: RecipientRole.CC,
|
||||
signingStatus: SigningStatus.SIGNED,
|
||||
sendStatus: SendStatus.SENT,
|
||||
},
|
||||
{ signingOrder: 3 },
|
||||
],
|
||||
});
|
||||
|
||||
await prisma.documentMeta.update({
|
||||
where: {
|
||||
id: document.documentMetaId,
|
||||
},
|
||||
data: {
|
||||
signingOrder: DocumentSigningOrder.SEQUENTIAL,
|
||||
},
|
||||
});
|
||||
|
||||
const firstSigner = recipients.find((r) => r.email === 'signer1@example.com');
|
||||
const ccRecipient = recipients.find((r) => r.email === 'cc@example.com');
|
||||
const lastSigner = recipients.find((r) => r.email === 'signer2@example.com');
|
||||
|
||||
// CC recipients cannot have fields.
|
||||
await prisma.field.deleteMany({
|
||||
where: {
|
||||
recipientId: ccRecipient?.id,
|
||||
},
|
||||
});
|
||||
|
||||
// Sequential order is enforced: the last signer must wait while the first signer is pending.
|
||||
await page.goto(`/sign/${lastSigner?.token}`);
|
||||
await expect(page).toHaveURL(`/sign/${lastSigner?.token}/waiting`);
|
||||
|
||||
// Sign as the first signer.
|
||||
await page.goto(`/sign/${firstSigner?.token}`);
|
||||
await expect(page.getByRole('heading', { name: 'Sign Document' })).toBeVisible();
|
||||
await signSignaturePad(page);
|
||||
|
||||
const firstSignerField = await prisma.field.findFirstOrThrow({
|
||||
where: { recipientId: firstSigner?.id },
|
||||
});
|
||||
|
||||
await page.locator(`#field-${firstSignerField.id}`).getByRole('button').click();
|
||||
|
||||
await page.getByRole('button', { name: 'Complete' }).click();
|
||||
await page.getByRole('button', { name: 'Sign' }).click();
|
||||
await page.waitForURL(`/sign/${firstSigner?.token}/complete`);
|
||||
|
||||
// The CC recipient at order 2 must not block the last signer at order 3.
|
||||
await page.goto(`/sign/${lastSigner?.token}`);
|
||||
await expect(page).not.toHaveURL(`/sign/${lastSigner?.token}/waiting`);
|
||||
await expect(page.getByRole('heading', { name: 'Sign Document' })).toBeVisible();
|
||||
|
||||
await signSignaturePad(page);
|
||||
|
||||
const lastSignerField = await prisma.field.findFirstOrThrow({
|
||||
where: { recipientId: lastSigner?.id },
|
||||
});
|
||||
|
||||
await page.locator(`#field-${lastSignerField.id}`).getByRole('button').click();
|
||||
|
||||
await page.getByRole('button', { name: 'Complete' }).click();
|
||||
await page.getByRole('button', { name: 'Sign' }).click();
|
||||
await page.waitForURL(`/sign/${lastSigner?.token}/complete`);
|
||||
|
||||
// The document completes without any action from the CC recipient.
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const finalDocument = await prisma.envelope.findFirstOrThrow({
|
||||
where: { id: document.id },
|
||||
});
|
||||
|
||||
return finalDocument.status;
|
||||
},
|
||||
{ timeout: 30_000 },
|
||||
)
|
||||
.toBe(DocumentStatus.COMPLETED);
|
||||
});
|
||||
|
||||
test('[DOCUMENT_FLOW]: should skip unsigned CC recipients in sequential signing order', async ({ page }) => {
|
||||
const { user, team } = await seedUser();
|
||||
|
||||
const { document, recipients } = await seedPendingDocumentWithFullFields({
|
||||
teamId: team.id,
|
||||
owner: user,
|
||||
recipients: ['signer1@example.com', 'cc@example.com', 'signer2@example.com'],
|
||||
fields: [FieldType.SIGNATURE],
|
||||
recipientsCreateOptions: [
|
||||
{ signingOrder: 1 },
|
||||
{
|
||||
// Legacy/inconsistent data: a CC recipient that was never marked as signed.
|
||||
signingOrder: 2,
|
||||
role: RecipientRole.CC,
|
||||
signingStatus: SigningStatus.NOT_SIGNED,
|
||||
},
|
||||
{ signingOrder: 3 },
|
||||
],
|
||||
});
|
||||
|
||||
await prisma.documentMeta.update({
|
||||
where: {
|
||||
id: document.documentMetaId,
|
||||
},
|
||||
data: {
|
||||
signingOrder: DocumentSigningOrder.SEQUENTIAL,
|
||||
},
|
||||
});
|
||||
|
||||
const firstSigner = recipients.find((r) => r.email === 'signer1@example.com');
|
||||
const ccRecipient = recipients.find((r) => r.email === 'cc@example.com');
|
||||
const lastSigner = recipients.find((r) => r.email === 'signer2@example.com');
|
||||
|
||||
// CC recipients cannot have fields.
|
||||
await prisma.field.deleteMany({
|
||||
where: {
|
||||
recipientId: ccRecipient?.id,
|
||||
},
|
||||
});
|
||||
|
||||
// Sign as the first signer.
|
||||
await page.goto(`/sign/${firstSigner?.token}`);
|
||||
await expect(page.getByRole('heading', { name: 'Sign Document' })).toBeVisible();
|
||||
await signSignaturePad(page);
|
||||
|
||||
const firstSignerField = await prisma.field.findFirstOrThrow({
|
||||
where: { recipientId: firstSigner?.id },
|
||||
});
|
||||
|
||||
await page.locator(`#field-${firstSignerField.id}`).getByRole('button').click();
|
||||
|
||||
await page.getByRole('button', { name: 'Complete' }).click();
|
||||
await page.getByRole('button', { name: 'Sign' }).click();
|
||||
await page.waitForURL(`/sign/${firstSigner?.token}/complete`);
|
||||
|
||||
// The unsigned CC recipient at order 2 must not block the last signer at order 3.
|
||||
await page.goto(`/sign/${lastSigner?.token}`);
|
||||
await expect(page).not.toHaveURL(`/sign/${lastSigner?.token}/waiting`);
|
||||
await expect(page.getByRole('heading', { name: 'Sign Document' })).toBeVisible();
|
||||
|
||||
await signSignaturePad(page);
|
||||
|
||||
const lastSignerField = await prisma.field.findFirstOrThrow({
|
||||
where: { recipientId: lastSigner?.id },
|
||||
});
|
||||
|
||||
await page.locator(`#field-${lastSignerField.id}`).getByRole('button').click();
|
||||
|
||||
await page.getByRole('button', { name: 'Complete' }).click();
|
||||
await page.getByRole('button', { name: 'Sign' }).click();
|
||||
await page.waitForURL(`/sign/${lastSigner?.token}/complete`);
|
||||
|
||||
// The document completes without any action from the CC recipient.
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const finalDocument = await prisma.envelope.findFirstOrThrow({
|
||||
where: { id: document.id },
|
||||
});
|
||||
|
||||
return finalDocument.status;
|
||||
},
|
||||
{ timeout: 30_000 },
|
||||
)
|
||||
.toBe(DocumentStatus.COMPLETED);
|
||||
});
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import fs from 'node:fs';
|
||||
import { createTeam } from '@documenso/lib/server-only/team/create-team';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { seedCompletedDocument, seedDraftDocument, seedPendingDocument } from '@documenso/prisma/seed/documents';
|
||||
import { seedBlankFolder } from '@documenso/prisma/seed/folders';
|
||||
@@ -5,6 +7,7 @@ import { seedTeam, seedTeamMember } from '@documenso/prisma/seed/teams';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { DocumentStatus, TeamMemberRole } from '@prisma/client';
|
||||
import { unzipSync } from 'fflate';
|
||||
|
||||
import { apiSignin, apiSignout } from '../fixtures/authentication';
|
||||
import { expectToastTextToBeVisible } from '../fixtures/generic';
|
||||
@@ -50,10 +53,10 @@ test('[BULK_ACTIONS]: can select multiple documents with checkboxes', async ({ p
|
||||
});
|
||||
|
||||
await page.locator('tr', { hasText: 'Bulk Test Doc 1' }).getByRole('checkbox').click();
|
||||
await expect(page.getByText('1 selected')).toBeVisible();
|
||||
await expect(page.getByText(/1\s*selected/)).toBeVisible();
|
||||
|
||||
await page.locator('tr', { hasText: 'Bulk Test Doc 2' }).getByRole('checkbox').click();
|
||||
await expect(page.getByText('2 selected')).toBeVisible();
|
||||
await expect(page.getByText(/2\s*selected/)).toBeVisible();
|
||||
});
|
||||
|
||||
test('[BULK_ACTIONS]: header checkbox selects all documents on page', async ({ page }) => {
|
||||
@@ -67,7 +70,7 @@ test('[BULK_ACTIONS]: header checkbox selects all documents on page', async ({ p
|
||||
|
||||
await page.locator('thead').getByRole('checkbox').click();
|
||||
|
||||
await expect(page.getByText(`${documents.length} selected`)).toBeVisible();
|
||||
await expect(page.getByText(new RegExp(`${documents.length}\\s*selected`))).toBeVisible();
|
||||
});
|
||||
|
||||
test('[BULK_ACTIONS]: can clear selection with X button', async ({ page }) => {
|
||||
@@ -80,11 +83,11 @@ test('[BULK_ACTIONS]: can clear selection with X button', async ({ page }) => {
|
||||
});
|
||||
|
||||
await page.locator('thead').getByRole('checkbox').click();
|
||||
await expect(page.getByText(/\d+ selected/)).toBeVisible();
|
||||
await expect(page.getByText(/\d+\s*selected/)).toBeVisible();
|
||||
|
||||
await page.getByLabel('Clear selection').click();
|
||||
|
||||
await expect(page.getByText(/\d+ selected/)).not.toBeVisible();
|
||||
await expect(page.getByText(/\d+\s*selected/)).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('[BULK_ACTIONS]: can move multiple documents to a folder', async ({ page }) => {
|
||||
@@ -98,13 +101,13 @@ test('[BULK_ACTIONS]: can move multiple documents to a folder', async ({ page })
|
||||
|
||||
await page.locator('tr', { hasText: 'Bulk Test Doc 1' }).getByRole('checkbox').click();
|
||||
await page.locator('tr', { hasText: 'Bulk Test Doc 2' }).getByRole('checkbox').click();
|
||||
await page.getByRole('button', { name: 'Move to Folder' }).click();
|
||||
await page.getByRole('button', { name: 'Move', exact: true }).click();
|
||||
|
||||
await expect(page.getByRole('dialog')).toBeVisible();
|
||||
await expect(page.getByText('Move Documents to Folder')).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: folder.name }).click();
|
||||
await page.getByRole('button', { name: 'Move' }).click();
|
||||
await page.getByRole('dialog').getByRole('button', { name: 'Move' }).click();
|
||||
|
||||
await expectToastTextToBeVisible(page, 'Selected items have been moved.');
|
||||
|
||||
@@ -113,6 +116,122 @@ test('[BULK_ACTIONS]: can move multiple documents to a folder', async ({ page })
|
||||
await expect(page.getByRole('link', { name: 'Bulk Test Doc 2' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('[BULK_ACTIONS]: selection does not leak between teams', async ({ page }) => {
|
||||
const { sender } = await seedBulkActionsTestRequirements();
|
||||
|
||||
const teamBUrl = `team-b-${Date.now()}`;
|
||||
|
||||
await createTeam({
|
||||
userId: sender.user.id,
|
||||
teamName: 'Team B',
|
||||
teamUrl: teamBUrl,
|
||||
organisationId: sender.organisation.id,
|
||||
inheritMembers: true,
|
||||
});
|
||||
|
||||
const teamB = await prisma.team.findFirstOrThrow({
|
||||
where: { url: teamBUrl },
|
||||
});
|
||||
|
||||
await seedDraftDocument(sender.user, teamB.id, [], {
|
||||
createDocumentOptions: { title: 'Team B Doc' },
|
||||
});
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: sender.user.email,
|
||||
redirectPath: `/t/${sender.team.url}/documents`,
|
||||
});
|
||||
|
||||
await page.locator('tr', { hasText: 'Bulk Test Doc 1' }).getByRole('checkbox').click();
|
||||
await expect(page.getByText(/1\s*selected/)).toBeVisible();
|
||||
|
||||
// The selection made in team A must not appear in team B.
|
||||
await page.goto(`/t/${teamBUrl}/documents`);
|
||||
await expect(page.getByRole('link', { name: 'Team B Doc' })).toBeVisible();
|
||||
await expect(page.getByText(/\d+\s*selected/)).not.toBeVisible();
|
||||
|
||||
// Returning to team A restores its selection.
|
||||
await page.goto(`/t/${sender.team.url}/documents`);
|
||||
await expect(page.getByText(/1\s*selected/)).toBeVisible();
|
||||
});
|
||||
|
||||
test('[BULK_ACTIONS]: escape clears selection unless a dialog is open', async ({ page }) => {
|
||||
const { sender } = await seedBulkActionsTestRequirements();
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: sender.user.email,
|
||||
redirectPath: `/t/${sender.team.url}/documents`,
|
||||
});
|
||||
|
||||
await page.locator('tr', { hasText: 'Bulk Test Doc 1' }).getByRole('checkbox').click();
|
||||
await expect(page.getByText(/1\s*selected/)).toBeVisible();
|
||||
|
||||
// Escape while a dialog is open should close the dialog but keep the selection.
|
||||
await page.getByRole('button', { name: 'Move', exact: true }).click();
|
||||
await expect(page.getByRole('dialog')).toBeVisible();
|
||||
|
||||
await page.keyboard.press('Escape');
|
||||
|
||||
await expect(page.getByRole('dialog')).not.toBeVisible();
|
||||
await expect(page.getByText(/1\s*selected/)).toBeVisible();
|
||||
|
||||
// Escape with no dialog open should clear the selection.
|
||||
await page.keyboard.press('Escape');
|
||||
|
||||
await expect(page.getByText(/1\s*selected/)).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('[BULK_ACTIONS]: can bulk download multiple documents as a zip', async ({ page }) => {
|
||||
const { sender, documents } = await seedBulkActionsTestRequirements();
|
||||
|
||||
const [doc1, doc2] = documents;
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: sender.user.email,
|
||||
redirectPath: `/t/${sender.team.url}/documents`,
|
||||
});
|
||||
|
||||
await page.locator('tr', { hasText: 'Bulk Test Doc 1' }).getByRole('checkbox').click();
|
||||
await page.locator('tr', { hasText: 'Bulk Test Doc 2' }).getByRole('checkbox').click();
|
||||
|
||||
await page.getByRole('button', { name: 'Download', exact: true }).click();
|
||||
|
||||
const dialog = page.getByRole('dialog');
|
||||
|
||||
await expect(dialog).toBeVisible();
|
||||
await expect(dialog.getByText('Download Documents')).toBeVisible();
|
||||
await expect(dialog.getByText('Bulk Test Doc 1')).toBeVisible();
|
||||
await expect(dialog.getByText('Bulk Test Doc 2')).toBeVisible();
|
||||
await expect(dialog.getByText('Draft').first()).toBeVisible();
|
||||
|
||||
const downloadPromise = page.waitForEvent('download', { timeout: 10_000 });
|
||||
|
||||
await dialog.getByRole('button', { name: 'Download' }).click();
|
||||
|
||||
const download = await downloadPromise;
|
||||
|
||||
expect(download.suggestedFilename()).toMatch(/^documenso-documents-\d{4}-\d{2}-\d{2}\.zip$/);
|
||||
|
||||
const downloadPath = await download.path();
|
||||
const zipContents = unzipSync(new Uint8Array(fs.readFileSync(downloadPath)));
|
||||
|
||||
// Each envelope's files are nested inside an `envelopeId_title` folder.
|
||||
expect(Object.keys(zipContents).sort()).toEqual(
|
||||
[`${doc1.id}_Bulk Test Doc 1/Bulk Test Doc 1.pdf`, `${doc2.id}_Bulk Test Doc 2/Bulk Test Doc 2.pdf`].sort(),
|
||||
);
|
||||
|
||||
// Each entry should be a valid non-empty PDF (%PDF magic bytes).
|
||||
for (const entry of Object.values(zipContents)) {
|
||||
expect(Array.from(entry.slice(0, 4))).toEqual([0x25, 0x50, 0x44, 0x46]);
|
||||
}
|
||||
|
||||
await expectToastTextToBeVisible(page, 'Documents downloaded');
|
||||
await expect(page.getByText(/\d+\s*selected/)).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('[BULK_ACTIONS]: can delete multiple draft documents', async ({ page }) => {
|
||||
const { sender } = await seedBulkActionsTestRequirements();
|
||||
|
||||
@@ -152,14 +271,14 @@ test('[BULK_ACTIONS]: selection clears after successful move', async ({ page })
|
||||
});
|
||||
|
||||
await page.locator('tr', { hasText: 'Bulk Test Doc 1' }).getByRole('checkbox').click();
|
||||
await expect(page.getByText('1 selected')).toBeVisible();
|
||||
await expect(page.getByText(/1\s*selected/)).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Move to Folder' }).click();
|
||||
await page.getByRole('button', { name: 'Move', exact: true }).click();
|
||||
await page.getByRole('button', { name: folder.name }).click();
|
||||
await page.getByRole('button', { name: 'Move' }).click();
|
||||
await page.getByRole('dialog').getByRole('button', { name: 'Move' }).click();
|
||||
|
||||
await expectToastTextToBeVisible(page, 'Selected items have been moved.');
|
||||
await expect(page.getByText(/\d+ selected/)).not.toBeVisible();
|
||||
await expect(page.getByText(/\d+\s*selected/)).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('[BULK_ACTIONS]: selection clears after successful delete', async ({ page }) => {
|
||||
@@ -172,13 +291,13 @@ test('[BULK_ACTIONS]: selection clears after successful delete', async ({ page }
|
||||
});
|
||||
|
||||
await page.locator('tr', { hasText: 'Bulk Test Doc 1' }).getByRole('checkbox').click();
|
||||
await expect(page.getByText('1 selected')).toBeVisible();
|
||||
await expect(page.getByText(/1\s*selected/)).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Delete' }).click();
|
||||
await page.getByRole('dialog').getByRole('button', { name: 'Delete' }).click();
|
||||
|
||||
await expectToastTextToBeVisible(page, 'Documents deleted');
|
||||
await expect(page.getByText(/\d+ selected/)).not.toBeVisible();
|
||||
await expect(page.getByText(/\d+\s*selected/)).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('[BULK_ACTIONS]: can search for folders in move dialog', async ({ page }) => {
|
||||
@@ -199,7 +318,7 @@ test('[BULK_ACTIONS]: can search for folders in move dialog', async ({ page }) =
|
||||
|
||||
await page.locator('tr', { hasText: 'Bulk Test Doc 1' }).getByRole('checkbox').click();
|
||||
|
||||
await page.getByRole('button', { name: 'Move to Folder' }).click();
|
||||
await page.getByRole('button', { name: 'Move', exact: true }).click();
|
||||
await expect(page.getByRole('dialog')).toBeVisible();
|
||||
|
||||
await expect(page.getByRole('button', { name: folder.name })).toBeVisible();
|
||||
@@ -236,14 +355,14 @@ test('[BULK_ACTIONS]: can move documents from folder to home (root)', async ({ p
|
||||
await expect(page.getByRole('link', { name: 'Bulk Test Doc 1' })).toBeVisible();
|
||||
|
||||
await page.locator('tr', { hasText: 'Bulk Test Doc 1' }).getByRole('checkbox').click();
|
||||
await expect(page.getByText('1 selected')).toBeVisible();
|
||||
await expect(page.getByText(/1\s*selected/)).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Move to Folder' }).click();
|
||||
await page.getByRole('button', { name: 'Move', exact: true }).click();
|
||||
await expect(page.getByRole('dialog')).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Home (No Folder)' }).click();
|
||||
|
||||
await page.getByRole('button', { name: 'Move' }).click();
|
||||
await page.getByRole('dialog').getByRole('button', { name: 'Move' }).click();
|
||||
|
||||
await expectToastTextToBeVisible(page, 'Selected items have been moved.');
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ 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 { checkDocumentCounts, selectDocumentStatusFilter } from '../fixtures/documents';
|
||||
import { expectToastTextToBeVisible, openDropdownMenu } from '../fixtures/generic';
|
||||
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
@@ -61,13 +61,10 @@ test('[DOCUMENTS]: cancelling a pending document keeps it in the owner dashboard
|
||||
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);
|
||||
await checkDocumentCounts(page, { inbox: 0, pending: 0, cancelled: 1, all: 1 });
|
||||
|
||||
// The cancelled document is still listed.
|
||||
await page.getByRole('tab', { name: 'Cancelled' }).click();
|
||||
await selectDocumentStatusFilter(page, 'Cancelled');
|
||||
await expect(page.getByRole('link', { name: 'Document 1 - Pending' })).toBeVisible();
|
||||
|
||||
// The envelope status is persisted as CANCELLED.
|
||||
@@ -131,7 +128,7 @@ test('[DOCUMENTS]: a cancelled document can be deleted, hiding it from the owner
|
||||
await expectToastTextToBeVisible(page, 'Document cancelled');
|
||||
|
||||
// Delete the now-cancelled document. Being terminal, it should soft delete (hide).
|
||||
await page.getByRole('tab', { name: 'Cancelled' }).click();
|
||||
await selectDocumentStatusFilter(page, 'Cancelled');
|
||||
|
||||
const documentActionBtn = page
|
||||
.locator('tr', { hasText: 'Document 1 - Pending' })
|
||||
|
||||
@@ -3,7 +3,7 @@ import { seedUser } from '@documenso/prisma/seed/users';
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { apiSignin, apiSignout } from '../fixtures/authentication';
|
||||
import { checkDocumentTabCount } from '../fixtures/documents';
|
||||
import { checkDocumentCounts } from '../fixtures/documents';
|
||||
import { expectToastTextToBeVisible, openDropdownMenu } from '../fixtures/generic';
|
||||
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
@@ -174,11 +174,7 @@ test('[DOCUMENTS]: deleting draft documents should permanently remove it', async
|
||||
await expect(page.getByRole('row', { name: /Document 1 - Draft/ })).not.toBeVisible();
|
||||
|
||||
// Check document counts.
|
||||
await checkDocumentTabCount(page, 'Inbox', 0);
|
||||
await checkDocumentTabCount(page, 'Pending', 1);
|
||||
await checkDocumentTabCount(page, 'Completed', 1);
|
||||
await checkDocumentTabCount(page, 'Draft', 0);
|
||||
await checkDocumentTabCount(page, 'All', 2);
|
||||
await checkDocumentCounts(page, { inbox: 0, pending: 1, completed: 1, draft: 0, all: 2 });
|
||||
});
|
||||
|
||||
test('[DOCUMENTS]: deleting pending documents should permanently remove it', async ({ page }) => {
|
||||
@@ -207,11 +203,7 @@ test('[DOCUMENTS]: deleting pending documents should permanently remove it', asy
|
||||
await expect(page.getByRole('row', { name: /Document 1 - Pending/ })).not.toBeVisible();
|
||||
|
||||
// Check document counts.
|
||||
await checkDocumentTabCount(page, 'Inbox', 0);
|
||||
await checkDocumentTabCount(page, 'Pending', 0);
|
||||
await checkDocumentTabCount(page, 'Completed', 1);
|
||||
await checkDocumentTabCount(page, 'Draft', 1);
|
||||
await checkDocumentTabCount(page, 'All', 2);
|
||||
await checkDocumentCounts(page, { inbox: 0, pending: 0, completed: 1, draft: 1, all: 2 });
|
||||
});
|
||||
|
||||
test('[DOCUMENTS]: deleting completed documents as an owner should hide it from only the owner', async ({ page }) => {
|
||||
@@ -239,11 +231,7 @@ test('[DOCUMENTS]: deleting completed documents as an owner should hide it from
|
||||
|
||||
// Check document counts.
|
||||
await expect(page.getByRole('row', { name: /Document 1 - Completed/ })).not.toBeVisible();
|
||||
await checkDocumentTabCount(page, 'Inbox', 0);
|
||||
await checkDocumentTabCount(page, 'Pending', 1);
|
||||
await checkDocumentTabCount(page, 'Completed', 0);
|
||||
await checkDocumentTabCount(page, 'Draft', 1);
|
||||
await checkDocumentTabCount(page, 'All', 2);
|
||||
await checkDocumentCounts(page, { inbox: 0, pending: 1, completed: 0, draft: 1, all: 2 });
|
||||
|
||||
// Sign into the recipient account.
|
||||
await apiSignout({ page });
|
||||
@@ -255,11 +243,7 @@ test('[DOCUMENTS]: deleting completed documents as an owner should hide it from
|
||||
|
||||
// Check document counts.
|
||||
await expect(page.getByRole('row', { name: /Document 1 - Completed/ })).toBeVisible();
|
||||
await checkDocumentTabCount(page, 'Inbox', 1);
|
||||
await checkDocumentTabCount(page, 'Pending', 0);
|
||||
await checkDocumentTabCount(page, 'Completed', 1);
|
||||
await checkDocumentTabCount(page, 'Draft', 0);
|
||||
await checkDocumentTabCount(page, 'All', 2);
|
||||
await checkDocumentCounts(page, { inbox: 1, pending: 0, completed: 1, draft: 0, all: 2 });
|
||||
});
|
||||
|
||||
test('[DOCUMENTS]: deleting documents as a recipient should only hide it for them', async ({ page }) => {
|
||||
@@ -300,11 +284,7 @@ test('[DOCUMENTS]: deleting documents as a recipient should only hide it for the
|
||||
// Check document counts.
|
||||
await expect(page.getByRole('row', { name: /Document 1 - Completed/ })).not.toBeVisible();
|
||||
await expect(page.getByRole('row', { name: /Document 1 - Pending/ })).not.toBeVisible();
|
||||
await checkDocumentTabCount(page, 'Inbox', 0);
|
||||
await checkDocumentTabCount(page, 'Pending', 0);
|
||||
await checkDocumentTabCount(page, 'Completed', 0);
|
||||
await checkDocumentTabCount(page, 'Draft', 0);
|
||||
await checkDocumentTabCount(page, 'All', 0);
|
||||
await checkDocumentCounts(page, { inbox: 0, pending: 0, completed: 0, draft: 0, all: 0 });
|
||||
|
||||
// Sign into the sender account.
|
||||
await apiSignout({ page });
|
||||
@@ -315,11 +295,7 @@ test('[DOCUMENTS]: deleting documents as a recipient should only hide it for the
|
||||
});
|
||||
|
||||
// Check document counts for sender.
|
||||
await checkDocumentTabCount(page, 'Inbox', 0);
|
||||
await checkDocumentTabCount(page, 'Pending', 1);
|
||||
await checkDocumentTabCount(page, 'Completed', 1);
|
||||
await checkDocumentTabCount(page, 'Draft', 1);
|
||||
await checkDocumentTabCount(page, 'All', 3);
|
||||
await checkDocumentCounts(page, { inbox: 0, pending: 1, completed: 1, draft: 1, all: 3 });
|
||||
|
||||
// Sign into the other recipient account.
|
||||
await apiSignout({ page });
|
||||
@@ -330,9 +306,5 @@ test('[DOCUMENTS]: deleting documents as a recipient should only hide it for the
|
||||
});
|
||||
|
||||
// Check document counts for other recipient.
|
||||
await checkDocumentTabCount(page, 'Inbox', 1);
|
||||
await checkDocumentTabCount(page, 'Pending', 0);
|
||||
await checkDocumentTabCount(page, 'Completed', 1);
|
||||
await checkDocumentTabCount(page, 'Draft', 0);
|
||||
await checkDocumentTabCount(page, 'All', 2);
|
||||
await checkDocumentCounts(page, { inbox: 1, pending: 0, completed: 1, draft: 0, all: 2 });
|
||||
});
|
||||
|
||||
@@ -10,10 +10,17 @@ import { seedOrganisationMembers } from '@documenso/prisma/seed/organisations';
|
||||
import { seedTeam, seedTeamEmail, seedTeamMember } from '@documenso/prisma/seed/teams';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { DocumentStatus, DocumentVisibility, OrganisationMemberRole, TeamMemberRole } from '@prisma/client';
|
||||
import {
|
||||
DocumentStatus,
|
||||
DocumentVisibility,
|
||||
OrganisationMemberRole,
|
||||
RecipientRole,
|
||||
SigningStatus,
|
||||
TeamMemberRole,
|
||||
} from '@prisma/client';
|
||||
|
||||
import { apiSignin, apiSignout } from '../fixtures/authentication';
|
||||
import { checkDocumentTabCount } from '../fixtures/documents';
|
||||
import { checkDocumentCounts, checkDocumentTabCount, toggleDocumentSenderFilter } from '../fixtures/documents';
|
||||
|
||||
test.describe.configure({
|
||||
mode: 'parallel',
|
||||
@@ -54,10 +61,7 @@ test.describe('Find Documents UI - Personal Context', () => {
|
||||
redirectPath: `/t/${team.url}/documents`,
|
||||
});
|
||||
|
||||
await checkDocumentTabCount(page, 'All', 3);
|
||||
await checkDocumentTabCount(page, 'Draft', 1);
|
||||
await checkDocumentTabCount(page, 'Pending', 1);
|
||||
await checkDocumentTabCount(page, 'Completed', 1);
|
||||
await checkDocumentCounts(page, { draft: 1, pending: 1, completed: 1, all: 3 });
|
||||
});
|
||||
|
||||
test('received documents from other teams should NOT appear in personal context', async ({ page }) => {
|
||||
@@ -133,10 +137,9 @@ test.describe('Find Documents UI - Personal Context', () => {
|
||||
redirectPath: `/t/${ownerTeam.url}/documents`,
|
||||
});
|
||||
|
||||
// Inbox should be 0 since there's no team email and received docs are on sender's team
|
||||
await checkDocumentTabCount(page, 'Inbox', 0);
|
||||
// Owner's own doc should still show in All
|
||||
await checkDocumentTabCount(page, 'All', 1);
|
||||
// Inbox should be 0 since there's no team email and received docs are on sender's team.
|
||||
// Owner's own doc should still show in All.
|
||||
await checkDocumentCounts(page, { inbox: 0, all: 1 });
|
||||
await expect(page.getByRole('link', { name: 'Owner Draft Control' })).toBeVisible();
|
||||
});
|
||||
|
||||
@@ -700,9 +703,8 @@ test.describe('Find Documents UI - Team with Team Email', () => {
|
||||
redirectPath: `/t/${team.url}/documents`,
|
||||
});
|
||||
|
||||
await checkDocumentTabCount(page, 'Inbox', 0);
|
||||
// But pending should still show
|
||||
await checkDocumentTabCount(page, 'Pending', 1);
|
||||
// Inbox should be 0, but pending should still show.
|
||||
await checkDocumentCounts(page, { inbox: 0, pending: 1 });
|
||||
});
|
||||
|
||||
test('documents sent BY team email user should appear in team context', async ({ page }) => {
|
||||
@@ -803,12 +805,9 @@ test.describe('Find Documents UI - Data Isolation & No Leaking', () => {
|
||||
});
|
||||
|
||||
// UserA should see only their own docs
|
||||
await checkDocumentTabCount(page, 'All', 3);
|
||||
await checkDocumentTabCount(page, 'Draft', 1);
|
||||
await checkDocumentTabCount(page, 'Completed', 1);
|
||||
await checkDocumentCounts(page, { draft: 1, completed: 1, all: 3 });
|
||||
|
||||
// Verify no B docs leaked
|
||||
await page.getByRole('tab', { name: 'All' }).click();
|
||||
await expect(page.getByRole('link', { name: 'A Own Draft' })).toBeVisible();
|
||||
await expect(page.getByRole('link', { name: 'B Draft Private', exact: true })).not.toBeVisible();
|
||||
await expect(page.getByRole('link', { name: 'B Pending Private', exact: true })).not.toBeVisible();
|
||||
@@ -959,9 +958,9 @@ test.describe('Find Documents UI - Data Isolation & No Leaking', () => {
|
||||
redirectPath: `/t/${outsideTeam.url}/documents`,
|
||||
});
|
||||
|
||||
// Only the outside user's own draft should appear (cross-team docs are not visible)
|
||||
await checkDocumentTabCount(page, 'Inbox', 0); // No team email → 0
|
||||
await checkDocumentTabCount(page, 'All', 1); // Check All tab last so we can verify visible links
|
||||
// Only the outside user's own draft should appear (cross-team docs are not visible).
|
||||
// Inbox is 0 since there is no team email.
|
||||
await checkDocumentCounts(page, { inbox: 0, all: 1 });
|
||||
await expect(page.getByRole('link', { name: 'Outside Own Draft' })).toBeVisible();
|
||||
await expect(page.getByRole('link', { name: 'Team Doc For Outside User', exact: true })).not.toBeVisible();
|
||||
await expect(page.getByRole('link', { name: 'Team Doc For Other User Only', exact: true })).not.toBeVisible();
|
||||
@@ -1006,12 +1005,10 @@ test.describe('Find Documents UI - Tab Counts Consistency', () => {
|
||||
redirectPath: `/t/${ownerTeam.url}/documents`,
|
||||
});
|
||||
|
||||
// Only owner's own docs appear (received docs are on sender's team)
|
||||
await checkDocumentTabCount(page, 'Draft', 2);
|
||||
await checkDocumentTabCount(page, 'Pending', 1);
|
||||
await checkDocumentTabCount(page, 'Inbox', 0); // No team email → inbox returns null → 0
|
||||
await checkDocumentTabCount(page, 'Completed', 1); // Only owned completed (received is on sender's team)
|
||||
await checkDocumentTabCount(page, 'All', 4); // 2 drafts + 1 pending + 1 completed
|
||||
// Only owner's own docs appear (received docs are on sender's team).
|
||||
// Inbox is 0 since there is no team email, and only the owned completed
|
||||
// doc counts (received is on sender's team). All = 2 drafts + 1 pending + 1 completed.
|
||||
await checkDocumentCounts(page, { inbox: 0, draft: 2, pending: 1, completed: 1, all: 4 });
|
||||
});
|
||||
|
||||
test('team context tab counts should be accurate with mixed documents', async ({ page }) => {
|
||||
@@ -1063,10 +1060,7 @@ test.describe('Find Documents UI - Tab Counts Consistency', () => {
|
||||
redirectPath: `/t/${team.url}/documents`,
|
||||
});
|
||||
|
||||
await checkDocumentTabCount(page, 'Draft', 2);
|
||||
await checkDocumentTabCount(page, 'Pending', 1);
|
||||
await checkDocumentTabCount(page, 'Completed', 1);
|
||||
await checkDocumentTabCount(page, 'All', 4);
|
||||
await checkDocumentCounts(page, { draft: 2, pending: 1, completed: 1, all: 4 });
|
||||
});
|
||||
|
||||
test('team with team email tab counts should include received documents', async ({ page }) => {
|
||||
@@ -1100,11 +1094,9 @@ test.describe('Find Documents UI - Tab Counts Consistency', () => {
|
||||
redirectPath: `/t/${team.url}/documents`,
|
||||
});
|
||||
|
||||
await checkDocumentTabCount(page, 'Draft', 1);
|
||||
await checkDocumentTabCount(page, 'Inbox', 1); // One pending doc received by team email (NOT_SIGNED)
|
||||
await checkDocumentTabCount(page, 'Pending', 1); // Own pending
|
||||
await checkDocumentTabCount(page, 'Completed', 1); // Received completed via email
|
||||
await checkDocumentTabCount(page, 'All', 4); // All of the above
|
||||
// Inbox = one pending doc received by team email (NOT_SIGNED), pending = own
|
||||
// pending, completed = received completed via email, all = all of the above.
|
||||
await checkDocumentCounts(page, { inbox: 1, draft: 1, pending: 1, completed: 1, all: 4 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1156,12 +1148,139 @@ test.describe('Find Documents UI - Sender Filter', () => {
|
||||
await checkDocumentTabCount(page, 'All', 3);
|
||||
|
||||
// Filter by member1
|
||||
await page.locator('button').filter({ hasText: 'Sender: All' }).click();
|
||||
await page.getByRole('option', { name: member1.name ?? '' }).click();
|
||||
await page.waitForURL(/senderIds/);
|
||||
await toggleDocumentSenderFilter(page, member1.name ?? '');
|
||||
|
||||
// Should only show member1's doc
|
||||
await checkDocumentTabCount(page, 'All', 1);
|
||||
await expect(page.getByRole('link', { name: 'Member1 Sent Doc' })).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Find Documents UI - Rejected and Expired Tabs', () => {
|
||||
const PAST = new Date(Date.now() - 24 * 60 * 60 * 1000);
|
||||
|
||||
test('rejected tab lists rejected documents and counts them independently', async ({ page }) => {
|
||||
const { user: owner, team } = await seedUser();
|
||||
const { user: recipient } = await seedUser();
|
||||
|
||||
// A rejected document: envelope status REJECTED + a recipient who rejected.
|
||||
const rejectedDoc = await seedPendingDocument(owner, team.id, [recipient], {
|
||||
createDocumentOptions: { title: 'Rejected Doc' },
|
||||
});
|
||||
await prisma.envelope.update({
|
||||
where: { id: rejectedDoc.id },
|
||||
data: { status: DocumentStatus.REJECTED },
|
||||
});
|
||||
await prisma.recipient.updateMany({
|
||||
where: { envelopeId: rejectedDoc.id },
|
||||
data: { signingStatus: SigningStatus.REJECTED },
|
||||
});
|
||||
|
||||
// A plain pending document (noise — must not appear under Rejected).
|
||||
await seedPendingDocument(owner, team.id, [recipient], {
|
||||
createDocumentOptions: { title: 'Plain Pending Doc' },
|
||||
});
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: owner.email,
|
||||
redirectPath: `/t/${team.url}/documents`,
|
||||
});
|
||||
|
||||
await checkDocumentTabCount(page, 'Rejected', 1);
|
||||
await expect(page.getByRole('link', { name: 'Rejected Doc' })).toBeVisible();
|
||||
await expect(page.getByRole('link', { name: 'Plain Pending Doc' })).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('expired tab lists documents with an expired recipient and shows empty state otherwise', async ({ page }) => {
|
||||
const { user: owner, team } = await seedUser();
|
||||
const { user: recipient } = await seedUser();
|
||||
|
||||
const expiredDoc = await seedPendingDocument(owner, team.id, [recipient], {
|
||||
createDocumentOptions: { title: 'Expired Doc' },
|
||||
});
|
||||
await prisma.recipient.updateMany({
|
||||
where: { envelopeId: expiredDoc.id },
|
||||
data: { expiresAt: PAST },
|
||||
});
|
||||
|
||||
// Active pending doc — recipient link not expired.
|
||||
await seedPendingDocument(owner, team.id, [recipient], {
|
||||
createDocumentOptions: { title: 'Active Doc' },
|
||||
});
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: owner.email,
|
||||
redirectPath: `/t/${team.url}/documents`,
|
||||
});
|
||||
|
||||
// Expired doc is still PENDING, so it appears under both Pending and Expired.
|
||||
await checkDocumentTabCount(page, 'Pending', 2);
|
||||
await checkDocumentTabCount(page, 'Expired', 1);
|
||||
await expect(page.getByRole('link', { name: 'Expired Doc' })).toBeVisible();
|
||||
await expect(page.getByRole('link', { name: 'Active Doc' })).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('expired tab excludes signed and CC recipients', async ({ page }) => {
|
||||
const { user: owner, team } = await seedUser();
|
||||
const { user: recipient } = await seedUser();
|
||||
|
||||
// Expired but already signed — must NOT count as expired.
|
||||
const signedDoc = await seedPendingDocument(owner, team.id, [recipient], {
|
||||
createDocumentOptions: { title: 'Expired Signed Doc' },
|
||||
});
|
||||
await prisma.recipient.updateMany({
|
||||
where: { envelopeId: signedDoc.id },
|
||||
data: { expiresAt: PAST, signingStatus: SigningStatus.SIGNED },
|
||||
});
|
||||
|
||||
// Expired but CC — must NOT count as expired.
|
||||
const ccDoc = await seedPendingDocument(owner, team.id, [recipient], {
|
||||
createDocumentOptions: { title: 'Expired CC Doc' },
|
||||
});
|
||||
await prisma.recipient.updateMany({
|
||||
where: { envelopeId: ccDoc.id },
|
||||
data: { expiresAt: PAST, role: RecipientRole.CC },
|
||||
});
|
||||
|
||||
// Expired, unsigned, non-CC — the only one that should appear.
|
||||
const validDoc = await seedPendingDocument(owner, team.id, [recipient], {
|
||||
createDocumentOptions: { title: 'Expired Valid Doc' },
|
||||
});
|
||||
await prisma.recipient.updateMany({
|
||||
where: { envelopeId: validDoc.id },
|
||||
data: { expiresAt: PAST },
|
||||
});
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: owner.email,
|
||||
redirectPath: `/t/${team.url}/documents`,
|
||||
});
|
||||
|
||||
await checkDocumentTabCount(page, 'Expired', 1);
|
||||
await expect(page.getByRole('link', { name: 'Expired Valid Doc' })).toBeVisible();
|
||||
await expect(page.getByRole('link', { name: 'Expired Signed Doc' })).not.toBeVisible();
|
||||
await expect(page.getByRole('link', { name: 'Expired CC Doc' })).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('rejected and expired tabs show tailored empty states when nothing matches', async ({ page }) => {
|
||||
const { user: owner, team } = await seedUser();
|
||||
const { user: recipient } = await seedUser();
|
||||
|
||||
await seedPendingDocument(owner, team.id, [recipient], {
|
||||
createDocumentOptions: { title: 'Just Pending' },
|
||||
});
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: owner.email,
|
||||
redirectPath: `/t/${team.url}/documents`,
|
||||
});
|
||||
|
||||
// count === 0 asserts the empty-document-state is visible.
|
||||
await checkDocumentTabCount(page, 'Rejected', 0);
|
||||
await checkDocumentTabCount(page, 'Expired', 0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { seedDirectTemplate } from '@documenso/prisma/seed/templates';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
import { expect, type Page, test } from '@playwright/test';
|
||||
|
||||
import { apiSignin } from '../fixtures/authentication';
|
||||
import { clickEnvelopeEditorStep } from '../fixtures/envelope-editor';
|
||||
|
||||
const INVALID_DIRECT_TEMPLATE_ALERT_TITLE = 'Invalid direct link template';
|
||||
|
||||
/**
|
||||
* Place a field on the PDF canvas in the envelope editor.
|
||||
*/
|
||||
const placeFieldOnPdf = async (root: Page, fieldName: 'Signature' | 'Text', position: { x: number; y: number }) => {
|
||||
await root.getByRole('button', { name: fieldName, exact: true }).click();
|
||||
|
||||
const canvas = root.locator('.konva-container canvas').first();
|
||||
await expect(canvas).toBeVisible();
|
||||
await canvas.click({ position });
|
||||
};
|
||||
|
||||
/**
|
||||
* Seed a V2 direct template and open it in the native template editor.
|
||||
*
|
||||
* Only the native template editor is covered here: direct links only exist
|
||||
* for templates and are not part of the embedded editor surfaces.
|
||||
*/
|
||||
const openDirectTemplateEditor = async (page: Page, options: { createDirectRecipientSignatureField: boolean }) => {
|
||||
const { user, team } = await seedUser();
|
||||
|
||||
const template = await seedDirectTemplate({
|
||||
title: `E2E Direct Template Validation ${Date.now()}`,
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
internalVersion: 2,
|
||||
createDirectRecipientSignatureField: options.createDirectRecipientSignatureField,
|
||||
});
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
redirectPath: `/t/${team.url}/templates/${template.id}/edit`,
|
||||
});
|
||||
|
||||
return { user, team, template };
|
||||
};
|
||||
|
||||
test.describe('template editor', () => {
|
||||
test('shows invalid direct template warning when a signer has no signature field', async ({ page }) => {
|
||||
await openDirectTemplateEditor(page, { createDirectRecipientSignatureField: false });
|
||||
|
||||
await expect(page.getByText(INVALID_DIRECT_TEMPLATE_ALERT_TITLE)).toBeVisible();
|
||||
await expect(page.getByText('are missing a signature field')).toBeVisible();
|
||||
});
|
||||
|
||||
test('does not show the warning when all signers have signature fields', async ({ page }) => {
|
||||
await openDirectTemplateEditor(page, { createDirectRecipientSignatureField: true });
|
||||
|
||||
// Wait for the editor to render before asserting the banner is absent.
|
||||
await expect(page.getByTestId('envelope-editor-step-upload')).toBeVisible();
|
||||
await expect(page.getByText(INVALID_DIRECT_TEMPLATE_ALERT_TITLE)).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('warning disappears after placing a signature field', async ({ page }) => {
|
||||
await openDirectTemplateEditor(page, { createDirectRecipientSignatureField: false });
|
||||
|
||||
await expect(page.getByText(INVALID_DIRECT_TEMPLATE_ALERT_TITLE)).toBeVisible();
|
||||
|
||||
// Place a signature field for the direct recipient (auto-selected single recipient).
|
||||
await clickEnvelopeEditorStep(page, 'addFields');
|
||||
await expect(page.locator('.konva-container canvas').first()).toBeVisible();
|
||||
await placeFieldOnPdf(page, 'Signature', { x: 120, y: 140 });
|
||||
|
||||
// The banner clears once the field is autosaved and the envelope state updates.
|
||||
await expect(page.getByText(INVALID_DIRECT_TEMPLATE_ALERT_TITLE)).not.toBeVisible({ timeout: 15_000 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,199 @@
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { expect, type Page, test } from '@playwright/test';
|
||||
|
||||
import {
|
||||
clickAddSignerButton,
|
||||
clickEnvelopeEditorStep,
|
||||
getRecipientEmailInputs,
|
||||
openDocumentEnvelopeEditor,
|
||||
setRecipientEmail,
|
||||
setRecipientName,
|
||||
type TEnvelopeEditorSurface,
|
||||
} from '../fixtures/envelope-editor';
|
||||
|
||||
/**
|
||||
* Reproduction for the recipient autosave race condition.
|
||||
*
|
||||
* Symptom (production only, where there is real network lag):
|
||||
* 1. The author adds a recipient and types its name/email.
|
||||
* 2. They navigate to the "Add Fields" step.
|
||||
* 3. The recipient selector shows the default "Recipient 1" placeholder
|
||||
* instead of the recipient they just typed, and the typed name/email is
|
||||
* silently lost.
|
||||
*
|
||||
* Theory (see packages/lib/client-only/hooks/use-envelope-autosave.ts):
|
||||
* When the author navigates, `flushAutosave()` is awaited before the Add
|
||||
* Fields page renders. If an *earlier* (empty) recipient save is still
|
||||
* in-flight at that moment, `flush()` awaits that in-flight save and returns
|
||||
* WITHOUT committing the newer typed data sitting in `lastArgsRef` (whose
|
||||
* debounce timer it just cleared). The typed data is dropped, the empty
|
||||
* recipient persists, and the selector renders "Recipient 1".
|
||||
*
|
||||
* This only happens when a save is still in-flight at navigation time, which is
|
||||
* why it never reproduces locally (fast saves) but does on a laggy network.
|
||||
*
|
||||
* The test below simulates that lag by holding the first `envelope.recipient.set`
|
||||
* request open. It asserts the CORRECT behaviour (typed recipient survives), so
|
||||
* it is RED while the bug exists and GREEN once the autosave hook is fixed.
|
||||
*/
|
||||
|
||||
const RECIPIENT_SET_PROCEDURE = 'envelope.recipient.set';
|
||||
|
||||
// How long to hold the first recipient autosave "in-flight" to emulate prod lag.
|
||||
const SIMULATED_NETWORK_LAG_MS = 5000;
|
||||
|
||||
const FIRST_RECIPIENT = {
|
||||
name: 'Alice Author',
|
||||
email: 'alice-autosave-race@example.com',
|
||||
};
|
||||
|
||||
const SECOND_RECIPIENT = {
|
||||
name: 'Bob Builder',
|
||||
email: 'bob-autosave-race@example.com',
|
||||
};
|
||||
|
||||
type RecipientSetLagHandle = {
|
||||
/** Resolves the instant the first recipient.set request is in-flight on the client. */
|
||||
firstRecipientSetInFlight: Promise<void>;
|
||||
/** Raw request bodies of every recipient.set call we intercepted. */
|
||||
recipientSetRequestBodies: string[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Installs a fake "production network lag" on the recipient autosave mutation.
|
||||
*
|
||||
* Only the FIRST recipient.set request is held open for `lagMs` (this is the save
|
||||
* that must still be in-flight at navigation time for the race to occur). It
|
||||
* resolves `firstRecipientSetInFlight` the instant it is intercepted so the test
|
||||
* can keep typing while that save is pending. Subsequent recipient.set requests
|
||||
* (e.g. the follow-up save the fixed hook issues) are forwarded immediately so the
|
||||
* test does not pay the lag twice.
|
||||
*/
|
||||
const installRecipientSetLag = async (page: Page, lagMs: number): Promise<RecipientSetLagHandle> => {
|
||||
let markFirstInFlight: () => void = () => {};
|
||||
|
||||
const firstRecipientSetInFlight = new Promise<void>((resolve) => {
|
||||
markFirstInFlight = resolve;
|
||||
});
|
||||
|
||||
const recipientSetRequestBodies: string[] = [];
|
||||
|
||||
await page.route('**/api/trpc/**', async (route) => {
|
||||
const request = route.request();
|
||||
|
||||
if (request.method() !== 'POST' || !request.url().includes(RECIPIENT_SET_PROCEDURE)) {
|
||||
await route.continue();
|
||||
return;
|
||||
}
|
||||
|
||||
const callIndex = recipientSetRequestBodies.length + 1;
|
||||
recipientSetRequestBodies.push(request.postData() ?? '');
|
||||
|
||||
if (callIndex === 1) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`[test] holding first ${RECIPIENT_SET_PROCEDURE} for ${lagMs}ms (simulated network lag)`);
|
||||
|
||||
// The empty save is now in-flight from the client's perspective.
|
||||
markFirstInFlight();
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, lagMs));
|
||||
} else {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`[test] forwarding ${RECIPIENT_SET_PROCEDURE} #${callIndex} (no lag)`);
|
||||
}
|
||||
|
||||
await route.continue();
|
||||
});
|
||||
|
||||
return { firstRecipientSetInFlight, recipientSetRequestBodies };
|
||||
};
|
||||
|
||||
const assertEnvelopeRecipientsPersisted = async (surface: TEnvelopeEditorSurface) => {
|
||||
if (!surface.envelopeId) {
|
||||
throw new Error('Expected the document editor surface to have an envelopeId');
|
||||
}
|
||||
|
||||
const envelope = await prisma.envelope.findFirstOrThrow({
|
||||
where: { id: surface.envelopeId },
|
||||
include: {
|
||||
recipients: {
|
||||
orderBy: { signingOrder: 'asc' },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const persistedEmails = envelope.recipients.map((recipient) => recipient.email).filter(Boolean);
|
||||
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(
|
||||
'[test] persisted recipients:',
|
||||
JSON.stringify(
|
||||
envelope.recipients.map((recipient) => ({ name: recipient.name, email: recipient.email })),
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
|
||||
expect(persistedEmails).toContain(FIRST_RECIPIENT.email);
|
||||
expect(persistedEmails).toContain(SECOND_RECIPIENT.email);
|
||||
};
|
||||
|
||||
test.describe('envelope editor recipient autosave race (network lag)', () => {
|
||||
test('document editor: typed recipient survives navigation to Add Fields', async ({ page }) => {
|
||||
const surface = await openDocumentEnvelopeEditor(page);
|
||||
|
||||
const { firstRecipientSetInFlight, recipientSetRequestBodies } = await installRecipientSetLag(
|
||||
page,
|
||||
SIMULATED_NETWORK_LAG_MS,
|
||||
);
|
||||
|
||||
// 1. Add a second signer row. A blank document already has one empty default
|
||||
// signer, so this schedules an autosave of TWO empty recipients
|
||||
// (name='' / email='') - this is the save that will be in-flight.
|
||||
await clickAddSignerButton(surface.root);
|
||||
await expect(getRecipientEmailInputs(surface.root)).toHaveCount(2);
|
||||
|
||||
// 2. Wait until that empty autosave is actually in-flight on the client. This
|
||||
// is the precondition the bug needs: a slow save holding the autosave lock.
|
||||
await firstRecipientSetInFlight;
|
||||
|
||||
// 3. The author now fills in the recipients they are adding.
|
||||
await setRecipientName(surface.root, 0, FIRST_RECIPIENT.name);
|
||||
await setRecipientEmail(surface.root, 0, FIRST_RECIPIENT.email);
|
||||
await setRecipientName(surface.root, 1, SECOND_RECIPIENT.name);
|
||||
await setRecipientEmail(surface.root, 1, SECOND_RECIPIENT.email);
|
||||
|
||||
// 4. Immediately navigate to Add Fields (before the typed data's debounce
|
||||
// fires). flushAutosave() awaits the in-flight EMPTY save; with the bug
|
||||
// present it returns without ever committing the typed data.
|
||||
await clickEnvelopeEditorStep(surface.root, 'addFields');
|
||||
|
||||
// 5. Wait for the Add Fields page to render (after the lagged flush resolves).
|
||||
await expect(surface.root.getByText('Selected Recipient')).toBeVisible({
|
||||
timeout: SIMULATED_NETWORK_LAG_MS + 15000,
|
||||
});
|
||||
|
||||
// Diagnostics - the request bodies show what actually reached the server.
|
||||
// Buggy: only the first (empty) save is ever sent. Fixed: a follow-up save
|
||||
// carrying the typed recipients is sent too.
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('\n===== AUTOSAVE RACE DIAGNOSTICS =====');
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`recipient.set requests sent to server: ${recipientSetRequestBodies.length}`);
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(
|
||||
`server ever received "${FIRST_RECIPIENT.email}": ${recipientSetRequestBodies.some((body) => body.includes(FIRST_RECIPIENT.email))}`,
|
||||
);
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('=====================================\n');
|
||||
|
||||
// 6. THE USER-VISIBLE BUG: the selected recipient must be the one we typed
|
||||
// (Alice), not the default "Recipient 1" placeholder.
|
||||
const selectedRecipientSection = surface.root.locator('section').filter({ hasText: 'Selected Recipient' });
|
||||
|
||||
await expect(selectedRecipientSection.getByRole('combobox')).toContainText(FIRST_RECIPIENT.name);
|
||||
|
||||
// 7. THE DATA LOSS: the typed recipients must actually be persisted.
|
||||
await assertEnvelopeRecipientsPersisted(surface);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,155 @@
|
||||
import { nanoid } from '@documenso/lib/universal/id';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { seedBlankDocument } from '@documenso/prisma/seed/documents';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
import type { Page } from '@playwright/test';
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { DocumentSigningOrder, RecipientRole } from '@prisma/client';
|
||||
|
||||
import { apiSignin } from '../fixtures/authentication';
|
||||
import {
|
||||
assertRecipientRole,
|
||||
getRecipientEmailInputs,
|
||||
getRecipientRows,
|
||||
getSigningOrderInputs,
|
||||
openDocumentEnvelopeEditor,
|
||||
setRecipientEmail,
|
||||
setRecipientName,
|
||||
setRecipientRole,
|
||||
toggleSigningOrder,
|
||||
} from '../fixtures/envelope-editor';
|
||||
|
||||
const SIGNER_A = { email: 'cc-order-signer-a@example.com', name: 'Signer A' };
|
||||
const SIGNER_B = { email: 'cc-order-signer-b@example.com', name: 'Signer B' };
|
||||
const CC_RECIPIENT = { email: 'cc-order-cc@example.com', name: 'CC Recipient' };
|
||||
|
||||
const assertCcDisplayedLastWithNoOrderInput = async (root: Page) => {
|
||||
// CC recipient is displayed last despite being added/stored mid-list.
|
||||
await expect(getRecipientEmailInputs(root)).toHaveCount(3);
|
||||
await expect(getRecipientEmailInputs(root).nth(0)).toHaveValue(SIGNER_A.email);
|
||||
await expect(getRecipientEmailInputs(root).nth(1)).toHaveValue(SIGNER_B.email);
|
||||
await expect(getRecipientEmailInputs(root).nth(2)).toHaveValue(CC_RECIPIENT.email);
|
||||
|
||||
await assertRecipientRole(root, 0, 'Needs to sign');
|
||||
await assertRecipientRole(root, 1, 'Needs to sign');
|
||||
await assertRecipientRole(root, 2, 'Receives copy');
|
||||
|
||||
// Only the two signers have signing order inputs, showing 1 and 2.
|
||||
await expect(getSigningOrderInputs(root)).toHaveCount(2);
|
||||
await expect(getSigningOrderInputs(root).nth(0)).toHaveValue('1');
|
||||
await expect(getSigningOrderInputs(root).nth(1)).toHaveValue('2');
|
||||
|
||||
// The CC row itself renders no signing order input (placeholder div instead).
|
||||
const ccRow = getRecipientRows(root).nth(2);
|
||||
await expect(ccRow.locator('[data-testid="signing-order-input"]')).toHaveCount(0);
|
||||
};
|
||||
|
||||
test.describe('document editor', () => {
|
||||
test('CC recipient added mid-list is displayed last with no signing order input', async ({ page }) => {
|
||||
const surface = await openDocumentEnvelopeEditor(page);
|
||||
const { root } = surface;
|
||||
|
||||
await toggleSigningOrder(root, true);
|
||||
|
||||
// Add signer A into the initial empty row.
|
||||
await setRecipientEmail(root, 0, SIGNER_A.email);
|
||||
await setRecipientName(root, 0, SIGNER_A.name);
|
||||
|
||||
// Add the CC recipient second.
|
||||
await root.getByRole('button', { name: 'Add Signer' }).click();
|
||||
await setRecipientEmail(root, 1, CC_RECIPIENT.email);
|
||||
await setRecipientName(root, 1, CC_RECIPIENT.name);
|
||||
await setRecipientRole(root, 1, 'Receives copy');
|
||||
|
||||
// Once the row becomes CC, its signing order input disappears.
|
||||
await expect(getSigningOrderInputs(root)).toHaveCount(1);
|
||||
|
||||
// Add signer B third. The new row is inserted before the CC recipient,
|
||||
// which is kept last by the client-side sorting.
|
||||
await root.getByRole('button', { name: 'Add Signer' }).click();
|
||||
await expect(getRecipientEmailInputs(root).nth(2)).toHaveValue(CC_RECIPIENT.email);
|
||||
|
||||
await setRecipientEmail(root, 1, SIGNER_B.email);
|
||||
await setRecipientName(root, 1, SIGNER_B.name);
|
||||
|
||||
await assertCcDisplayedLastWithNoOrderInput(root);
|
||||
|
||||
// The editor autosaves with a debounce, poll the DB until all three
|
||||
// recipients have been persisted before reloading the page.
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const recipients = await prisma.recipient.findMany({
|
||||
where: { envelopeId: surface.envelopeId },
|
||||
});
|
||||
|
||||
return recipients.length;
|
||||
},
|
||||
{ timeout: 15_000 },
|
||||
)
|
||||
.toBe(3);
|
||||
|
||||
// Reload the editor and assert the CC recipient is still displayed last.
|
||||
await root.reload();
|
||||
await expect(root.getByRole('heading', { name: 'Recipients' })).toBeVisible();
|
||||
|
||||
await assertCcDisplayedLastWithNoOrderInput(root);
|
||||
});
|
||||
|
||||
test('CC recipient seeded with mid-list signing order is displayed last', async ({ page }) => {
|
||||
const { user, team } = await seedUser();
|
||||
|
||||
const document = await seedBlankDocument(user, team.id, {
|
||||
internalVersion: 2,
|
||||
});
|
||||
|
||||
// Seed a CC recipient directly in the DB with a mid-list signing order
|
||||
// (2 of 3) BEFORE opening the editor, so the editor's autosave cannot
|
||||
// race with the seeded recipients, and assert the editor renders it last.
|
||||
await prisma.envelope.update({
|
||||
where: { id: document.id },
|
||||
data: {
|
||||
documentMeta: {
|
||||
update: { signingOrder: DocumentSigningOrder.SEQUENTIAL },
|
||||
},
|
||||
recipients: {
|
||||
createMany: {
|
||||
data: [
|
||||
{
|
||||
email: SIGNER_A.email,
|
||||
name: SIGNER_A.name,
|
||||
token: nanoid(),
|
||||
role: RecipientRole.SIGNER,
|
||||
signingOrder: 1,
|
||||
},
|
||||
{
|
||||
email: CC_RECIPIENT.email,
|
||||
name: CC_RECIPIENT.name,
|
||||
token: nanoid(),
|
||||
role: RecipientRole.CC,
|
||||
signingOrder: 2,
|
||||
},
|
||||
{
|
||||
email: SIGNER_B.email,
|
||||
name: SIGNER_B.name,
|
||||
token: nanoid(),
|
||||
role: RecipientRole.SIGNER,
|
||||
signingOrder: 3,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
redirectPath: `/t/${team.url}/documents/${document.id}/edit?step=uploadAndRecipients`,
|
||||
});
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Recipients' })).toBeVisible();
|
||||
|
||||
await assertCcDisplayedLastWithNoOrderInput(page);
|
||||
});
|
||||
});
|
||||
@@ -15,11 +15,11 @@ test('[ENVELOPE_EXPIRATION]: set custom expiration period at organisation level'
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
redirectPath: `/o/${organisation.url}/settings/document`,
|
||||
redirectPath: `/o/${organisation.url}/settings/reminders`,
|
||||
});
|
||||
|
||||
// Wait for the form to load.
|
||||
await expect(page.getByTestId('document-language-trigger')).toBeVisible();
|
||||
await expect(page.getByTestId('envelope-expiration-mode')).toBeVisible();
|
||||
|
||||
// Change the amount to 2.
|
||||
const amountInput = page.getByTestId('envelope-expiration-amount');
|
||||
@@ -36,7 +36,7 @@ test('[ENVELOPE_EXPIRATION]: set custom expiration period at organisation level'
|
||||
await page.getByRole('option', { name: 'Weeks' }).click();
|
||||
|
||||
await page.getByRole('button', { name: 'Save changes' }).first().click();
|
||||
await expect(page.getByText('Your document preferences have been updated').first()).toBeVisible();
|
||||
await expect(page.getByText('Your reminder preferences have been updated').first()).toBeVisible();
|
||||
|
||||
// Verify via database.
|
||||
const orgSettings = await prisma.organisationGlobalSettings.findUniqueOrThrow({
|
||||
@@ -54,18 +54,18 @@ test('[ENVELOPE_EXPIRATION]: disable expiration at organisation level', async ({
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
redirectPath: `/o/${organisation.url}/settings/document`,
|
||||
redirectPath: `/o/${organisation.url}/settings/reminders`,
|
||||
});
|
||||
|
||||
await expect(page.getByTestId('document-language-trigger')).toBeVisible();
|
||||
|
||||
// Find the mode select (shows "Custom duration") and change to "Never expires".
|
||||
const modeTrigger = page.getByTestId('envelope-expiration-mode');
|
||||
await expect(modeTrigger).toBeVisible();
|
||||
|
||||
await modeTrigger.click();
|
||||
await page.getByRole('option', { name: 'Never expires' }).click();
|
||||
|
||||
await page.getByRole('button', { name: 'Save changes' }).first().click();
|
||||
await expect(page.getByText('Your document preferences have been updated').first()).toBeVisible();
|
||||
await expect(page.getByText('Your reminder preferences have been updated').first()).toBeVisible();
|
||||
|
||||
// Verify via database.
|
||||
const orgSettings = await prisma.organisationGlobalSettings.findUniqueOrThrow({
|
||||
@@ -106,11 +106,9 @@ test('[ENVELOPE_EXPIRATION]: team overrides organisation expiration', async ({ p
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
redirectPath: `/t/${team.url}/settings/document`,
|
||||
redirectPath: `/t/${team.url}/settings/reminders`,
|
||||
});
|
||||
|
||||
await expect(page.getByTestId('document-language-trigger')).toBeVisible();
|
||||
|
||||
// The expiration picker mode select should show "Inherit from organisation" by default.
|
||||
const modeTrigger = page.getByTestId('envelope-expiration-mode');
|
||||
await expect(modeTrigger).toBeVisible();
|
||||
@@ -129,7 +127,7 @@ test('[ENVELOPE_EXPIRATION]: team overrides organisation expiration', async ({ p
|
||||
await page.getByRole('option', { name: 'Days' }).click();
|
||||
|
||||
await page.getByRole('button', { name: 'Save changes' }).first().click();
|
||||
await expect(page.getByText('Your document preferences have been updated').first()).toBeVisible();
|
||||
await expect(page.getByText('Your reminder preferences have been updated').first()).toBeVisible();
|
||||
|
||||
// Verify team setting is overridden.
|
||||
const teamSettings = await getTeamSettings({ teamId: team.id });
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { FieldType } from '@documenso/prisma/client';
|
||||
import { seedPendingDocumentWithFullFields } from '@documenso/prisma/seed/documents';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { type APIRequestContext, expect, test } from '@playwright/test';
|
||||
|
||||
import { apiSeedPendingDocument } from '../fixtures/api-seeds';
|
||||
import { apiSignin } from '../fixtures/authentication';
|
||||
import { signSignaturePad } from '../fixtures/signature';
|
||||
|
||||
@@ -128,3 +130,82 @@ test('[ENVELOPE_EXPIRATION]: expired recipient cannot complete signing', async (
|
||||
}).toPass({ timeout: 10_000 });
|
||||
}
|
||||
});
|
||||
|
||||
const trpcMutation = async (request: APIRequestContext, procedure: string, input: Record<string, unknown>) => {
|
||||
return await request.post(`${NEXT_PUBLIC_WEBAPP_URL()}/api/trpc/${procedure}`, {
|
||||
headers: { 'content-type': 'application/json' },
|
||||
data: JSON.stringify({ json: input }),
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* The signing page loader only redirects expired recipients, which a direct API call
|
||||
* bypasses. The tests above exercise the V1 signing path; this covers the V2 route
|
||||
* (`envelope.field.sign`), which must reject on the server regardless of the UI.
|
||||
*/
|
||||
test('[ENVELOPE_EXPIRATION]: expired recipient cannot sign a field via the V2 API', async ({ request }) => {
|
||||
const { envelope, distributeResult } = await apiSeedPendingDocument(request, {
|
||||
title: '[TEST] Expired recipient V2 signing',
|
||||
recipients: [
|
||||
{
|
||||
email: `expired-v2-${Date.now()}@test.documenso.com`,
|
||||
name: 'Expired Signer',
|
||||
role: 'SIGNER',
|
||||
signingOrder: 1,
|
||||
},
|
||||
],
|
||||
fieldsPerRecipient: [
|
||||
[
|
||||
{ type: FieldType.SIGNATURE, page: 1, positionX: 5, positionY: 5, width: 5, height: 5 },
|
||||
{ type: FieldType.TEXT, page: 1, positionX: 5, positionY: 15, width: 5, height: 5 },
|
||||
],
|
||||
],
|
||||
});
|
||||
|
||||
const recipient = distributeResult.recipients[0];
|
||||
|
||||
const seededEnvelope = await prisma.envelope.findUniqueOrThrow({
|
||||
where: { id: envelope.id },
|
||||
include: { fields: true },
|
||||
});
|
||||
|
||||
const textField = seededEnvelope.fields.find((field) => field.type === FieldType.TEXT);
|
||||
|
||||
if (!textField) {
|
||||
throw new Error('TEXT field not found on the seeded envelope');
|
||||
}
|
||||
|
||||
// Sanity check: the recipient can sign while the signing window is open.
|
||||
const beforeExpiry = await trpcMutation(request, 'envelope.field.sign', {
|
||||
token: recipient.token,
|
||||
fieldId: textField.id,
|
||||
fieldValue: { type: FieldType.TEXT, value: 'before' },
|
||||
});
|
||||
|
||||
expect(beforeExpiry.ok()).toBeTruthy();
|
||||
|
||||
await prisma.field.update({
|
||||
where: { id: textField.id },
|
||||
data: { inserted: false, customText: '' },
|
||||
});
|
||||
|
||||
await prisma.recipient.update({
|
||||
where: { id: recipient.id },
|
||||
data: { expiresAt: new Date(Date.now() - 60_000) },
|
||||
});
|
||||
|
||||
const afterExpiry = await trpcMutation(request, 'envelope.field.sign', {
|
||||
token: recipient.token,
|
||||
fieldId: textField.id,
|
||||
fieldValue: { type: FieldType.TEXT, value: 'after' },
|
||||
});
|
||||
|
||||
expect(afterExpiry.ok()).toBeFalsy();
|
||||
|
||||
const fieldAfter = await prisma.field.findUniqueOrThrow({
|
||||
where: { id: textField.id },
|
||||
});
|
||||
|
||||
expect(fieldAfter.inserted).toBe(false);
|
||||
expect(fieldAfter.customText).toBe('');
|
||||
});
|
||||
|
||||
@@ -313,20 +313,14 @@ test.describe('Signing Certificate Tests', () => {
|
||||
await apiSignin({
|
||||
page,
|
||||
email: owner.email,
|
||||
redirectPath: `/t/${team.url}/settings/document`,
|
||||
redirectPath: `/t/${team.url}/settings/certificates`,
|
||||
});
|
||||
|
||||
await page
|
||||
.getByRole('group')
|
||||
.locator('div')
|
||||
.filter({ hasText: 'Include the Signing' })
|
||||
.getByRole('combobox')
|
||||
.click();
|
||||
await page.getByTestId('include-signing-certificate-trigger').click();
|
||||
await page.getByRole('option', { name: 'No' }).click();
|
||||
|
||||
await page.getByRole('button', { name: 'Save changes' }).first().click();
|
||||
|
||||
await page.waitForTimeout(1000);
|
||||
await expect(page.getByText('Your certificate preferences have been updated').first()).toBeVisible();
|
||||
|
||||
// Verify the setting was saved
|
||||
const updatedTeam = await prisma.team.findFirstOrThrow({
|
||||
@@ -337,23 +331,21 @@ test.describe('Signing Certificate Tests', () => {
|
||||
expect(updatedTeam.teamGlobalSettings?.includeSigningCertificate).toBe(false);
|
||||
|
||||
// Toggle the setting back to true
|
||||
await page
|
||||
.getByRole('group')
|
||||
.locator('div')
|
||||
.filter({ hasText: 'Include the Signing' })
|
||||
.getByRole('combobox')
|
||||
.click();
|
||||
await page.getByTestId('include-signing-certificate-trigger').click();
|
||||
await page.getByRole('option', { name: 'Yes' }).click();
|
||||
await page.getByRole('button', { name: 'Save changes' }).first().click();
|
||||
|
||||
await page.waitForTimeout(1000);
|
||||
// The toast from the first save may still be visible, so poll the database
|
||||
// for the saved value instead of waiting on UI signals.
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const updatedTeam = await prisma.team.findFirstOrThrow({
|
||||
where: { id: team.id },
|
||||
include: { teamGlobalSettings: true },
|
||||
});
|
||||
|
||||
// Verify the setting was saved
|
||||
const updatedTeam2 = await prisma.team.findFirstOrThrow({
|
||||
where: { id: team.id },
|
||||
include: { teamGlobalSettings: true },
|
||||
});
|
||||
|
||||
expect(updatedTeam2.teamGlobalSettings?.includeSigningCertificate).toBe(true);
|
||||
return updatedTeam.teamGlobalSettings?.includeSigningCertificate;
|
||||
})
|
||||
.toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { Page } from '@playwright/test';
|
||||
import { expect } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* Opens the app command menu via the keyboard shortcut.
|
||||
*
|
||||
* Retries the shortcut until the menu appears since the keypress is a no-op
|
||||
* when it happens before the page has hydrated.
|
||||
*
|
||||
* @param placeholder The search input placeholder to wait for, which differs
|
||||
* between admin and non-admin users.
|
||||
*/
|
||||
export const openCommandMenu = async (page: Page, placeholder: string) => {
|
||||
await expect(async () => {
|
||||
await page.keyboard.press('Meta+K');
|
||||
await expect(page.getByPlaceholder(placeholder).first()).toBeVisible({ timeout: 1_000 });
|
||||
}).toPass({ timeout: 15_000 });
|
||||
};
|
||||
@@ -1,11 +1,116 @@
|
||||
import type { Page } from '@playwright/test';
|
||||
import { expect } from '@playwright/test';
|
||||
|
||||
export const checkDocumentTabCount = async (page: Page, tabName: string, count: number) => {
|
||||
await page.getByRole('tab', { name: tabName }).click();
|
||||
type DocumentStatusCounts = {
|
||||
inbox?: number;
|
||||
pending?: number;
|
||||
completed?: number;
|
||||
draft?: number;
|
||||
cancelled?: number;
|
||||
rejected?: number;
|
||||
expired?: number;
|
||||
all?: number;
|
||||
};
|
||||
|
||||
if (tabName !== 'All') {
|
||||
await expect(page.getByRole('tab', { name: tabName })).toContainText(count.toString());
|
||||
const STATUS_KEYS = {
|
||||
inbox: 'INBOX',
|
||||
pending: 'PENDING',
|
||||
completed: 'COMPLETED',
|
||||
draft: 'DRAFT',
|
||||
cancelled: 'CANCELLED',
|
||||
rejected: 'REJECTED',
|
||||
expired: 'EXPIRED',
|
||||
all: 'ALL',
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Check the counts for multiple document statuses in one go via the
|
||||
* visually hidden stats rendered alongside the status filter.
|
||||
*
|
||||
* When `all` is provided the status filter is also cleared and the
|
||||
* unfiltered table count (or empty state) is verified.
|
||||
*/
|
||||
export const checkDocumentCounts = async (page: Page, counts: DocumentStatusCounts) => {
|
||||
for (const [key, status] of Object.entries(STATUS_KEYS)) {
|
||||
const count = counts[key as keyof typeof STATUS_KEYS];
|
||||
|
||||
if (count === undefined) {
|
||||
continue;
|
||||
}
|
||||
|
||||
await expect(page.getByTestId(`documents-status-count-${status}`)).toHaveText(count.toString());
|
||||
}
|
||||
|
||||
if (counts.all !== undefined) {
|
||||
await clearDocumentStatusFilter(page);
|
||||
|
||||
if (counts.all === 0) {
|
||||
await expect(page.getByTestId('empty-document-state')).toBeVisible();
|
||||
return;
|
||||
}
|
||||
|
||||
await expect(page.getByTestId('data-table-count')).toContainText(`Showing ${counts.all}`);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Select a status in the documents status filter pill.
|
||||
*
|
||||
* No-op if the status is already selected, since selecting the active
|
||||
* option again would clear the filter.
|
||||
*/
|
||||
export const selectDocumentStatusFilter = async (page: Page, statusName: string) => {
|
||||
const currentStatus = new URL(page.url()).searchParams.get('status');
|
||||
|
||||
if (currentStatus === statusName.toUpperCase()) {
|
||||
return;
|
||||
}
|
||||
|
||||
await page.getByTestId('documents-table-status-filter').click();
|
||||
await page.getByRole('option', { name: statusName }).click();
|
||||
};
|
||||
|
||||
/**
|
||||
* Toggle a sender in the documents sender filter pill.
|
||||
*
|
||||
* The sender filter is a multi select, so the popover stays open after
|
||||
* picking and is closed with Escape.
|
||||
*/
|
||||
export const toggleDocumentSenderFilter = async (page: Page, senderName: string) => {
|
||||
await page.getByTestId('documents-table-sender-filter').click();
|
||||
await page.getByRole('option', { name: senderName }).click();
|
||||
await page.waitForURL(/senderIds/);
|
||||
await page.keyboard.press('Escape');
|
||||
};
|
||||
|
||||
/**
|
||||
* Clear the documents status filter pill, returning to the "All" view.
|
||||
*/
|
||||
export const clearDocumentStatusFilter = async (page: Page) => {
|
||||
const currentStatus = new URL(page.url()).searchParams.get('status');
|
||||
|
||||
if (!currentStatus) {
|
||||
return;
|
||||
}
|
||||
|
||||
await page.getByTestId('documents-table-status-filter').click();
|
||||
await page.getByRole('option', { name: 'Clear' }).click();
|
||||
};
|
||||
|
||||
/**
|
||||
* Apply a status filter (or 'All' to clear it) and verify both the hidden
|
||||
* stats count and the resulting table.
|
||||
*
|
||||
* The count is not asserted against the stats for 'All', since tests use it
|
||||
* with search queries applied which only the table respects.
|
||||
*/
|
||||
export const checkDocumentTabCount = async (page: Page, tabName: string, count: number) => {
|
||||
if (tabName === 'All') {
|
||||
await clearDocumentStatusFilter(page);
|
||||
} else {
|
||||
await expect(page.getByTestId(`documents-status-count-${tabName.toUpperCase()}`)).toHaveText(count.toString());
|
||||
|
||||
await selectDocumentStatusFilter(page, tabName);
|
||||
}
|
||||
|
||||
if (count === 0) {
|
||||
|
||||
@@ -35,12 +35,24 @@ test('[ORGANISATIONS]: manage document preferences', async ({ page }) => {
|
||||
await page.getByTestId('signature-types-trigger').click();
|
||||
await page.getByRole('option', { name: 'Draw' }).click();
|
||||
await page.getByRole('option', { name: 'Upload' }).click();
|
||||
await page.keyboard.press('Escape');
|
||||
|
||||
await page.getByRole('button', { name: 'Save changes' }).first().click();
|
||||
await expect(page.getByText('Your document preferences have been updated').first()).toBeVisible();
|
||||
|
||||
// Sender details moved to the email preferences page.
|
||||
await page.goto(`/o/${organisation.url}/settings/email`);
|
||||
await page.getByTestId('include-sender-details-trigger').click();
|
||||
await page.getByRole('option', { name: 'No' }).click();
|
||||
await page.getByRole('button', { name: 'Save changes' }).first().click();
|
||||
await expect(page.getByText('Your email preferences have been updated').first()).toBeVisible();
|
||||
|
||||
// The signing certificate toggle moved to the certificates page.
|
||||
await page.goto(`/o/${organisation.url}/settings/certificates`);
|
||||
await page.getByTestId('include-signing-certificate-trigger').click();
|
||||
await page.getByRole('option', { name: 'No' }).click();
|
||||
await page.getByRole('button', { name: 'Save changes' }).first().click();
|
||||
await expect(page.getByText('Your document preferences have been updated').first()).toBeVisible();
|
||||
await expect(page.getByText('Your certificate preferences have been updated').first()).toBeVisible();
|
||||
|
||||
const teamSettings = await getTeamSettings({
|
||||
teamId: team.id,
|
||||
@@ -236,8 +248,14 @@ test('[ORGANISATIONS]: manage email preferences', async ({ page }) => {
|
||||
await page.getByRole('textbox', { name: 'Reply to email' }).click();
|
||||
await page.getByRole('textbox', { name: 'Reply to email' }).fill('team@example.com');
|
||||
|
||||
// Change email document settings inheritance to controlled
|
||||
await page.getByRole('combobox').filter({ hasText: 'Inherit from organisation' }).click();
|
||||
// Change email document settings inheritance to controlled. Scope to the
|
||||
// email-document-settings field — the sender-details select on this page also
|
||||
// renders an "Inherit from organisation" value.
|
||||
await page
|
||||
.getByTestId('inheritable-email-document-settings')
|
||||
.getByRole('combobox')
|
||||
.filter({ hasText: 'Inherit from organisation' })
|
||||
.click();
|
||||
await page.getByRole('option', { name: 'Override organisation settings' }).click();
|
||||
|
||||
// Update some email settings
|
||||
|
||||
@@ -6,6 +6,7 @@ import { expect, test } from '@playwright/test';
|
||||
|
||||
import { apiSignin } from '../fixtures/authentication';
|
||||
import { expectToastTextToBeVisible } from '../fixtures/generic';
|
||||
import { signSignaturePad } from '../fixtures/signature';
|
||||
|
||||
test('[PUBLIC_PROFILE]: create team profile', async ({ page }) => {
|
||||
const { user, team } = await seedUser();
|
||||
@@ -73,11 +74,53 @@ test('[PUBLIC_PROFILE]: create team profile', async ({ page }) => {
|
||||
await expect(page.locator('body')).toContainText('public-direct-template-title');
|
||||
await expect(page.locator('body')).toContainText('public-direct-template-description');
|
||||
|
||||
const directSignatureField = directTemplate.fields[0];
|
||||
|
||||
if (!directSignatureField) {
|
||||
throw new Error('Expected seeded direct template signature field to exist');
|
||||
}
|
||||
|
||||
await page.getByRole('link', { name: 'Sign' }).click();
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
|
||||
await signSignaturePad(page);
|
||||
await page.locator(`#field-${directSignatureField.id}`).getByRole('button').click();
|
||||
await expect(page.locator(`#field-${directSignatureField.id}`)).toHaveAttribute('data-inserted', 'true');
|
||||
|
||||
await page.getByRole('button', { name: 'Complete' }).click();
|
||||
await page.getByRole('button', { name: 'Sign' }).click();
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Document Signed' })).toBeVisible();
|
||||
await expect(page.getByRole('heading')).toContainText('Document Signed');
|
||||
});
|
||||
|
||||
test('[PUBLIC_PROFILE]: empty-profile settings hint only shows to team managers', async ({ page }) => {
|
||||
const { user, team } = await seedUser();
|
||||
|
||||
// Enable the team's public profile with no linked templates so the empty
|
||||
// state (and its "manage your profile" hint) renders.
|
||||
await prisma.teamProfile.upsert({
|
||||
where: { teamId: team.id },
|
||||
update: { enabled: true },
|
||||
create: { teamId: team.id, enabled: true },
|
||||
});
|
||||
|
||||
// The team owner manages the team → sees the hint linking straight to the
|
||||
// team's public-profile settings.
|
||||
await apiSignin({ page, email: user.email });
|
||||
await page.goto(`${NEXT_PUBLIC_WEBAPP_URL()}/p/${team.url}`);
|
||||
|
||||
const settingsLink = page.getByRole('link', { name: 'public profile settings' });
|
||||
await expect(settingsLink).toBeVisible();
|
||||
await expect(settingsLink).toHaveAttribute('href', `/t/${team.url}/settings/public-profile`);
|
||||
|
||||
// A different signed-in user who doesn't manage this team sees the empty state
|
||||
// but no settings hint.
|
||||
const { user: stranger } = await seedUser();
|
||||
|
||||
await apiSignin({ page, email: stranger.email });
|
||||
await page.goto(`${NEXT_PUBLIC_WEBAPP_URL()}/p/${team.url}`);
|
||||
|
||||
await expect(page.getByText("hasn't added any documents")).toBeVisible();
|
||||
await expect(page.getByRole('link', { name: 'public profile settings' })).toHaveCount(0);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import { seedOrganisationMembers } from '@documenso/prisma/seed/organisations';
|
||||
import { seedTeam } from '@documenso/prisma/seed/teams';
|
||||
import type { Page } from '@playwright/test';
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { OrganisationMemberRole } from '@prisma/client';
|
||||
|
||||
import { apiSignin } from '../fixtures/authentication';
|
||||
|
||||
const readPreferredTeamUrl = async (page: Page) => {
|
||||
const cookies = await page.context().cookies();
|
||||
|
||||
return cookies.find((cookie) => cookie.name === 'preferred-team-url')?.value ?? null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Two organisations the signed-in user administers, each with its own team.
|
||||
*/
|
||||
const seedTwoOrganisations = async () => {
|
||||
const { owner, team: teamA, organisation: orgA } = await seedTeam();
|
||||
const { organisation: orgB, team: teamB } = await seedTeam();
|
||||
|
||||
await seedOrganisationMembers({
|
||||
members: [{ email: owner.email, organisationRole: OrganisationMemberRole.ADMIN }],
|
||||
organisationId: orgB.id,
|
||||
});
|
||||
|
||||
return { owner, orgA, teamA, orgB, teamB };
|
||||
};
|
||||
|
||||
const switchOrganisationInSettings = async (page: Page, organisationUrl: string) => {
|
||||
const sidebar = page.getByTestId('unified-settings-sidebar');
|
||||
|
||||
await sidebar.getByTestId('settings-org-switcher-trigger').click();
|
||||
await page.getByTestId(`settings-org-switcher-item-${organisationUrl}`).click();
|
||||
await page.waitForURL(`/o/${organisationUrl}/settings/general`);
|
||||
};
|
||||
|
||||
test.describe('Preferred team cookie', () => {
|
||||
test('switching organisation in settings records a team from that organisation', async ({ page }) => {
|
||||
const { owner, teamA, orgB, teamB } = await seedTwoOrganisations();
|
||||
|
||||
await apiSignin({ page, email: owner.email });
|
||||
|
||||
await page.goto(`/t/${teamA.url}/settings/general`);
|
||||
expect(await readPreferredTeamUrl(page)).toBe(teamA.url);
|
||||
|
||||
await switchOrganisationInSettings(page, orgB.url);
|
||||
|
||||
// Recorded by the settings layout, which posts asynchronously rather than blocking the
|
||||
// navigation, so the swap lands shortly after the URL changes.
|
||||
await expect.poll(() => readPreferredTeamUrl(page)).toBe(teamB.url);
|
||||
});
|
||||
|
||||
test('app root redirects into the organisation last selected in settings', async ({ page }) => {
|
||||
const { owner, teamA, orgB, teamB } = await seedTwoOrganisations();
|
||||
|
||||
await apiSignin({ page, email: owner.email });
|
||||
|
||||
await page.goto(`/t/${teamA.url}/settings/general`);
|
||||
await switchOrganisationInSettings(page, orgB.url);
|
||||
|
||||
await expect.poll(() => readPreferredTeamUrl(page)).toBe(teamB.url);
|
||||
|
||||
await page.goto('/');
|
||||
await expect(page).toHaveURL(`/t/${teamB.url}/documents`);
|
||||
});
|
||||
|
||||
test('switching team in settings records the newly selected team', async ({ page }) => {
|
||||
const { owner, teamA, orgB, teamB } = await seedTwoOrganisations();
|
||||
|
||||
await apiSignin({ page, email: owner.email });
|
||||
|
||||
await page.goto(`/t/${teamB.url}/settings/general`);
|
||||
expect(await readPreferredTeamUrl(page)).toBe(teamB.url);
|
||||
|
||||
await page.goto(`/t/${teamA.url}/settings/general`);
|
||||
expect(await readPreferredTeamUrl(page)).toBe(teamA.url);
|
||||
|
||||
await page.goto('/');
|
||||
await expect(page).toHaveURL(`/t/${teamA.url}/documents`);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,554 @@
|
||||
import { createTeam } from '@documenso/lib/server-only/team/create-team';
|
||||
import { nanoid } from '@documenso/lib/universal/id';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { seedTeam, seedTeamMember } from '@documenso/prisma/seed/teams';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { TeamMemberRole } from '@prisma/client';
|
||||
|
||||
import { apiSignin } from '../fixtures/authentication';
|
||||
|
||||
/**
|
||||
* Every seeded user is given their own organisation. Removing it leaves the user with only
|
||||
* the access that was explicitly granted, which is how we reach the "team access only" and
|
||||
* "no organisations at all" states.
|
||||
*/
|
||||
const deleteOwnedOrganisations = async (userId: number) => {
|
||||
await prisma.organisation.deleteMany({
|
||||
where: {
|
||||
ownerUserId: userId,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
test.describe('Unified Settings', () => {
|
||||
test('shows both groups for the team owner at team scope', async ({ page }) => {
|
||||
const { owner, team, organisation } = await seedTeam();
|
||||
|
||||
await apiSignin({ page, email: owner.email });
|
||||
await page.goto(`/t/${team.url}/settings`);
|
||||
|
||||
const sidebar = page.getByTestId('unified-settings-sidebar');
|
||||
await expect(sidebar).toBeVisible();
|
||||
|
||||
const groups = sidebar.getByTestId('unified-settings-sidebar-group');
|
||||
// Organisation + Team groups, plus the always-visible Account group.
|
||||
await expect(groups).toHaveCount(3);
|
||||
|
||||
await expect(sidebar.getByTestId('settings-org-switcher-trigger')).toContainText(organisation.name);
|
||||
await expect(sidebar.getByTestId('settings-team-switcher-trigger')).toContainText(team.name);
|
||||
|
||||
// Nav item labels are lingui `msg` descriptors resolved to strings at render —
|
||||
// assert the visible text so a broken translation (blank / [object Object])
|
||||
// would fail here. Test ids are scope-qualified because item keys repeat across groups.
|
||||
await expect(sidebar.getByTestId('unified-settings-nav-team-members')).toContainText('Members');
|
||||
await expect(sidebar.getByTestId('unified-settings-nav-team-preferences')).toContainText('Preferences');
|
||||
await expect(sidebar.getByTestId('unified-settings-nav-organisation-members')).toContainText('Members');
|
||||
await expect(sidebar.getByTestId('unified-settings-nav-account-profile')).toContainText('Profile');
|
||||
});
|
||||
|
||||
test('shows both groups for the team owner at org scope (team-fallback)', async ({ page }) => {
|
||||
const { owner, team, organisation } = await seedTeam();
|
||||
|
||||
await apiSignin({ page, email: owner.email });
|
||||
// At org scope `useOptionalCurrentTeam()` is null, but the layout falls
|
||||
// back to the user's first manageable team in the current org so both
|
||||
// groups still render.
|
||||
await page.goto(`/o/${organisation.url}/settings`);
|
||||
|
||||
const sidebar = page.getByTestId('unified-settings-sidebar');
|
||||
await expect(sidebar).toBeVisible();
|
||||
|
||||
const groups = sidebar.getByTestId('unified-settings-sidebar-group');
|
||||
// Organisation + Team groups, plus the always-visible Account group.
|
||||
await expect(groups).toHaveCount(3);
|
||||
|
||||
await expect(sidebar.getByTestId('settings-org-switcher-trigger')).toContainText(organisation.name);
|
||||
// Team switcher shows the fallback team (the user's first manageable team in this org).
|
||||
await expect(sidebar.getByTestId('settings-team-switcher-trigger')).toContainText(team.name);
|
||||
|
||||
// The empty state is only for users who can't manage the organisation.
|
||||
await expect(sidebar.getByTestId('unified-settings-organisation-empty-state')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('sidebar is flush with the left viewport edge', async ({ page }) => {
|
||||
const { owner, organisation } = await seedTeam();
|
||||
|
||||
// Wide viewport — a centered max-w-screen-xl container would offset the
|
||||
// sidebar by (1600 - 1280) / 2 = 160px+, while flush-left is ~16px (the
|
||||
// aside's own internal padding).
|
||||
await page.setViewportSize({ width: 1600, height: 900 });
|
||||
|
||||
await apiSignin({ page, email: owner.email });
|
||||
await page.goto(`/o/${organisation.url}/settings`);
|
||||
|
||||
const sidebar = page.getByTestId('unified-settings-sidebar');
|
||||
await expect(sidebar).toBeVisible();
|
||||
|
||||
const box = await sidebar.boundingBox();
|
||||
|
||||
expect(box?.x ?? Number.MAX_SAFE_INTEGER).toBeLessThan(100);
|
||||
|
||||
// The app header stretches to the full viewport width on settings pages.
|
||||
const headerContainer = page.getByTestId('app-header-container');
|
||||
const headerBox = await headerContainer.boundingBox();
|
||||
|
||||
expect(headerBox?.x ?? Number.MAX_SAFE_INTEGER).toBeLessThan(50);
|
||||
expect((headerBox?.x ?? 0) + (headerBox?.width ?? 0)).toBeGreaterThan(1550);
|
||||
|
||||
// Outside of settings the header keeps its centered max-w-screen-xl container.
|
||||
await page.goto(`/o/${organisation.url}`);
|
||||
await expect(headerContainer).toBeVisible();
|
||||
|
||||
const centeredHeaderBox = await headerContainer.boundingBox();
|
||||
|
||||
expect(centeredHeaderBox?.x ?? 0).toBeGreaterThan(100);
|
||||
});
|
||||
|
||||
test('content is centered within the pane beside the sidebar', async ({ page }) => {
|
||||
const { owner, organisation } = await seedTeam();
|
||||
|
||||
await page.setViewportSize({ width: 1600, height: 900 });
|
||||
|
||||
await apiSignin({ page, email: owner.email });
|
||||
await page.goto(`/o/${organisation.url}/settings`);
|
||||
|
||||
const content = page.getByTestId('unified-settings-content');
|
||||
await expect(content).toBeVisible();
|
||||
|
||||
const contentBox = await content.boundingBox();
|
||||
|
||||
// The pane spans from the sidebar's right edge (fixed 320px aside) to the
|
||||
// viewport edge. The content container should be centered within it.
|
||||
const paneCenter = (320 + 1600) / 2;
|
||||
const contentCenter = (contentBox?.x ?? 0) + (contentBox?.width ?? 0) / 2;
|
||||
|
||||
expect(Math.abs(contentCenter - paneCenter)).toBeLessThan(24);
|
||||
});
|
||||
|
||||
test('keeps current section when switching teams', async ({ page }) => {
|
||||
// Seed one team, then add a second team to the same organisation.
|
||||
const { owner, team: team1, organisation } = await seedTeam();
|
||||
|
||||
const team2Url = `team-two-${nanoid()}`;
|
||||
|
||||
await createTeam({
|
||||
userId: owner.id,
|
||||
teamName: 'Team Two',
|
||||
teamUrl: team2Url,
|
||||
organisationId: organisation.id,
|
||||
inheritMembers: true,
|
||||
});
|
||||
|
||||
await apiSignin({ page, email: owner.email });
|
||||
await page.goto(`/t/${team1.url}/settings/members`);
|
||||
|
||||
// Scope to the desktop sidebar — the mobile sidebar also renders the
|
||||
// same testid.
|
||||
const sidebar = page.getByTestId('unified-settings-sidebar');
|
||||
await sidebar.getByTestId('settings-team-switcher-trigger').click();
|
||||
|
||||
// The popover content matches the trigger width.
|
||||
const triggerBox = await sidebar.getByTestId('settings-team-switcher-trigger').boundingBox();
|
||||
const contentBox = await page.getByTestId('settings-team-switcher-content').boundingBox();
|
||||
|
||||
expect(Math.abs((contentBox?.width ?? 0) - (triggerBox?.width ?? -1))).toBeLessThan(2);
|
||||
|
||||
await page.getByTestId(`settings-team-switcher-item-${team2Url}`).click();
|
||||
|
||||
await page.waitForURL(`/t/${team2Url}/settings/members`);
|
||||
await expect(page).toHaveURL(`/t/${team2Url}/settings/members`);
|
||||
});
|
||||
|
||||
test('account settings keep the organisation the user was working in', async ({ page }) => {
|
||||
// The user administers their own organisation, but only manages a team in the seeded
|
||||
// one — so the two differ in whether organisation settings are reachable.
|
||||
const { team: teamInOtherOrg, organisation: otherOrganisation } = await seedTeam();
|
||||
|
||||
const user = await seedTeamMember({ teamId: teamInOtherOrg.id, role: TeamMemberRole.MANAGER });
|
||||
|
||||
const ownedOrganisation = await prisma.organisation.findFirstOrThrow({
|
||||
where: { ownerUserId: user.id },
|
||||
include: { teams: true },
|
||||
});
|
||||
|
||||
// Both are seeded as "Personal Organisation", so rename them to tell the switcher apart.
|
||||
await prisma.organisation.update({ where: { id: ownedOrganisation.id }, data: { name: 'Org I Administer' } });
|
||||
await prisma.organisation.update({
|
||||
where: { id: otherOrganisation.id },
|
||||
data: { name: 'Org I Only Have A Team In' },
|
||||
});
|
||||
|
||||
await apiSignin({ page, email: user.email });
|
||||
|
||||
const sidebar = page.getByTestId('unified-settings-sidebar');
|
||||
const orgTrigger = sidebar.getByTestId('settings-org-switcher-trigger');
|
||||
|
||||
// `organisations` comes back unordered, so which one account scope falls back to isn't
|
||||
// fixed. Read it cold, then work in the *other* one — otherwise the test can pass just
|
||||
// because the fallback already happened to be the right organisation.
|
||||
await page.goto('/settings/profile');
|
||||
|
||||
const fallbackIsOwned = ((await orgTrigger.textContent()) ?? '').includes('Org I Administer');
|
||||
|
||||
const target = fallbackIsOwned
|
||||
? { name: 'Org I Only Have A Team In', teamUrl: teamInOtherOrg.url }
|
||||
: { name: 'Org I Administer', teamUrl: ownedOrganisation.teams[0].url };
|
||||
|
||||
await page.goto(`/t/${target.teamUrl}/settings/general`);
|
||||
await expect(orgTrigger).toContainText(target.name);
|
||||
|
||||
// Account scope has no organisation in the URL either, so it must not silently jump
|
||||
// back to whichever organisation happens to be first.
|
||||
await sidebar.getByTestId('unified-settings-nav-account-profile').click();
|
||||
await page.waitForURL('/settings/profile');
|
||||
await expect(page.getByTestId('settings-scope-breadcrumb-chip')).toContainText('Account Settings');
|
||||
|
||||
await expect(orgTrigger).toContainText(target.name);
|
||||
});
|
||||
|
||||
test('team switcher keeps the selected team when moving to organisation scope', async ({ page }) => {
|
||||
const { owner, team: team1, organisation } = await seedTeam();
|
||||
|
||||
// Lowercased to match what `ZTeamUrlSchema` stores — `createTeam` is called directly
|
||||
// here, bypassing the tRPC input schema that would normalise it in the real flow.
|
||||
const team2Url = `team-two-${nanoid()}`.toLowerCase();
|
||||
|
||||
await createTeam({
|
||||
userId: owner.id,
|
||||
teamName: 'Team Two',
|
||||
teamUrl: team2Url,
|
||||
organisationId: organisation.id,
|
||||
inheritMembers: true,
|
||||
});
|
||||
|
||||
await apiSignin({ page, email: owner.email });
|
||||
|
||||
const sidebar = page.getByTestId('unified-settings-sidebar');
|
||||
const trigger = sidebar.getByTestId('settings-team-switcher-trigger');
|
||||
|
||||
// `organisation.teams` comes back unordered, so which team the sidebar falls back to
|
||||
// isn't fixed. Read it first, then deliberately select the *other* one — otherwise the
|
||||
// test can pass simply because the fallback already happened to be the right team.
|
||||
await page.goto(`/o/${organisation.url}/settings/general`);
|
||||
|
||||
const fallbackIsTeam2 = ((await trigger.textContent()) ?? '').includes('Team Two');
|
||||
const selected = fallbackIsTeam2 ? { url: team1.url, name: team1.name } : { url: team2Url, name: 'Team Two' };
|
||||
|
||||
await page.goto(`/t/${team2Url}/settings/general`);
|
||||
await trigger.click();
|
||||
await page.getByTestId(`settings-team-switcher-item-${selected.url}`).click();
|
||||
await page.waitForURL(`/t/${selected.url}/settings/general`);
|
||||
await expect(trigger).toContainText(selected.name);
|
||||
|
||||
// Organisation scope has no team in the URL, so the sidebar has to remember which team
|
||||
// the user picked rather than falling back to whichever one happens to be first.
|
||||
await sidebar.getByTestId('unified-settings-nav-organisation-general').click();
|
||||
await page.waitForURL(`/o/${organisation.url}/settings/general`);
|
||||
|
||||
// Wait for the organisation page to actually render — asserting straight after
|
||||
// `waitForURL` can read the previous scope's still-mounted sidebar and pass falsely.
|
||||
await expect(page.getByTestId('settings-scope-breadcrumb-chip')).toContainText('Organisation Settings');
|
||||
|
||||
await expect(trigger).toContainText(selected.name);
|
||||
|
||||
// The selection must also survive in the cookie — otherwise the app root would send the
|
||||
// user back to the wrong team.
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const cookies = await page.context().cookies();
|
||||
|
||||
return cookies.find((cookie) => cookie.name === 'preferred-team-url')?.value ?? null;
|
||||
})
|
||||
.toBe(selected.url);
|
||||
});
|
||||
|
||||
test('inheritable field toggles between INHERITED and OVERRIDDEN', async ({ page }) => {
|
||||
const { owner, team } = await seedTeam();
|
||||
|
||||
await apiSignin({ page, email: owner.email });
|
||||
await page.goto(`/t/${team.url}/settings/document`);
|
||||
|
||||
const langStatus = page.getByTestId('document-language-status');
|
||||
await expect(langStatus).toHaveText(/inherited/i);
|
||||
|
||||
// Open the language select and pick a non-default value.
|
||||
await page.getByTestId('document-language-trigger').click();
|
||||
await page
|
||||
.getByRole('option', { name: /english/i })
|
||||
.first()
|
||||
.click();
|
||||
|
||||
await expect(langStatus).toHaveText(/override/i);
|
||||
|
||||
// Selecting the inherit option stages the field back to inherited.
|
||||
await page.getByTestId('document-language-trigger').click();
|
||||
await page.getByRole('option', { name: /inherit from organisation/i }).click();
|
||||
|
||||
await expect(langStatus).toHaveText(/inherited/i);
|
||||
});
|
||||
|
||||
test('branding fields toggle between INHERITED and OVERRIDDEN', async ({ page }) => {
|
||||
const { owner, team } = await seedTeam();
|
||||
|
||||
await apiSignin({ page, email: owner.email });
|
||||
await page.goto(`/t/${team.url}/settings/branding`);
|
||||
|
||||
const enabledStatus = page.getByTestId('branding-enabled-status');
|
||||
const urlStatus = page.getByTestId('branding-url-status');
|
||||
|
||||
await expect(enabledStatus).toHaveText(/inherited/i);
|
||||
await expect(urlStatus).toHaveText(/inherited/i);
|
||||
|
||||
// Enable branding — unlocks the other fields and overrides the tri-state select.
|
||||
await page.getByTestId('enable-branding').click();
|
||||
await page.getByRole('option', { name: /yes/i }).click();
|
||||
|
||||
await expect(enabledStatus).toHaveText(/override/i);
|
||||
|
||||
// Override the brand website (inherit sentinel is the empty string).
|
||||
await page.getByPlaceholder('https://example.com').fill('https://example.org');
|
||||
|
||||
await expect(urlStatus).toHaveText(/override/i);
|
||||
|
||||
// Clearing the field stages it back to its inherit sentinel (empty string).
|
||||
await page.getByPlaceholder('https://example.com').fill('');
|
||||
|
||||
await expect(urlStatus).toHaveText(/inherited/i);
|
||||
});
|
||||
|
||||
test('reminders page renders extracted fields with inheritance badges', async ({ page }) => {
|
||||
const { owner, team } = await seedTeam();
|
||||
|
||||
await apiSignin({ page, email: owner.email });
|
||||
await page.goto(`/t/${team.url}/settings/reminders`);
|
||||
|
||||
await expect(page.getByTestId('envelope-expiration-period-status')).toHaveText(/inherited/i);
|
||||
await expect(page.getByTestId('reminder-settings-status')).toHaveText(/inherited/i);
|
||||
|
||||
// The fields were extracted out of the document preferences page.
|
||||
await page.goto(`/t/${team.url}/settings/document`);
|
||||
await expect(page.getByTestId('document-language-status')).toBeVisible();
|
||||
await expect(page.getByTestId('envelope-expiration-period-status')).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('certificates page renders extracted fields with inheritance badges', async ({ page }) => {
|
||||
const { owner, team } = await seedTeam();
|
||||
|
||||
await apiSignin({ page, email: owner.email });
|
||||
await page.goto(`/t/${team.url}/settings/certificates`);
|
||||
|
||||
await expect(page.getByTestId('include-signing-certificate-status')).toHaveText(/inherited/i);
|
||||
await expect(page.getByTestId('include-audit-log-status')).toHaveText(/inherited/i);
|
||||
});
|
||||
|
||||
test('send on behalf of team lives on the email preferences page', async ({ page }) => {
|
||||
const { owner, team } = await seedTeam();
|
||||
|
||||
await apiSignin({ page, email: owner.email });
|
||||
await page.goto(`/t/${team.url}/settings/email`);
|
||||
|
||||
await expect(page.getByTestId('include-sender-details-status')).toHaveText(/inherited/i);
|
||||
|
||||
// Moved out of the document preferences page.
|
||||
await page.goto(`/t/${team.url}/settings/document`);
|
||||
await expect(page.getByTestId('document-language-status')).toBeVisible();
|
||||
await expect(page.getByTestId('include-sender-details-status')).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('account settings render inside the unified layout', async ({ page }) => {
|
||||
const { owner } = await seedTeam();
|
||||
|
||||
await apiSignin({ page, email: owner.email });
|
||||
await page.goto('/settings/profile');
|
||||
|
||||
const sidebar = page.getByTestId('unified-settings-sidebar');
|
||||
await expect(sidebar).toBeVisible();
|
||||
|
||||
// Org + team groups render via the manageable-organisation fallback, and the
|
||||
// Account group is always present.
|
||||
await expect(sidebar.getByTestId('unified-settings-sidebar-group')).toHaveCount(3);
|
||||
|
||||
await expect(page.getByTestId('settings-scope-breadcrumb-chip')).toContainText('Account Settings');
|
||||
});
|
||||
|
||||
test('personal team can save email preferences', async ({ page }) => {
|
||||
const { user, team } = await seedUser({ isPersonalOrganisation: true });
|
||||
|
||||
await apiSignin({ page, email: user.email });
|
||||
await page.goto(`/t/${team.url}/settings/email`);
|
||||
|
||||
// The sender-details field is hidden for personal orgs and its unchanged
|
||||
// inherit sentinel is echoed back on submit — the server must drop it as a
|
||||
// no-op rather than rejecting the whole update.
|
||||
await page.getByPlaceholder('noreply@example.com').fill('replies@example.com');
|
||||
|
||||
await page.getByRole('button', { name: /save changes/i }).click();
|
||||
|
||||
await expect(page.getByText('Email preferences updated').first()).toBeVisible({ timeout: 15_000 });
|
||||
});
|
||||
|
||||
test('content pane scrolls back to top when navigating between sections', async ({ page }) => {
|
||||
const { owner, team } = await seedTeam();
|
||||
|
||||
// Short (but still md+) viewport so the document preferences page overflows
|
||||
// the internally-scrolling content pane.
|
||||
await page.setViewportSize({ width: 1280, height: 720 });
|
||||
|
||||
await apiSignin({ page, email: owner.email });
|
||||
await page.goto(`/t/${team.url}/settings/document`);
|
||||
|
||||
// Wait for the preferences form itself — the pane only overflows once the
|
||||
// form has loaded (the query-loading spinner is shorter than the pane).
|
||||
await expect(page.getByTestId('document-language-trigger')).toBeVisible();
|
||||
|
||||
// The content pane is the <main> wrapping the content container.
|
||||
const contentPane = page.getByTestId('unified-settings-content').locator('..');
|
||||
|
||||
// Scroll the pane down (the document preferences page overflows it).
|
||||
await contentPane.evaluate((el) => el.scrollTo(0, el.scrollHeight));
|
||||
|
||||
const scrolledOffset = await contentPane.evaluate((el) => el.scrollTop);
|
||||
expect(scrolledOffset).toBeGreaterThan(0);
|
||||
|
||||
// Navigate to another section via the sidebar (the members testid exists in
|
||||
// both scope groups, so target the team group's link by href).
|
||||
await page.getByTestId('unified-settings-sidebar').locator(`a[href="/t/${team.url}/settings/members"]`).click();
|
||||
await expect(page).toHaveURL(`/t/${team.url}/settings/members`);
|
||||
|
||||
await expect.poll(async () => await contentPane.evaluate((el) => el.scrollTop)).toBe(0);
|
||||
});
|
||||
|
||||
test('deleted personal-layout URL returns 404', async ({ page }) => {
|
||||
const { user } = await seedUser();
|
||||
|
||||
await apiSignin({ page, email: user.email });
|
||||
const response = await page.goto('/settings/document');
|
||||
|
||||
expect(response?.status()).toBe(404);
|
||||
});
|
||||
|
||||
test('team-only access shows the org switcher but no organisation pages', async ({ page }) => {
|
||||
const { team, organisation } = await seedTeam();
|
||||
|
||||
const manager = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.MANAGER });
|
||||
await deleteOwnedOrganisations(manager.id);
|
||||
|
||||
await apiSignin({ page, email: manager.email });
|
||||
await page.goto(`/t/${team.url}/settings/general`);
|
||||
|
||||
const sidebar = page.getByTestId('unified-settings-sidebar');
|
||||
await expect(sidebar).toBeVisible();
|
||||
|
||||
// The Organisation group still renders so it can host the switcher — that's the only
|
||||
// way this user can move between organisations — but it exposes no pages.
|
||||
await expect(sidebar.getByTestId('settings-org-switcher-trigger')).toContainText(organisation.name);
|
||||
await expect(sidebar.getByTestId('unified-settings-nav-organisation-general')).toHaveCount(0);
|
||||
await expect(sidebar.getByTestId('unified-settings-nav-organisation-members')).toHaveCount(0);
|
||||
await expect(sidebar.getByTestId('unified-settings-nav-organisation-billing')).toHaveCount(0);
|
||||
|
||||
// An empty group would just look broken, so it explains itself directly under the switcher.
|
||||
const emptyState = sidebar.getByTestId('unified-settings-organisation-empty-state');
|
||||
await expect(emptyState).toBeVisible();
|
||||
await expect(emptyState).toContainText(/permission to manage this organisation/i);
|
||||
|
||||
// Team and account pages remain navigable.
|
||||
await expect(sidebar.getByTestId('unified-settings-nav-team-general')).toBeVisible();
|
||||
await expect(sidebar.getByTestId('unified-settings-nav-team-members')).toBeVisible();
|
||||
await expect(sidebar.getByTestId('unified-settings-nav-account-profile')).toBeVisible();
|
||||
});
|
||||
|
||||
test('team-only access is rejected from organisation settings', async ({ page }) => {
|
||||
const { team, organisation } = await seedTeam();
|
||||
|
||||
const manager = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.MANAGER });
|
||||
await deleteOwnedOrganisations(manager.id);
|
||||
|
||||
await apiSignin({ page, email: manager.email });
|
||||
|
||||
// Managing a team must not grant access to the organisation scope.
|
||||
await page.goto(`/o/${organisation.url}/settings/general`);
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Unauthorized' })).toBeVisible();
|
||||
await expect(page.getByRole('link', { name: /go to your settings/i })).toBeVisible();
|
||||
await expect(page.getByTestId('unified-settings-sidebar')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('team member without manage permission is rejected from team settings', async ({ page }) => {
|
||||
const { team } = await seedTeam();
|
||||
|
||||
const member = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.MEMBER });
|
||||
|
||||
await apiSignin({ page, email: member.email });
|
||||
await page.goto(`/t/${team.url}/settings/general`);
|
||||
|
||||
// The team settings loader redirects out of the settings tree on a full page load.
|
||||
await expect(page).not.toHaveURL(/\/settings\//);
|
||||
await expect(page.getByTestId('unified-settings-sidebar')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('user with no organisations only sees account settings', async ({ page }) => {
|
||||
const { user } = await seedUser();
|
||||
|
||||
await deleteOwnedOrganisations(user.id);
|
||||
|
||||
await apiSignin({ page, email: user.email });
|
||||
await page.goto('/settings/profile');
|
||||
|
||||
const sidebar = page.getByTestId('unified-settings-sidebar');
|
||||
await expect(sidebar).toBeVisible();
|
||||
|
||||
await expect(sidebar.getByTestId('unified-settings-sidebar-group')).toHaveCount(1);
|
||||
await expect(sidebar.getByTestId('unified-settings-nav-account-profile')).toBeVisible();
|
||||
await expect(sidebar.getByTestId('unified-settings-nav-account-security')).toBeVisible();
|
||||
|
||||
// No organisation in context means no switcher and no scoped groups.
|
||||
await expect(sidebar.getByTestId('settings-org-switcher-trigger')).toHaveCount(0);
|
||||
await expect(sidebar.getByTestId('settings-team-switcher-trigger')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('switching to an organisation the user cannot manage lands in team scope', async ({ page }) => {
|
||||
const { team: otherTeam, organisation: otherOrganisation } = await seedTeam();
|
||||
|
||||
// `seedTeamMember` seeds the user with their own organisation (which they own) and
|
||||
// then grants them a team role in the seeded organisation — exactly the mixed-access
|
||||
// shape the switcher has to handle.
|
||||
const manager = await seedTeamMember({ teamId: otherTeam.id, role: TeamMemberRole.MANAGER });
|
||||
|
||||
const ownedOrganisation = await prisma.organisation.findFirstOrThrow({
|
||||
where: { ownerUserId: manager.id },
|
||||
});
|
||||
|
||||
await apiSignin({ page, email: manager.email });
|
||||
await page.goto(`/o/${ownedOrganisation.url}/settings/members`);
|
||||
|
||||
const sidebar = page.getByTestId('unified-settings-sidebar');
|
||||
await sidebar.getByTestId('settings-org-switcher-trigger').click();
|
||||
await page.getByTestId(`settings-org-switcher-item-${otherOrganisation.url}`).click();
|
||||
|
||||
// `members` exists under both scopes so the section carries over, but the scope drops
|
||||
// to team because the user can't manage the destination organisation.
|
||||
await page.waitForURL(`/t/${otherTeam.url}/settings/members`);
|
||||
});
|
||||
|
||||
test('switching scope falls back to General when the section does not exist there', async ({ page }) => {
|
||||
const { team: otherTeam, organisation: otherOrganisation } = await seedTeam();
|
||||
|
||||
const manager = await seedTeamMember({ teamId: otherTeam.id, role: TeamMemberRole.MANAGER });
|
||||
|
||||
const ownedOrganisation = await prisma.organisation.findFirstOrThrow({
|
||||
where: { ownerUserId: manager.id },
|
||||
});
|
||||
|
||||
await apiSignin({ page, email: manager.email });
|
||||
|
||||
// `teams` only exists under organisation scope.
|
||||
await page.goto(`/o/${ownedOrganisation.url}/settings/teams`);
|
||||
|
||||
const sidebar = page.getByTestId('unified-settings-sidebar');
|
||||
await sidebar.getByTestId('settings-org-switcher-trigger').click();
|
||||
await page.getByTestId(`settings-org-switcher-item-${otherOrganisation.url}`).click();
|
||||
|
||||
await page.waitForURL(`/t/${otherTeam.url}/settings/general`);
|
||||
});
|
||||
});
|
||||
@@ -110,7 +110,7 @@ test.describe('Default Recipients', () => {
|
||||
await page.getByRole('button', { name: 'Add Signer' }).click();
|
||||
|
||||
// Add a regular signer using the v2 editor
|
||||
await page.getByTestId('signer-email-input').last().fill('regular-signer@documenso.com');
|
||||
await page.getByTestId('signer-email-input').first().fill('regular-signer@documenso.com');
|
||||
await page
|
||||
.getByPlaceholder(/Recipient/)
|
||||
.first()
|
||||
|
||||
@@ -69,5 +69,6 @@ test('[TEAMS]: update team', async ({ page }) => {
|
||||
await page.getByRole('button', { name: 'Save changes' }).click();
|
||||
|
||||
// Check we have been redirected to the new team URL and the name is updated.
|
||||
await page.waitForURL(`${NEXT_PUBLIC_WEBAPP_URL()}/t/${updatedTeamId}/settings`);
|
||||
// The team settings index redirects to the explicit General route.
|
||||
await page.waitForURL(`${NEXT_PUBLIC_WEBAPP_URL()}/t/${updatedTeamId}/settings/general`);
|
||||
});
|
||||
|
||||
@@ -5,7 +5,7 @@ import { expect, test } from '@playwright/test';
|
||||
import { DocumentStatus, DocumentVisibility, TeamMemberRole } from '@prisma/client';
|
||||
|
||||
import { apiSignin, apiSignout } from '../fixtures/authentication';
|
||||
import { checkDocumentTabCount } from '../fixtures/documents';
|
||||
import { checkDocumentCounts, checkDocumentTabCount, toggleDocumentSenderFilter } from '../fixtures/documents';
|
||||
import { expectTextToBeVisible, expectToastTextToBeVisible, openDropdownMenu } from '../fixtures/generic';
|
||||
|
||||
test('[TEAMS]: check team documents count', async ({ page }) => {
|
||||
@@ -20,23 +20,13 @@ test('[TEAMS]: check team documents count', async ({ page }) => {
|
||||
});
|
||||
|
||||
// Check document counts.
|
||||
await checkDocumentTabCount(page, 'Inbox', 0);
|
||||
await checkDocumentTabCount(page, 'Pending', 2);
|
||||
await checkDocumentTabCount(page, 'Completed', 1);
|
||||
await checkDocumentTabCount(page, 'Draft', 2);
|
||||
await checkDocumentTabCount(page, 'All', 5);
|
||||
await checkDocumentCounts(page, { inbox: 0, pending: 2, completed: 1, draft: 2, all: 5 });
|
||||
|
||||
// Apply filter.
|
||||
await page.locator('button').filter({ hasText: 'Sender: All' }).click();
|
||||
await page.getByRole('option', { name: teamMember2.name ?? '' }).click();
|
||||
await page.waitForURL(/senderIds/);
|
||||
await toggleDocumentSenderFilter(page, teamMember2.name ?? '');
|
||||
|
||||
// Check counts after filtering.
|
||||
await checkDocumentTabCount(page, 'Inbox', 0);
|
||||
await checkDocumentTabCount(page, 'Pending', 2);
|
||||
await checkDocumentTabCount(page, 'Completed', 0);
|
||||
await checkDocumentTabCount(page, 'Draft', 1);
|
||||
await checkDocumentTabCount(page, 'All', 3);
|
||||
await checkDocumentCounts(page, { inbox: 0, pending: 2, completed: 0, draft: 1, all: 3 });
|
||||
|
||||
await apiSignout({ page });
|
||||
}
|
||||
@@ -115,23 +105,13 @@ test('[TEAMS]: check team documents count with internal team email', async ({ pa
|
||||
});
|
||||
|
||||
// Check document counts.
|
||||
await checkDocumentTabCount(page, 'Inbox', 2);
|
||||
await checkDocumentTabCount(page, 'Pending', 3);
|
||||
await checkDocumentTabCount(page, 'Completed', 3);
|
||||
await checkDocumentTabCount(page, 'Draft', 3);
|
||||
await checkDocumentTabCount(page, 'All', 11);
|
||||
await checkDocumentCounts(page, { inbox: 2, pending: 3, completed: 3, draft: 3, all: 11 });
|
||||
|
||||
// Apply filter.
|
||||
await page.locator('button').filter({ hasText: 'Sender: All' }).click();
|
||||
await page.getByRole('option', { name: teamMember2.name ?? '' }).click();
|
||||
await page.waitForURL(/senderIds/);
|
||||
await toggleDocumentSenderFilter(page, teamMember2.name ?? '');
|
||||
|
||||
// Check counts after filtering.
|
||||
await checkDocumentTabCount(page, 'Inbox', 0);
|
||||
await checkDocumentTabCount(page, 'Pending', 2);
|
||||
await checkDocumentTabCount(page, 'Completed', 0);
|
||||
await checkDocumentTabCount(page, 'Draft', 1);
|
||||
await checkDocumentTabCount(page, 'All', 3);
|
||||
await checkDocumentCounts(page, { inbox: 0, pending: 2, completed: 0, draft: 1, all: 3 });
|
||||
|
||||
await apiSignout({ page });
|
||||
}
|
||||
@@ -202,23 +182,13 @@ test('[TEAMS]: check team documents count with external team email', async ({ pa
|
||||
});
|
||||
|
||||
// Check document counts.
|
||||
await checkDocumentTabCount(page, 'Inbox', 3);
|
||||
await checkDocumentTabCount(page, 'Pending', 2);
|
||||
await checkDocumentTabCount(page, 'Completed', 2);
|
||||
await checkDocumentTabCount(page, 'Draft', 2);
|
||||
await checkDocumentTabCount(page, 'All', 9);
|
||||
await checkDocumentCounts(page, { inbox: 3, pending: 2, completed: 2, draft: 2, all: 9 });
|
||||
|
||||
// Apply filter.
|
||||
await page.locator('button').filter({ hasText: 'Sender: All' }).click();
|
||||
await page.getByRole('option', { name: teamMember2.name ?? '' }).click();
|
||||
await page.waitForURL(/senderIds/);
|
||||
await toggleDocumentSenderFilter(page, teamMember2.name ?? '');
|
||||
|
||||
// Check counts after filtering.
|
||||
await checkDocumentTabCount(page, 'Inbox', 0);
|
||||
await checkDocumentTabCount(page, 'Pending', 2);
|
||||
await checkDocumentTabCount(page, 'Completed', 0);
|
||||
await checkDocumentTabCount(page, 'Draft', 1);
|
||||
await checkDocumentTabCount(page, 'All', 3);
|
||||
await checkDocumentCounts(page, { inbox: 0, pending: 2, completed: 0, draft: 1, all: 3 });
|
||||
});
|
||||
|
||||
test('[TEAMS]: resend pending team document', async ({ page }) => {
|
||||
@@ -273,11 +243,7 @@ test('[TEAMS]: delete draft team document', async ({ page }) => {
|
||||
});
|
||||
|
||||
// Check document counts.
|
||||
await checkDocumentTabCount(page, 'Inbox', 0);
|
||||
await checkDocumentTabCount(page, 'Pending', 2);
|
||||
await checkDocumentTabCount(page, 'Completed', 1);
|
||||
await checkDocumentTabCount(page, 'Draft', 1);
|
||||
await checkDocumentTabCount(page, 'All', 4);
|
||||
await checkDocumentCounts(page, { inbox: 0, pending: 2, completed: 1, draft: 1, all: 4 });
|
||||
|
||||
await apiSignout({ page });
|
||||
}
|
||||
@@ -316,11 +282,7 @@ test('[TEAMS]: delete pending team document', async ({ page }) => {
|
||||
});
|
||||
|
||||
// Check document counts.
|
||||
await checkDocumentTabCount(page, 'Inbox', 0);
|
||||
await checkDocumentTabCount(page, 'Pending', 1);
|
||||
await checkDocumentTabCount(page, 'Completed', 1);
|
||||
await checkDocumentTabCount(page, 'Draft', 2);
|
||||
await checkDocumentTabCount(page, 'All', 4);
|
||||
await checkDocumentCounts(page, { inbox: 0, pending: 1, completed: 1, draft: 2, all: 4 });
|
||||
|
||||
await apiSignout({ page });
|
||||
}
|
||||
@@ -359,11 +321,7 @@ test('[TEAMS]: delete completed team document', async ({ page }) => {
|
||||
});
|
||||
|
||||
// Check document counts.
|
||||
await checkDocumentTabCount(page, 'Inbox', 0);
|
||||
await checkDocumentTabCount(page, 'Pending', 2);
|
||||
await checkDocumentTabCount(page, 'Completed', 0);
|
||||
await checkDocumentTabCount(page, 'Draft', 2);
|
||||
await checkDocumentTabCount(page, 'All', 4);
|
||||
await checkDocumentCounts(page, { inbox: 0, pending: 2, completed: 0, draft: 2, all: 4 });
|
||||
|
||||
await apiSignout({ page });
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { seedTeamEmailVerification } from '@documenso/prisma/seed/teams';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
import { expect, test } from '@playwright/test';
|
||||
@@ -29,7 +30,33 @@ test('[TEAMS]: send team email request', async ({ page }) => {
|
||||
});
|
||||
|
||||
test('[TEAMS]: accept team email request', async ({ page }) => {
|
||||
const { user, team } = await seedUser();
|
||||
const { team } = await seedUser();
|
||||
|
||||
const teamEmailVerification = await seedTeamEmailVerification({
|
||||
email: `team-email-verification--${team.url}@test.documenso.com`,
|
||||
teamId: team.id,
|
||||
});
|
||||
|
||||
const getTeamEmail = async () => prisma.teamEmail.findUnique({ where: { teamId: team.id } });
|
||||
|
||||
expect(await getTeamEmail()).toBeNull();
|
||||
|
||||
await page.goto(`${NEXT_PUBLIC_WEBAPP_URL()}/team/verify/email/${teamEmailVerification.token}`);
|
||||
|
||||
// Visiting the page (GET) must not verify the team email. An automated email link
|
||||
// scanner or prefetcher must not be able to complete the verification.
|
||||
await expect(page.getByRole('heading', { name: 'Verify team email' })).toBeVisible();
|
||||
expect(await getTeamEmail()).toBeNull();
|
||||
|
||||
await page.getByRole('button', { name: 'Verify email' }).click();
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Team email verified!' })).toBeVisible();
|
||||
|
||||
expect(await getTeamEmail()).not.toBeNull();
|
||||
});
|
||||
|
||||
test('[TEAMS]: team email verification link is invalid once completed', async ({ page }) => {
|
||||
const { team } = await seedUser();
|
||||
|
||||
const teamEmailVerification = await seedTeamEmailVerification({
|
||||
email: `team-email-verification--${team.url}@test.documenso.com`,
|
||||
@@ -37,7 +64,11 @@ test('[TEAMS]: accept team email request', async ({ page }) => {
|
||||
});
|
||||
|
||||
await page.goto(`${NEXT_PUBLIC_WEBAPP_URL()}/team/verify/email/${teamEmailVerification.token}`);
|
||||
await expect(page.getByRole('heading')).toContainText('Team email verified!');
|
||||
await page.getByRole('button', { name: 'Verify email' }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Team email verified!' })).toBeVisible();
|
||||
|
||||
await page.goto(`${NEXT_PUBLIC_WEBAPP_URL()}/team/verify/email/${teamEmailVerification.token}`);
|
||||
await expect(page.getByRole('heading', { name: 'Team email already verified!' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('[TEAMS]: delete team email', async ({ page }) => {
|
||||
|
||||
@@ -51,6 +51,10 @@ test('[ORGANISATIONS]: settings save bar floats when the form footer is off-scre
|
||||
isPersonalOrganisation: false,
|
||||
});
|
||||
|
||||
// Short (but still md+) viewport so the document preferences form overflows
|
||||
// the internally-scrolling settings content pane.
|
||||
await page.setViewportSize({ width: 1280, height: 720 });
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
@@ -71,8 +75,10 @@ test('[ORGANISATIONS]: settings save bar floats when the form footer is off-scre
|
||||
await expect(page.getByRole('button', { name: 'Save changes' })).toBeVisible();
|
||||
|
||||
// Scroll to the footer → the floating pill merges into the docked buttons and the
|
||||
// notice disappears.
|
||||
await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));
|
||||
// notice disappears. The settings layout scrolls its content pane internally,
|
||||
// so scroll that pane rather than the window.
|
||||
const contentPane = page.getByTestId('unified-settings-content').locator('..');
|
||||
await contentPane.evaluate((el) => el.scrollTo(0, el.scrollHeight));
|
||||
|
||||
await expect(page.getByText('You have unsaved changes')).not.toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'Save changes' })).toBeVisible();
|
||||
|
||||
@@ -49,10 +49,10 @@ test('[BULK_ACTIONS]: can select multiple templates with checkboxes', async ({ p
|
||||
});
|
||||
|
||||
await page.locator('tr', { hasText: 'Bulk Test Template 1' }).getByRole('checkbox').click();
|
||||
await expect(page.getByText('1 selected')).toBeVisible();
|
||||
await expect(page.getByText(/1\s*selected/)).toBeVisible();
|
||||
|
||||
await page.locator('tr', { hasText: 'Bulk Test Template 2' }).getByRole('checkbox').click();
|
||||
await expect(page.getByText('2 selected')).toBeVisible();
|
||||
await expect(page.getByText(/2\s*selected/)).toBeVisible();
|
||||
});
|
||||
|
||||
test('[BULK_ACTIONS]: header checkbox selects all templates on page', async ({ page }) => {
|
||||
@@ -66,7 +66,7 @@ test('[BULK_ACTIONS]: header checkbox selects all templates on page', async ({ p
|
||||
|
||||
await page.locator('thead').getByRole('checkbox').click();
|
||||
|
||||
await expect(page.getByText(`${templates.length} selected`)).toBeVisible();
|
||||
await expect(page.getByText(new RegExp(`${templates.length}\\s*selected`))).toBeVisible();
|
||||
});
|
||||
|
||||
test('[BULK_ACTIONS]: can clear selection with X button', async ({ page }) => {
|
||||
@@ -79,11 +79,11 @@ test('[BULK_ACTIONS]: can clear selection with X button', async ({ page }) => {
|
||||
});
|
||||
|
||||
await page.locator('thead').getByRole('checkbox').click();
|
||||
await expect(page.getByText(/\d+ selected/)).toBeVisible();
|
||||
await expect(page.getByText(/\d+\s*selected/)).toBeVisible();
|
||||
|
||||
await page.getByLabel('Clear selection').click();
|
||||
|
||||
await expect(page.getByText(/\d+ selected/)).not.toBeVisible();
|
||||
await expect(page.getByText(/\d+\s*selected/)).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('[BULK_ACTIONS]: can move multiple templates to a folder', async ({ page }) => {
|
||||
@@ -97,13 +97,13 @@ test('[BULK_ACTIONS]: can move multiple templates to a folder', async ({ page })
|
||||
|
||||
await page.locator('tr', { hasText: 'Bulk Test Template 1' }).getByRole('checkbox').click();
|
||||
await page.locator('tr', { hasText: 'Bulk Test Template 2' }).getByRole('checkbox').click();
|
||||
await page.getByRole('button', { name: 'Move to Folder' }).click();
|
||||
await page.getByRole('button', { name: 'Move', exact: true }).click();
|
||||
|
||||
await expect(page.getByRole('dialog')).toBeVisible();
|
||||
await expect(page.getByText('Move Templates to Folder')).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: folder.name }).click();
|
||||
await page.getByRole('button', { name: 'Move' }).click();
|
||||
await page.getByRole('dialog').getByRole('button', { name: 'Move' }).click();
|
||||
|
||||
await expectToastTextToBeVisible(page, 'Selected items have been moved.');
|
||||
|
||||
@@ -151,14 +151,14 @@ test('[BULK_ACTIONS]: selection clears after successful move', async ({ page })
|
||||
});
|
||||
|
||||
await page.locator('tr', { hasText: 'Bulk Test Template 1' }).getByRole('checkbox').click();
|
||||
await expect(page.getByText('1 selected')).toBeVisible();
|
||||
await expect(page.getByText(/1\s*selected/)).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Move to Folder' }).click();
|
||||
await page.getByRole('button', { name: 'Move', exact: true }).click();
|
||||
await page.getByRole('button', { name: folder.name }).click();
|
||||
await page.getByRole('button', { name: 'Move' }).click();
|
||||
await page.getByRole('dialog').getByRole('button', { name: 'Move' }).click();
|
||||
|
||||
await expectToastTextToBeVisible(page, 'Selected items have been moved.');
|
||||
await expect(page.getByText(/\d+ selected/)).not.toBeVisible();
|
||||
await expect(page.getByText(/\d+\s*selected/)).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('[BULK_ACTIONS]: selection clears after successful delete', async ({ page }) => {
|
||||
@@ -171,13 +171,13 @@ test('[BULK_ACTIONS]: selection clears after successful delete', async ({ page }
|
||||
});
|
||||
|
||||
await page.locator('tr', { hasText: 'Bulk Test Template 1' }).getByRole('checkbox').click();
|
||||
await expect(page.getByText('1 selected')).toBeVisible();
|
||||
await expect(page.getByText(/1\s*selected/)).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Delete' }).click();
|
||||
await page.getByRole('dialog').getByRole('button', { name: 'Delete' }).click();
|
||||
|
||||
await expectToastTextToBeVisible(page, 'Templates deleted');
|
||||
await expect(page.getByText(/\d+ selected/)).not.toBeVisible();
|
||||
await expect(page.getByText(/\d+\s*selected/)).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('[BULK_ACTIONS]: can search for folders in move dialog', async ({ page }) => {
|
||||
@@ -199,7 +199,7 @@ test('[BULK_ACTIONS]: can search for folders in move dialog', async ({ page }) =
|
||||
|
||||
await page.locator('tr', { hasText: 'Bulk Test Template 1' }).getByRole('checkbox').click();
|
||||
|
||||
await page.getByRole('button', { name: 'Move to Folder' }).click();
|
||||
await page.getByRole('button', { name: 'Move', exact: true }).click();
|
||||
await expect(page.getByRole('dialog')).toBeVisible();
|
||||
|
||||
await expect(page.getByRole('button', { name: folder.name })).toBeVisible();
|
||||
@@ -236,14 +236,14 @@ test('[BULK_ACTIONS]: can move templates from folder to home (root)', async ({ p
|
||||
await expect(page.getByRole('link', { name: 'Bulk Test Template 1' })).toBeVisible();
|
||||
|
||||
await page.locator('tr', { hasText: 'Bulk Test Template 1' }).getByRole('checkbox').click();
|
||||
await expect(page.getByText('1 selected')).toBeVisible();
|
||||
await expect(page.getByText(/1\s*selected/)).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Move to Folder' }).click();
|
||||
await page.getByRole('button', { name: 'Move', exact: true }).click();
|
||||
await expect(page.getByRole('dialog')).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Home (No Folder)' }).click();
|
||||
|
||||
await page.getByRole('button', { name: 'Move' }).click();
|
||||
await page.getByRole('dialog').getByRole('button', { name: 'Move' }).click();
|
||||
|
||||
await expectToastTextToBeVisible(page, 'Selected items have been moved.');
|
||||
|
||||
|
||||
@@ -197,7 +197,18 @@ test('[DIRECT_TEMPLATES]: V1 direct template link auth access', async ({ page })
|
||||
await expect(page.getByRole('heading', { name: 'General' })).toBeVisible();
|
||||
await expect(page.getByLabel('Email')).toBeDisabled();
|
||||
|
||||
const directSignatureField = directTemplateWithAuth.fields[0];
|
||||
|
||||
if (!directSignatureField) {
|
||||
throw new Error('Expected seeded direct template signature field to exist');
|
||||
}
|
||||
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
|
||||
await signSignaturePad(page);
|
||||
await page.locator(`#field-${directSignatureField.id}`).getByRole('button').click();
|
||||
await expect(page.locator(`#field-${directSignatureField.id}`)).toHaveAttribute('data-inserted', 'true');
|
||||
|
||||
await page.getByRole('button', { name: 'Complete' }).click();
|
||||
|
||||
await page.getByRole('button', { name: 'Sign' }).click();
|
||||
@@ -235,6 +246,37 @@ test('[DIRECT_TEMPLATES]: V2 direct template link auth access', async ({ page })
|
||||
await page.goto(directTemplatePath);
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Personal direct template link' })).toBeVisible();
|
||||
|
||||
const directSignatureField = directTemplateWithAuth.fields[0];
|
||||
|
||||
if (!directSignatureField) {
|
||||
throw new Error('Expected seeded direct template signature field to exist');
|
||||
}
|
||||
|
||||
// Wait for the PDF and the Konva canvas overlay to be ready.
|
||||
await expect(page.locator('img[data-page-number]').first()).toBeVisible({ timeout: 30_000 });
|
||||
const canvas = page.locator('.konva-container canvas').first();
|
||||
await expect(canvas).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
// Sign the direct template recipient's signature field via the canvas-based V2 UI.
|
||||
await signSignaturePad(page);
|
||||
|
||||
const canvasBox = await canvas.boundingBox();
|
||||
|
||||
if (!canvasBox) {
|
||||
throw new Error('Canvas bounding box not found');
|
||||
}
|
||||
|
||||
const x =
|
||||
(Number(directSignatureField.positionX) / 100) * canvasBox.width +
|
||||
((Number(directSignatureField.width) / 100) * canvasBox.width) / 2;
|
||||
const y =
|
||||
(Number(directSignatureField.positionY) / 100) * canvasBox.height +
|
||||
((Number(directSignatureField.height) / 100) * canvasBox.height) / 2;
|
||||
|
||||
await canvas.click({ position: { x, y } });
|
||||
await expect(page.getByText('0 Fields Remaining').first()).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
await page.getByRole('button', { name: 'Complete' }).click();
|
||||
await expect(page.getByLabel('Your Email')).not.toBeVisible();
|
||||
|
||||
@@ -266,6 +308,16 @@ test('[DIRECT_TEMPLATES]: use direct template link with 1 recipient', async ({ p
|
||||
|
||||
await expect(page.getByText('Next Recipient Name')).not.toBeVisible();
|
||||
|
||||
const directSignatureField = template.fields[0];
|
||||
|
||||
if (!directSignatureField) {
|
||||
throw new Error('Expected seeded direct template signature field to exist');
|
||||
}
|
||||
|
||||
await signSignaturePad(page);
|
||||
await page.locator(`#field-${directSignatureField.id}`).getByRole('button').click();
|
||||
await expect(page.locator(`#field-${directSignatureField.id}`)).toHaveAttribute('data-inserted', 'true');
|
||||
|
||||
await page.getByRole('button', { name: 'Complete' }).click();
|
||||
await page.getByRole('button', { name: 'Sign' }).click();
|
||||
await page.waitForURL(/\/sign/);
|
||||
@@ -299,19 +351,13 @@ test('[DIRECT_TEMPLATES]: V1 use direct template link with 2 recipients with nex
|
||||
},
|
||||
});
|
||||
|
||||
const directTemplateRecipient = template.recipients[0];
|
||||
// The seeded direct template already includes a signature field for the direct recipient.
|
||||
const directSignatureField = template.fields[0];
|
||||
|
||||
if (!directTemplateRecipient) {
|
||||
throw new Error('Expected direct template recipient to exist');
|
||||
if (!directSignatureField) {
|
||||
throw new Error('Expected seeded direct template signature field to exist');
|
||||
}
|
||||
|
||||
// All SIGNER recipients need a signature field for sendDocument to dispatch emails.
|
||||
const directSignatureField = await seedSignatureFieldForRecipient({
|
||||
envelopeId: template.id,
|
||||
recipientId: directTemplateRecipient.id,
|
||||
positionY: 10,
|
||||
});
|
||||
|
||||
const originalName = 'Signer 2';
|
||||
const originalSecondSignerEmail = seedTestEmail();
|
||||
|
||||
@@ -413,19 +459,13 @@ test('[DIRECT_TEMPLATES]: V2 use direct template link with 2 recipients with nex
|
||||
},
|
||||
});
|
||||
|
||||
const directTemplateRecipient = template.recipients[0];
|
||||
// The seeded direct template already includes a signature field for the direct recipient.
|
||||
const directSignatureField = template.fields[0];
|
||||
|
||||
if (!directTemplateRecipient) {
|
||||
throw new Error('Expected direct template recipient to exist');
|
||||
if (!directSignatureField) {
|
||||
throw new Error('Expected seeded direct template signature field to exist');
|
||||
}
|
||||
|
||||
// All SIGNER recipients need a signature field for sendDocument to dispatch emails.
|
||||
const directSignatureField = await seedSignatureFieldForRecipient({
|
||||
envelopeId: template.id,
|
||||
recipientId: directTemplateRecipient.id,
|
||||
positionY: 10,
|
||||
});
|
||||
|
||||
const originalName = 'Signer 2';
|
||||
const originalSecondSignerEmail = seedTestEmail();
|
||||
|
||||
@@ -521,3 +561,48 @@ test('[DIRECT_TEMPLATES]: V2 use direct template link with 2 recipients with nex
|
||||
expect(updatedSecondRecipient.email).toBe(newSecondSignerEmail);
|
||||
await expectSigningRequestJobForRecipient(updatedSecondRecipient.id);
|
||||
});
|
||||
|
||||
test('[DIRECT_TEMPLATES]: V1 direct template without signature fields shows invalid template page', async ({
|
||||
page,
|
||||
}) => {
|
||||
const { user, team } = await seedUser();
|
||||
|
||||
const template = await seedDirectTemplate({
|
||||
title: 'V1 invalid direct template',
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
createDirectRecipientSignatureField: false,
|
||||
});
|
||||
|
||||
await page.goto(formatDirectTemplatePath(template.directLink?.token || ''));
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Invalid direct link template' })).toBeVisible();
|
||||
await expect(page.getByText('This direct link template cannot be used because one or more signers')).toBeVisible();
|
||||
|
||||
// The signing flow must not render.
|
||||
await expect(page.getByRole('heading', { name: 'General' })).not.toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'Continue' })).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('[DIRECT_TEMPLATES]: V2 direct template without signature fields shows invalid template page', async ({
|
||||
page,
|
||||
}) => {
|
||||
const { user, team } = await seedUser();
|
||||
|
||||
const template = await seedDirectTemplate({
|
||||
title: 'V2 invalid direct template',
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
internalVersion: 2,
|
||||
createDirectRecipientSignatureField: false,
|
||||
});
|
||||
|
||||
await page.goto(formatDirectTemplatePath(template.directLink?.token || ''));
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Invalid direct link template' })).toBeVisible();
|
||||
await expect(page.getByText('This direct link template cannot be used because one or more signers')).toBeVisible();
|
||||
|
||||
// The signing flow (PDF canvas) must not render.
|
||||
await expect(page.locator('.konva-container canvas')).toHaveCount(0);
|
||||
await expect(page.getByRole('button', { name: 'Complete' })).not.toBeVisible();
|
||||
});
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import { FIELD_SIGNATURE_META_DEFAULT_VALUES } from '@documenso/lib/types/field-meta';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { seedTemplate } from '@documenso/prisma/seed/templates';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { DocumentStatus, FieldType } from '@prisma/client';
|
||||
|
||||
import { apiSignin } from '../fixtures/authentication';
|
||||
import { expectToastTextToBeVisible } from '../fixtures/generic';
|
||||
|
||||
const seedSignatureFieldForRecipient = async (options: { envelopeId: string; recipientId: number }) => {
|
||||
const envelopeItem = await prisma.envelopeItem.findFirstOrThrow({
|
||||
where: { envelopeId: options.envelopeId },
|
||||
});
|
||||
|
||||
return await prisma.field.create({
|
||||
data: {
|
||||
envelopeId: options.envelopeId,
|
||||
envelopeItemId: envelopeItem.id,
|
||||
recipientId: options.recipientId,
|
||||
type: FieldType.SIGNATURE,
|
||||
page: 1,
|
||||
positionX: 5,
|
||||
positionY: 10,
|
||||
width: 20,
|
||||
height: 5,
|
||||
customText: '',
|
||||
inserted: false,
|
||||
fieldMeta: FIELD_SIGNATURE_META_DEFAULT_VALUES,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
test('[TEMPLATE_USE]: shows missing signature fields error when sending a template without signature fields', async ({
|
||||
page,
|
||||
}) => {
|
||||
const { user, team } = await seedUser();
|
||||
|
||||
// seedTemplate creates one SIGNER recipient and no fields.
|
||||
await seedTemplate({
|
||||
title: 'Template missing signature fields',
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
});
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
redirectPath: `/t/${team.url}/templates`,
|
||||
});
|
||||
|
||||
await page.getByRole('button', { name: 'Use Template' }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Create document from template' })).toBeVisible();
|
||||
|
||||
// Enable distribution so the document is sent on creation.
|
||||
await page.locator('#distributeDocument').click();
|
||||
await page.getByRole('button', { name: 'Create and send' }).click();
|
||||
|
||||
await expectToastTextToBeVisible(page, 'Missing signature fields');
|
||||
await expectToastTextToBeVisible(
|
||||
page,
|
||||
'The document could not be sent because some signers do not have a signature field',
|
||||
);
|
||||
});
|
||||
|
||||
test('[TEMPLATE_USE]: creates and sends a document when signers have signature fields', async ({ page }) => {
|
||||
const { user, team } = await seedUser();
|
||||
|
||||
const template = await seedTemplate({
|
||||
title: 'Template with signature fields',
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
});
|
||||
|
||||
await seedSignatureFieldForRecipient({
|
||||
envelopeId: template.id,
|
||||
recipientId: template.recipients[0].id,
|
||||
});
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
redirectPath: `/t/${team.url}/templates`,
|
||||
});
|
||||
|
||||
await page.getByRole('button', { name: 'Use Template' }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Create document from template' })).toBeVisible();
|
||||
|
||||
await page.locator('#distributeDocument').click();
|
||||
await page.getByRole('button', { name: 'Create and send' }).click();
|
||||
|
||||
await page.waitForURL(new RegExp(`/t/${team.url}/documents/envelope_.*`));
|
||||
|
||||
const envelopeId = page.url().split('/').pop()?.split('?')[0];
|
||||
|
||||
const envelope = await prisma.envelope.findFirstOrThrow({
|
||||
where: { id: envelopeId },
|
||||
});
|
||||
|
||||
expect(envelope.status).toBe(DocumentStatus.PENDING);
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { seedTeam, seedTeamMember } from '@documenso/prisma/seed/teams';
|
||||
import type { Page } from '@playwright/test';
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { TeamMemberRole, WebhookTriggerEvents } from '@prisma/client';
|
||||
|
||||
import { apiSignin } from '../fixtures/authentication';
|
||||
|
||||
/**
|
||||
* Calls the procedure the way an attacker would — directly, from the authenticated browser
|
||||
* context, bypassing the UI entirely. The settings page is gated on MANAGE_TEAM, so going
|
||||
* through the UI would only prove the page is hidden, not that the data is protected.
|
||||
*/
|
||||
const callGetTeamWebhooks = async (page: Page, teamId: number) =>
|
||||
await page.evaluate(async (id) => {
|
||||
const response = await fetch('/api/trpc/webhook.getTeamWebhooks', {
|
||||
method: 'GET',
|
||||
headers: { 'content-type': 'application/json', 'x-team-id': String(id) },
|
||||
});
|
||||
|
||||
return { status: response.status, body: await response.text() };
|
||||
}, teamId);
|
||||
|
||||
test.describe('Webhook secret access', () => {
|
||||
test('team managers can read webhook secrets', async ({ page }) => {
|
||||
const { owner, team } = await seedTeam();
|
||||
|
||||
await prisma.webhook.create({
|
||||
data: {
|
||||
webhookUrl: 'https://example.com/hook',
|
||||
eventTriggers: [WebhookTriggerEvents.DOCUMENT_SENT],
|
||||
secret: 'super-secret-signing-key',
|
||||
enabled: true,
|
||||
userId: owner.id,
|
||||
teamId: team.id,
|
||||
},
|
||||
});
|
||||
|
||||
await apiSignin({ page, email: owner.email });
|
||||
await page.goto(`/t/${team.url}/settings/webhooks`);
|
||||
|
||||
const { status, body } = await callGetTeamWebhooks(page, team.id);
|
||||
|
||||
expect(status).toBe(200);
|
||||
// The edit dialog reads the secret straight off these rows, so managers must get it.
|
||||
expect(body).toContain('super-secret-signing-key');
|
||||
});
|
||||
|
||||
test('team members without manage permission cannot read webhook secrets', async ({ page }) => {
|
||||
const { owner, team } = await seedTeam();
|
||||
|
||||
await prisma.webhook.create({
|
||||
data: {
|
||||
webhookUrl: 'https://example.com/hook',
|
||||
eventTriggers: [WebhookTriggerEvents.DOCUMENT_SENT],
|
||||
secret: 'super-secret-signing-key',
|
||||
enabled: true,
|
||||
userId: owner.id,
|
||||
teamId: team.id,
|
||||
},
|
||||
});
|
||||
|
||||
const member = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.MEMBER });
|
||||
|
||||
await apiSignin({ page, email: member.email });
|
||||
await page.goto(`/t/${team.url}/documents`);
|
||||
|
||||
const { status, body } = await callGetTeamWebhooks(page, team.id);
|
||||
|
||||
// Whatever the failure mode, the signing key must never appear in the response.
|
||||
expect(body).not.toContain('super-secret-signing-key');
|
||||
expect(status).not.toBe(200);
|
||||
});
|
||||
});
|
||||
@@ -18,9 +18,9 @@
|
||||
"@playwright/test": "1.56.1",
|
||||
"@types/node": "^20",
|
||||
"@types/pngjs": "^6.0.5",
|
||||
"tsx": "^4.20.6",
|
||||
"pixelmatch": "^7.1.0",
|
||||
"pngjs": "^7.0.0"
|
||||
"pngjs": "^7.0.0",
|
||||
"tsx": "^4.23.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"start-server-and-test": "^2.1.3"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import { formatPath, NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import { AppError } from '@documenso/lib/errors/app-error';
|
||||
import type { ClientResponse, InferRequestType } from 'hono/client';
|
||||
import { hc } from 'hono/client';
|
||||
@@ -36,8 +36,6 @@ type TPasskeySignin = InferRequestType<AuthClientType['passkey']['authorize']['$
|
||||
export class AuthClient {
|
||||
public client: AuthClientType;
|
||||
|
||||
private signOutredirectPath: string = '/signin';
|
||||
|
||||
constructor(options: { baseUrl: string }) {
|
||||
this.client = hc<AuthAppType>(options.baseUrl);
|
||||
}
|
||||
@@ -45,7 +43,7 @@ export class AuthClient {
|
||||
public async signOut({ redirectPath }: { redirectPath?: string } = {}) {
|
||||
await this.client.signout.$post();
|
||||
|
||||
window.location.href = redirectPath ?? this.signOutredirectPath;
|
||||
window.location.href = redirectPath ?? formatPath('/signin');
|
||||
}
|
||||
|
||||
public async signOutAllSessions() {
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
/**
|
||||
* Todo: Use library for cookies instead.
|
||||
*
|
||||
* Server-side counterpart of `extractCookieFromDocument`, keep their matching
|
||||
* semantics in step.
|
||||
*/
|
||||
export const extractCookieFromHeaders = (cookieName: string, headers: Headers): string | null => {
|
||||
const cookieHeader = headers.get('cookie') || '';
|
||||
const cookiePairs = cookieHeader.split(';');
|
||||
const cookie = cookiePairs.find((pair) => pair.trim().startsWith(cookieName));
|
||||
const cookie = cookiePairs.find((pair) => pair.trim().startsWith(`${cookieName}=`));
|
||||
|
||||
if (!cookie) {
|
||||
return null;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import { formatPath, NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import {
|
||||
isDisposableEmail,
|
||||
isEmailDomainAllowedForSignup,
|
||||
@@ -121,7 +121,7 @@ export const handleOAuthCallbackUrl = async (options: HandleOAuthCallbackUrlOpti
|
||||
|
||||
// Check if signups are disabled for this provider.
|
||||
if (!isSignupEnabledForProvider(clientOptions.id as 'google' | 'microsoft' | 'oidc')) {
|
||||
const errorUrl = new URL('/signin', NEXT_PUBLIC_WEBAPP_URL());
|
||||
const errorUrl = new URL(formatPath('/signin'), NEXT_PUBLIC_WEBAPP_URL());
|
||||
|
||||
errorUrl.searchParams.set('error', AuthenticationErrorCode.SignupDisabled);
|
||||
|
||||
@@ -130,7 +130,7 @@ export const handleOAuthCallbackUrl = async (options: HandleOAuthCallbackUrlOpti
|
||||
|
||||
// Check domain restriction for new SSO users.
|
||||
if (!isEmailDomainAllowedForSignup(email)) {
|
||||
const errorUrl = new URL('/signin', NEXT_PUBLIC_WEBAPP_URL());
|
||||
const errorUrl = new URL(formatPath('/signin'), NEXT_PUBLIC_WEBAPP_URL());
|
||||
|
||||
errorUrl.searchParams.set('error', AuthenticationErrorCode.SignupDisabled);
|
||||
|
||||
@@ -141,7 +141,7 @@ export const handleOAuthCallbackUrl = async (options: HandleOAuthCallbackUrlOpti
|
||||
const additionalBlockedDomains = await getEmailBlocklistDomains();
|
||||
|
||||
if (isDisposableEmail(email, additionalBlockedDomains)) {
|
||||
const errorUrl = new URL('/signin', NEXT_PUBLIC_WEBAPP_URL());
|
||||
const errorUrl = new URL(formatPath('/signin'), NEXT_PUBLIC_WEBAPP_URL());
|
||||
|
||||
errorUrl.searchParams.set('error', AuthenticationErrorCode.SignupDisposableEmail);
|
||||
|
||||
@@ -213,15 +213,18 @@ export const validateOauth = async (options: HandleOAuthCallbackUrlOptions) => {
|
||||
// eslint-disable-next-line prefer-const
|
||||
let [redirectState, redirectPath] = storedRedirectPath.split(' ');
|
||||
|
||||
// The sub-path aware root, e.g. "/" or "/ESign/".
|
||||
const defaultRedirectPath = formatPath('/');
|
||||
|
||||
if (redirectState !== storedState || !redirectPath) {
|
||||
redirectPath = '/';
|
||||
redirectPath = defaultRedirectPath;
|
||||
}
|
||||
|
||||
if (!isValidReturnTo(redirectPath)) {
|
||||
redirectPath = '/';
|
||||
redirectPath = defaultRedirectPath;
|
||||
}
|
||||
|
||||
redirectPath = normalizeReturnTo(redirectPath) || '/';
|
||||
redirectPath = normalizeReturnTo(redirectPath) || defaultRedirectPath;
|
||||
|
||||
const tokens = await oAuthClient.validateAuthorizationCode(token_endpoint, code, storedCodeVerifier);
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { sendOrganisationAccountLinkConfirmationEmail } from '@documenso/ee/server-only/lib/send-organisation-account-link-confirmation-email';
|
||||
import { formatPath } from '@documenso/lib/constants/app';
|
||||
import { isDisposableEmail, isSignupEnabledForProvider } from '@documenso/lib/constants/auth';
|
||||
import { AppError } from '@documenso/lib/errors/app-error';
|
||||
import { getEmailBlocklistDomains } from '@documenso/lib/server-only/site-settings/get-email-blocklist-domains';
|
||||
@@ -56,7 +57,7 @@ export const handleOAuthOrganisationCallbackUrl = async (options: HandleOAuthOrg
|
||||
if (existingAccount) {
|
||||
await onAuthorize({ userId: existingAccount.user.id }, c);
|
||||
|
||||
return c.redirect(`/o/${orgUrl}`, 302);
|
||||
return c.redirect(formatPath(`/o/${orgUrl}`), 302);
|
||||
}
|
||||
|
||||
let userToLink = await prisma.user.findFirst({
|
||||
|
||||
@@ -1,5 +1,25 @@
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
|
||||
/**
|
||||
* Derive the default redirect target ("/" at the root, "/ESign/" when served under a sub-path).
|
||||
*/
|
||||
const getDefaultRedirect = () => {
|
||||
try {
|
||||
const pathname = new URL(NEXT_PUBLIC_WEBAPP_URL()).pathname.replace(/\/$/, '');
|
||||
return `${pathname}/`;
|
||||
} catch {
|
||||
return '/';
|
||||
}
|
||||
};
|
||||
|
||||
const getWebAppOrigin = () => {
|
||||
try {
|
||||
return new URL(NEXT_PUBLIC_WEBAPP_URL()).origin;
|
||||
} catch {
|
||||
return NEXT_PUBLIC_WEBAPP_URL();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle an optional redirect path.
|
||||
*/
|
||||
@@ -10,19 +30,20 @@ export const handleRequestRedirect = (redirectUrl?: string) => {
|
||||
|
||||
const url = new URL(redirectUrl, NEXT_PUBLIC_WEBAPP_URL());
|
||||
|
||||
if (url.origin !== NEXT_PUBLIC_WEBAPP_URL()) {
|
||||
window.location.href = '/';
|
||||
if (url.origin !== getWebAppOrigin()) {
|
||||
window.location.href = getDefaultRedirect();
|
||||
} else {
|
||||
window.location.href = redirectUrl;
|
||||
}
|
||||
};
|
||||
|
||||
export const handleSignInRedirect = (redirectUrl: string = '/') => {
|
||||
const url = new URL(redirectUrl, NEXT_PUBLIC_WEBAPP_URL());
|
||||
export const handleSignInRedirect = (redirectUrl?: string) => {
|
||||
const target = redirectUrl ?? getDefaultRedirect();
|
||||
const url = new URL(target, NEXT_PUBLIC_WEBAPP_URL());
|
||||
|
||||
if (url.origin !== NEXT_PUBLIC_WEBAPP_URL()) {
|
||||
window.location.href = '/';
|
||||
if (url.origin !== getWebAppOrigin()) {
|
||||
window.location.href = getDefaultRedirect();
|
||||
} else {
|
||||
window.location.href = redirectUrl;
|
||||
window.location.href = target;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
"arctic": "^3.7.0",
|
||||
"hono": "^4.12.14",
|
||||
"luxon": "^3.7.2",
|
||||
"react": "^18",
|
||||
"react": "^19.2.7",
|
||||
"ts-pattern": "^5.9.0",
|
||||
"zod": "^3.25.76"
|
||||
}
|
||||
|
||||
@@ -12,7 +12,10 @@ export type GetLimitsOptions = {
|
||||
export const getLimits = async ({ headers, teamId }: GetLimitsOptions) => {
|
||||
const requestHeaders = headers ?? {};
|
||||
|
||||
const url = new URL('/api/limits', NEXT_PUBLIC_WEBAPP_URL());
|
||||
// Note: the path must be appended rather than passed as the `new URL()` path
|
||||
// argument, since a leading-slash path replaces the sub-path that
|
||||
// NEXT_PUBLIC_WEBAPP_URL may carry (e.g. https://host/ESign).
|
||||
const url = new URL(`${NEXT_PUBLIC_WEBAPP_URL()}/api/limits`);
|
||||
|
||||
if (teamId) {
|
||||
requestHeaders['team-id'] = teamId.toString();
|
||||
|
||||
@@ -5,6 +5,7 @@ import { INTERNAL_CLAIM_ID } from '@documenso/lib/types/subscription';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { OrganisationType, type Prisma, SubscriptionStatus } from '@prisma/client';
|
||||
import { match } from 'ts-pattern';
|
||||
import { reconcileSeatBasedPlans } from './update-subscription-item-quantity';
|
||||
|
||||
const LIVE_SUBSCRIPTION_STATUSES: Stripe.Subscription.Status[] = ['active', 'trialing', 'past_due'];
|
||||
|
||||
@@ -229,6 +230,19 @@ const handleLiveSubscription = async ({
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Detect a billing-period roll by comparing the persisted period end with
|
||||
// the freshly-fetched one — the convergent equivalent of the old
|
||||
// `previous_attributes.current_period_start` signal. On renewal, reconcile
|
||||
// the seat quantity and claim down to the actual member count. The reconcile
|
||||
// itself no-ops for non-seat/unlimited plans and inactive subscriptions.
|
||||
const previousPeriodEnd = organisation.subscription?.periodEnd ?? null;
|
||||
|
||||
const hasPeriodAdvanced = previousPeriodEnd !== null && periodEnd.getTime() > previousPeriodEnd.getTime();
|
||||
|
||||
if (hasPeriodAdvanced && !bypassClaimUpdate) {
|
||||
await reconcileSeatBasedPlans(organisation.id);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -3,6 +3,7 @@ import { stripe } from '@documenso/lib/server-only/stripe';
|
||||
import { appLog } from '@documenso/lib/utils/debugger';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import type { OrganisationClaim, Subscription } from '@prisma/client';
|
||||
import { SubscriptionStatus } from '@prisma/client';
|
||||
import type Stripe from 'stripe';
|
||||
|
||||
import { isPriceSeatsBased } from './is-price-seats-based';
|
||||
@@ -11,12 +12,14 @@ export type UpdateSubscriptionItemQuantityOptions = {
|
||||
subscriptionId: string;
|
||||
quantity: number;
|
||||
priceId: string;
|
||||
prorationBehaviour: 'always_invoice' | 'none';
|
||||
};
|
||||
|
||||
export const updateSubscriptionItemQuantity = async ({
|
||||
subscriptionId,
|
||||
quantity,
|
||||
priceId,
|
||||
prorationBehaviour,
|
||||
}: UpdateSubscriptionItemQuantityOptions) => {
|
||||
const subscription = await stripe.subscriptions.retrieve(subscriptionId);
|
||||
|
||||
@@ -26,7 +29,6 @@ export const updateSubscriptionItemQuantity = async ({
|
||||
throw new Error('Subscription does not contain required item');
|
||||
}
|
||||
|
||||
const hasYearlyItem = items.find((item) => item.price.recurring?.interval === 'year');
|
||||
const oldQuantity = items[0].quantity;
|
||||
|
||||
if (oldQuantity === quantity) {
|
||||
@@ -38,13 +40,12 @@ export const updateSubscriptionItemQuantity = async ({
|
||||
id: item.id,
|
||||
quantity,
|
||||
})),
|
||||
proration_behavior: prorationBehaviour,
|
||||
// Need to "off_session" updates since adding 3DS will have payments
|
||||
// not pass through for these immediate invoices.
|
||||
off_session: true,
|
||||
};
|
||||
|
||||
// Only invoice immediately when changing the quantity of yearly item.
|
||||
if (hasYearlyItem) {
|
||||
subscriptionUpdatePayload.proration_behavior = 'always_invoice';
|
||||
}
|
||||
|
||||
await stripe.subscriptions.update(subscriptionId, subscriptionUpdatePayload);
|
||||
};
|
||||
|
||||
@@ -55,15 +56,19 @@ export const updateSubscriptionItemQuantity = async ({
|
||||
* via Stripe rather than enforcing a hard cap. A `memberCount` of `0` on the
|
||||
* organisation claim represents unlimited seats.
|
||||
*
|
||||
* Organisations without a subscription (e.g. after being downgraded to the
|
||||
* free plan) can pass `null`, in which case the claim cap is enforced
|
||||
* directly without the seats-based exemption.
|
||||
*
|
||||
* Should only be called from grow paths (invite/add). Reducing operations
|
||||
* must never be gated by this check.
|
||||
*
|
||||
* @param subscription - The organisation's Stripe subscription.
|
||||
* @param subscription - The organisation's Stripe subscription, if any.
|
||||
* @param organisationClaim - The organisation claim.
|
||||
* @param quantity - The proposed total member + pending invite count.
|
||||
* @param quantity - The proposed total member count.
|
||||
*/
|
||||
export const assertMemberCountWithinCap = async (
|
||||
subscription: Subscription,
|
||||
subscription: Subscription | null,
|
||||
organisationClaim: OrganisationClaim,
|
||||
quantity: number,
|
||||
) => {
|
||||
@@ -75,10 +80,12 @@ export const assertMemberCountWithinCap = async (
|
||||
}
|
||||
|
||||
// Seats-based plans don't have a hard cap; Stripe meters the usage.
|
||||
const isSeatsBased = await isPriceSeatsBased(subscription.priceId);
|
||||
if (subscription) {
|
||||
const isSeatsBased = await isPriceSeatsBased(subscription.priceId);
|
||||
|
||||
if (isSeatsBased) {
|
||||
return;
|
||||
if (isSeatsBased) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (quantity > maximumMemberCount) {
|
||||
@@ -89,48 +96,134 @@ export const assertMemberCountWithinCap = async (
|
||||
};
|
||||
|
||||
/**
|
||||
* Syncs the organisation's member count with the Stripe subscription quantity.
|
||||
* Syncs the Stripe subscription quantity with the organisation's member count.
|
||||
*
|
||||
* No-ops for plans that are not seats-based, and for organisations with
|
||||
* unlimited seats (`organisationClaim.memberCount === 0`). Safe to call from
|
||||
* both grow and shrink paths.
|
||||
* This is a Stripe <-> Database sync operation.
|
||||
*
|
||||
* Note: `organisationClaim.memberCount` is the paid seat high-water mark for the
|
||||
* current billing period — the highest count we've already billed for.
|
||||
*
|
||||
* @param subscription - The subscription to sync the member count with.
|
||||
* @param organisationClaim - The organisation claim.
|
||||
* @param quantity - The new total member + pending invite count to sync.
|
||||
* @param quantity - The new total member count to sync.
|
||||
* @param mode - The member-count change that triggered the sync.
|
||||
*/
|
||||
export const syncMemberCountWithStripeSeatPlan = async (
|
||||
subscription: Subscription,
|
||||
organisationClaim: OrganisationClaim,
|
||||
quantity: number,
|
||||
mode: 'grow' | 'shrink',
|
||||
) => {
|
||||
// Infinite seats means no sync needed.
|
||||
// Unlimited seats — nothing to meter.
|
||||
if (organisationClaim.memberCount === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isSeatsBased = await isPriceSeatsBased(subscription.priceId);
|
||||
|
||||
// Only seat-based plans support seat syncing.
|
||||
if (!isSeatsBased) {
|
||||
return;
|
||||
}
|
||||
|
||||
appLog('BILLING', 'Updating seat based plan');
|
||||
// Whether to immediately invoice for new seats if the quantity is greater than
|
||||
// the high-water mark.
|
||||
const billsForNewSeats = mode === 'grow' && quantity > organisationClaim.memberCount;
|
||||
|
||||
appLog('BILLING', `Syncing seat based plan (${mode}, quantity ${quantity})`);
|
||||
|
||||
await updateSubscriptionItemQuantity({
|
||||
priceId: subscription.priceId,
|
||||
subscriptionId: subscription.planId,
|
||||
quantity,
|
||||
prorationBehaviour: billsForNewSeats ? 'always_invoice' : 'none',
|
||||
});
|
||||
|
||||
// Advance the high-water mark when billing for new seats; it is reset to the
|
||||
// actual member count when the billing period rolls over. Re-adds and shrinks
|
||||
// deliberately leave it untouched so a seat already paid for this period is
|
||||
// never re-charged.
|
||||
if (billsForNewSeats) {
|
||||
await prisma.organisationClaim.update({
|
||||
where: {
|
||||
id: organisationClaim.id,
|
||||
},
|
||||
data: {
|
||||
memberCount: quantity,
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Reconciles the organisation claim seat counter, and the stripe quantity with the
|
||||
* actual member count.
|
||||
*
|
||||
* Uses the member count as the authoritative source of truth. Meaning:
|
||||
* - Update the organisation claim with the member count
|
||||
* - Update the Stripe subscription quantity to the member count
|
||||
*
|
||||
* This should only be called when the billing period rolls over.
|
||||
*/
|
||||
export const reconcileSeatBasedPlans = async (organisationId: string) => {
|
||||
const organisation = await prisma.organisation.findFirst({
|
||||
where: {
|
||||
id: organisationId,
|
||||
},
|
||||
include: {
|
||||
organisationClaim: true,
|
||||
subscription: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!organisation || !organisation.subscription) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { subscription, organisationClaim } = organisation;
|
||||
|
||||
// Stripe rejects quantity updates on canceled subscriptions. PAST_DUE is
|
||||
// still live and a no-proration sync is safe, so it's allowed through.
|
||||
if (subscription.status === SubscriptionStatus.INACTIVE) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Unlimited seats — nothing to meter.
|
||||
if (organisationClaim.memberCount === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isSeatsBased = await isPriceSeatsBased(subscription.priceId);
|
||||
|
||||
// Only seat-based plans support seat syncing.
|
||||
if (!isSeatsBased) {
|
||||
return;
|
||||
}
|
||||
|
||||
const memberCount = await prisma.organisationMember.count({
|
||||
where: {
|
||||
organisationId,
|
||||
},
|
||||
});
|
||||
|
||||
// An organisation always retains its owner; never write the unlimited sentinel.
|
||||
if (memberCount === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
await updateSubscriptionItemQuantity({
|
||||
priceId: subscription.priceId,
|
||||
subscriptionId: subscription.planId,
|
||||
quantity: memberCount,
|
||||
prorationBehaviour: 'none',
|
||||
});
|
||||
|
||||
// This should be automatically updated after the Stripe webhook is fired
|
||||
// but we just manually adjust it here as well to avoid any race conditions.
|
||||
await prisma.organisationClaim.update({
|
||||
where: {
|
||||
id: organisationClaim.id,
|
||||
},
|
||||
data: {
|
||||
memberCount: quantity,
|
||||
memberCount,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -2,7 +2,6 @@ import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
|
||||
import type { Stripe } from '@documenso/lib/server-only/stripe';
|
||||
import { stripe } from '@documenso/lib/server-only/stripe';
|
||||
import { env } from '@documenso/lib/utils/env';
|
||||
|
||||
import { syncStripeCustomerSubscription } from '../sync-stripe-customer-subscription';
|
||||
|
||||
type StripeWebhookResponse = {
|
||||
|
||||
@@ -1,17 +1,19 @@
|
||||
export { Body } from '@react-email/body';
|
||||
export { Button } from '@react-email/button';
|
||||
export { Column } from '@react-email/column';
|
||||
export { Container } from '@react-email/container';
|
||||
export { Font } from '@react-email/font';
|
||||
export { Head } from '@react-email/head';
|
||||
export { Heading } from '@react-email/heading';
|
||||
export { Hr } from '@react-email/hr';
|
||||
export { Html } from '@react-email/html';
|
||||
export { Img } from '@react-email/img';
|
||||
export { Link } from '@react-email/link';
|
||||
export { Preview } from '@react-email/preview';
|
||||
export { render } from '@react-email/render';
|
||||
export { Row } from '@react-email/row';
|
||||
export { Section } from '@react-email/section';
|
||||
export { Tailwind } from '@react-email/tailwind';
|
||||
export { Text } from '@react-email/text';
|
||||
export {
|
||||
Body,
|
||||
Button,
|
||||
Column,
|
||||
Container,
|
||||
Font,
|
||||
Head,
|
||||
Heading,
|
||||
Hr,
|
||||
Html,
|
||||
Img,
|
||||
Link,
|
||||
Preview,
|
||||
Row,
|
||||
render,
|
||||
Section,
|
||||
Tailwind,
|
||||
Text,
|
||||
} from 'react-email';
|
||||
|
||||
@@ -17,33 +17,15 @@
|
||||
"clean": "rimraf node_modules"
|
||||
},
|
||||
"dependencies": {
|
||||
"@documenso/nodemailer-resend": "4.0.0",
|
||||
"@documenso/nodemailer-resend": "5.0.0",
|
||||
"@documenso/tailwind-config": "*",
|
||||
"@react-email/body": "0.2.0",
|
||||
"@react-email/button": "0.2.0",
|
||||
"@react-email/code-block": "0.2.0",
|
||||
"@react-email/code-inline": "0.0.5",
|
||||
"@react-email/column": "0.0.13",
|
||||
"@react-email/container": "0.0.15",
|
||||
"@react-email/font": "0.0.9",
|
||||
"@react-email/head": "0.0.12",
|
||||
"@react-email/heading": "0.0.15",
|
||||
"@react-email/hr": "0.0.11",
|
||||
"@react-email/html": "0.0.11",
|
||||
"@react-email/img": "0.0.11",
|
||||
"@react-email/link": "0.0.12",
|
||||
"@react-email/preview": "0.0.13",
|
||||
"@react-email/render": "2.0.0",
|
||||
"@react-email/row": "0.0.12",
|
||||
"@react-email/section": "0.0.16",
|
||||
"@react-email/tailwind": "^2.0.1",
|
||||
"@react-email/text": "0.1.5",
|
||||
"nodemailer": "^8.0.5",
|
||||
"react-email": "^5.0.6",
|
||||
"@react-email/render": "2.1.0",
|
||||
"nodemailer": "^9.0.0",
|
||||
"react-email": "^6.9.0",
|
||||
"resend": "^6.5.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@documenso/tsconfig": "*",
|
||||
"@types/nodemailer": "^8.0.0"
|
||||
"@types/nodemailer": "^8.0.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,7 +87,7 @@ export const TemplateDocumentInvite = ({
|
||||
|
||||
<Section className="mt-8 mb-6 text-center">
|
||||
<Button
|
||||
className="inline-flex items-center justify-center rounded-lg bg-primary px-6 py-3 text-center font-medium text-primary-foreground text-sbase no-underline"
|
||||
className="inline-flex items-center justify-center rounded-lg bg-primary px-6 py-3 text-center font-medium text-base text-primary-foreground no-underline"
|
||||
href={signDocumentLink}
|
||||
>
|
||||
{match(role)
|
||||
|
||||
@@ -3,6 +3,8 @@ import type { SentMessageInfo, Transport } from 'nodemailer';
|
||||
import type { Address } from 'nodemailer/lib/mailer';
|
||||
import type MailMessage from 'nodemailer/lib/mailer/mail-message';
|
||||
|
||||
import { normalizeMailHeaders } from './normalize-headers';
|
||||
|
||||
const VERSION = '1.0.0';
|
||||
|
||||
type NodeMailerAddress = string | Address | Array<string | Address> | undefined;
|
||||
@@ -54,6 +56,7 @@ export class MailChannelsTransport implements Transport<SentMessageInfo> {
|
||||
const mailBcc = this.toMailChannelsAddresses(mail.data.bcc);
|
||||
|
||||
const [from] = this.toMailChannelsAddresses(mail.data.from);
|
||||
const [replyTo] = this.toMailChannelsAddresses(mail.data.replyTo);
|
||||
|
||||
if (!from) {
|
||||
return callback(new Error('Missing required field "from"'), null);
|
||||
@@ -72,6 +75,8 @@ export class MailChannelsTransport implements Transport<SentMessageInfo> {
|
||||
headers: requestHeaders,
|
||||
body: JSON.stringify({
|
||||
from: from,
|
||||
reply_to: replyTo,
|
||||
headers: normalizeMailHeaders(mail.data.headers),
|
||||
subject: mail.data.subject,
|
||||
personalizations: [
|
||||
{
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import type Mail from 'nodemailer/lib/mailer';
|
||||
|
||||
/**
|
||||
* Normalizes nodemailer mail headers into the flat `Record<string, string>`
|
||||
* shape accepted by HTTP email APIs such as Resend and MailChannels.
|
||||
*
|
||||
* Kept in sync with `toResendHeaders` in the `@documenso/nodemailer-resend`
|
||||
* package, which applies the same normalization for the Resend transport.
|
||||
*/
|
||||
export const normalizeMailHeaders = (headers: Mail.Options['headers']): Record<string, string> | undefined => {
|
||||
if (!headers) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const normalized: Record<string, string> = {};
|
||||
|
||||
const appendHeader = (key: string, value: unknown) => {
|
||||
if (value === null || value === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
const stringValue = String(value);
|
||||
|
||||
normalized[key] = normalized[key] ? `${normalized[key]}, ${stringValue}` : stringValue;
|
||||
};
|
||||
|
||||
if (Array.isArray(headers)) {
|
||||
for (const { key, value } of headers) {
|
||||
appendHeader(key, value);
|
||||
}
|
||||
} else {
|
||||
for (const [key, value] of Object.entries(headers)) {
|
||||
if (Array.isArray(value)) {
|
||||
for (const item of value) {
|
||||
appendHeader(key, item);
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (typeof value === 'object' && value !== null) {
|
||||
appendHeader(key, value.value);
|
||||
continue;
|
||||
}
|
||||
|
||||
appendHeader(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(normalized).length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return normalized;
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* Read a cookie value from `document.cookie`.
|
||||
*
|
||||
* Client-side counterpart of `extractCookieFromHeaders`. Only works for cookies that
|
||||
* are not `HttpOnly`, such as the preferred team URL cookie.
|
||||
*/
|
||||
export const extractCookieFromDocument = (cookieName: string): string | null => {
|
||||
const cookiePairs = document.cookie.split(';');
|
||||
|
||||
const cookie = cookiePairs.find((pair) => pair.trim().startsWith(`${cookieName}=`));
|
||||
|
||||
if (!cookie) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return cookie.split('=')[1].trim();
|
||||
};
|
||||
@@ -0,0 +1,178 @@
|
||||
import { Zip, ZipPassThrough } from 'fflate';
|
||||
|
||||
export type ZipFileEntry = {
|
||||
/**
|
||||
* The path of the file within the archive. Forward slashes create folders.
|
||||
* Individual path segments should be sanitized with
|
||||
* {@link sanitizeZipPathSegment} when derived from user-controlled values.
|
||||
*/
|
||||
filename: string;
|
||||
data: Blob;
|
||||
};
|
||||
|
||||
/**
|
||||
* Sanitizes a single path segment (folder or file name) for use inside a zip
|
||||
* archive, replacing characters that are path separators or invalid on
|
||||
* Windows extraction.
|
||||
*/
|
||||
export const sanitizeZipPathSegment = (segment: string): string => {
|
||||
const sanitized = segment
|
||||
.replace(/[\\/:*?"<>|\p{Cc}]/gu, '-')
|
||||
.trim()
|
||||
// Windows cannot extract folders or files ending with a dot.
|
||||
.replace(/\.+$/, '');
|
||||
|
||||
return sanitized || 'untitled';
|
||||
};
|
||||
|
||||
export type ZipWriter = {
|
||||
/**
|
||||
* Adds a file to the zip stream. Files are written incrementally so the
|
||||
* input blob can be garbage collected once this resolves.
|
||||
*/
|
||||
addFile: (entry: ZipFileEntry) => Promise<void>;
|
||||
|
||||
/**
|
||||
* Finishes the zip stream and returns the archive as a blob.
|
||||
*/
|
||||
finalize: () => Blob;
|
||||
|
||||
/**
|
||||
* Discards the zip stream and any buffered output.
|
||||
*/
|
||||
abort: () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* How many bytes of a blob to materialise into the JS heap per read. Blobs
|
||||
* (e.g. fetch responses) can be disk-backed by the browser, it is only
|
||||
* `arrayBuffer()` that forces them into memory, so we read in slices.
|
||||
*/
|
||||
const READ_SLICE_BYTES = 4 * 1024 * 1024;
|
||||
|
||||
/**
|
||||
* Once this many bytes of zip output have accumulated in the JS heap they are
|
||||
* coalesced into an intermediate blob. Browsers can page blob storage to disk
|
||||
* under memory pressure, and the final `new Blob(parts)` composes parts by
|
||||
* reference, so this keeps the heap bounded regardless of archive size.
|
||||
*/
|
||||
const OUTPUT_COALESCE_BYTES = 16 * 1024 * 1024;
|
||||
|
||||
/**
|
||||
* Creates an incremental client-side zip writer.
|
||||
*
|
||||
* Files are stored without compression (PDFs are already internally
|
||||
* compressed) and streamed through the archive as they are added, so peak JS
|
||||
* heap usage is bounded by roughly one read slice plus one output buffer
|
||||
* rather than the total size of the archive.
|
||||
*/
|
||||
export const createZipWriter = (): ZipWriter => {
|
||||
const usedNames = new Set<string>();
|
||||
|
||||
const outputParts: Blob[] = [];
|
||||
let pendingChunks: Uint8Array[] = [];
|
||||
let pendingSize = 0;
|
||||
|
||||
let zipError: Error | null = null;
|
||||
|
||||
const flushPendingChunks = () => {
|
||||
if (pendingChunks.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
outputParts.push(new Blob(pendingChunks));
|
||||
pendingChunks = [];
|
||||
pendingSize = 0;
|
||||
};
|
||||
|
||||
// ZipPassThrough is synchronous (no workers), so output callbacks have
|
||||
// always fired by the time `push`/`end` return.
|
||||
const zipStream = new Zip((error, chunk, isFinal) => {
|
||||
if (error) {
|
||||
zipError = error;
|
||||
return;
|
||||
}
|
||||
|
||||
pendingChunks.push(chunk);
|
||||
pendingSize += chunk.length;
|
||||
|
||||
if (pendingSize >= OUTPUT_COALESCE_BYTES || isFinal) {
|
||||
flushPendingChunks();
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Deduplicates filenames case-insensitively (Windows extraction is
|
||||
* case-insensitive) by appending " (n)" before the extension.
|
||||
*/
|
||||
const deduplicateFilename = (filename: string) => {
|
||||
const match = filename.match(/^(.*?)(\.[^./]+)?$/);
|
||||
|
||||
const baseName = match?.[1] ?? filename;
|
||||
const extension = match?.[2] ?? '';
|
||||
|
||||
let candidate = filename;
|
||||
let counter = 1;
|
||||
|
||||
while (usedNames.has(candidate.toLowerCase())) {
|
||||
candidate = `${baseName} (${counter})${extension}`;
|
||||
counter += 1;
|
||||
}
|
||||
|
||||
usedNames.add(candidate.toLowerCase());
|
||||
|
||||
return candidate;
|
||||
};
|
||||
|
||||
const addFile = async ({ filename, data }: ZipFileEntry) => {
|
||||
if (zipError) {
|
||||
throw zipError;
|
||||
}
|
||||
|
||||
const file = new ZipPassThrough(deduplicateFilename(filename));
|
||||
|
||||
zipStream.add(file);
|
||||
|
||||
for (let offset = 0; offset < data.size; offset += READ_SLICE_BYTES) {
|
||||
const slice = data.slice(offset, offset + READ_SLICE_BYTES);
|
||||
|
||||
file.push(new Uint8Array(await slice.arrayBuffer()));
|
||||
|
||||
if (zipError) {
|
||||
throw zipError;
|
||||
}
|
||||
}
|
||||
|
||||
file.push(new Uint8Array(0), true);
|
||||
|
||||
if (zipError) {
|
||||
throw zipError;
|
||||
}
|
||||
};
|
||||
|
||||
const finalize = () => {
|
||||
zipStream.end();
|
||||
|
||||
if (zipError) {
|
||||
throw zipError;
|
||||
}
|
||||
|
||||
flushPendingChunks();
|
||||
|
||||
return new Blob(outputParts, { type: 'application/zip' });
|
||||
};
|
||||
|
||||
const abort = () => {
|
||||
zipStream.terminate();
|
||||
|
||||
pendingChunks = [];
|
||||
pendingSize = 0;
|
||||
outputParts.length = 0;
|
||||
};
|
||||
|
||||
return {
|
||||
addFile,
|
||||
finalize,
|
||||
abort,
|
||||
};
|
||||
};
|
||||
@@ -32,7 +32,11 @@ const versionToFilenameSuffix = (version: DocumentVersion): string => {
|
||||
}
|
||||
};
|
||||
|
||||
export const downloadPDF = async ({ envelopeItem, token, fileName, version = 'signed' }: DownloadPDFProps) => {
|
||||
/**
|
||||
* Fetches a PDF for an envelope item and returns it as a blob alongside the
|
||||
* filename it should be saved as. Throws on non-OK responses.
|
||||
*/
|
||||
export const fetchPDF = async ({ envelopeItem, token, fileName, version = 'signed' }: DownloadPDFProps) => {
|
||||
const downloadUrl = getEnvelopeItemPdfUrl({
|
||||
type: 'download',
|
||||
envelopeItem: envelopeItem,
|
||||
@@ -40,12 +44,27 @@ export const downloadPDF = async ({ envelopeItem, token, fileName, version = 'si
|
||||
version,
|
||||
});
|
||||
|
||||
const blob = await fetch(downloadUrl).then(async (res) => await res.blob());
|
||||
const response = await fetch(downloadUrl);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to download PDF: ${response.status}`);
|
||||
}
|
||||
|
||||
const blob = await response.blob();
|
||||
|
||||
const baseTitle = (fileName ?? 'document').replace(/\.pdf$/, '');
|
||||
|
||||
downloadFile({
|
||||
return {
|
||||
filename: `${baseTitle}${versionToFilenameSuffix(version)}`,
|
||||
blob,
|
||||
};
|
||||
};
|
||||
|
||||
export const downloadPDF = async (options: DownloadPDFProps) => {
|
||||
const { filename, blob } = await fetchPDF(options);
|
||||
|
||||
downloadFile({
|
||||
filename,
|
||||
data: blob,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -8,7 +8,7 @@ type SaveRequest<T, R> = {
|
||||
export const useAutoSave = <T, R = void>(onSave: (data: T) => Promise<R>, options: { delay?: number } = {}) => {
|
||||
const { delay = 2000 } = options;
|
||||
|
||||
const saveTimeoutRef = useRef<NodeJS.Timeout>();
|
||||
const saveTimeoutRef = useRef<NodeJS.Timeout | undefined>(undefined);
|
||||
const saveQueueRef = useRef<SaveRequest<T, R>[]>([]);
|
||||
const isProcessingRef = useRef(false);
|
||||
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { useMatches } from 'react-router';
|
||||
|
||||
/**
|
||||
* The layout treatment a route wants from its parent layout(s).
|
||||
*
|
||||
* - `'settings'` — the full-height unified settings layout: no centered page
|
||||
* container, a full-width app header, and a viewport-height flex column so the
|
||||
* settings shell can fill the available space and scroll internally.
|
||||
* - `null` — the default layout (centered `<PageContainer />`, normal flow).
|
||||
*/
|
||||
export type LayoutMode = 'settings' | null;
|
||||
|
||||
/**
|
||||
* Typed route `handle` export. Controls layout rendering.
|
||||
*
|
||||
* - `hideAppHeader` — tells the parent layout to skip rendering `<AppHeader />`.
|
||||
* - `layoutMode` — selects the layout treatment the parent layout(s) apply. See
|
||||
* {@link LayoutMode}.
|
||||
*/
|
||||
export type RouteHandle = {
|
||||
hideAppHeader?: boolean;
|
||||
layoutMode?: LayoutMode;
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns layout flags from the deepest matching route that sets any.
|
||||
* Layouts call this to decide whether to render certain elements.
|
||||
*/
|
||||
export function useChildRouteFlags(): { hideAppHeader: boolean; layoutMode: LayoutMode } {
|
||||
const matches = useMatches();
|
||||
|
||||
let hideAppHeader = false;
|
||||
let layoutMode: LayoutMode = null;
|
||||
|
||||
// Walk from deepest match backward so the leaf route wins per flag.
|
||||
for (let i = matches.length - 1; i >= 0; i--) {
|
||||
const handle = matches[i].handle;
|
||||
|
||||
if (handle == null || typeof handle !== 'object') {
|
||||
continue;
|
||||
}
|
||||
|
||||
const h = handle as RouteHandle;
|
||||
|
||||
if (layoutMode === null && h.layoutMode) {
|
||||
layoutMode = h.layoutMode;
|
||||
}
|
||||
|
||||
if (!hideAppHeader && h.hideAppHeader) {
|
||||
hideAppHeader = true;
|
||||
}
|
||||
}
|
||||
|
||||
return { hideAppHeader, layoutMode };
|
||||
}
|
||||
@@ -7,9 +7,10 @@ import { DocumentSigningOrder, RecipientRole } from '@prisma/client';
|
||||
import { useId } from 'react';
|
||||
import type { UseFormReturn } from 'react-hook-form';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { prop, sortBy } from 'remeda';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { isCcRecipient, normalizeRecipientSigningOrders, sortRecipientsForSigningOrder } from '../../utils/recipients';
|
||||
|
||||
const LocalRecipientSchema = z.object({
|
||||
formId: z.string().min(1),
|
||||
id: z.number().optional(),
|
||||
@@ -94,13 +95,13 @@ export const useEditorRecipients = ({ envelope }: EditorRecipientsProps): UseEdi
|
||||
name: recipient.name,
|
||||
email: recipient.email,
|
||||
role: recipient.role,
|
||||
signingOrder: recipient.signingOrder ?? index + 1,
|
||||
signingOrder: isCcRecipient(recipient) ? undefined : (recipient.signingOrder ?? index + 1),
|
||||
actionAuth: ZRecipientAuthOptionsSchema.parse(recipient.authOptions)?.actionAuth ?? undefined,
|
||||
}));
|
||||
|
||||
const signers: TLocalRecipient[] =
|
||||
formRecipients.length > 0
|
||||
? sortBy(formRecipients, [prop('signingOrder'), 'asc'], [prop('id'), 'asc'])
|
||||
? normalizeRecipientSigningOrders(sortRecipientsForSigningOrder(formRecipients))
|
||||
: [
|
||||
{
|
||||
formId: initialId,
|
||||
|
||||
@@ -1,84 +1,100 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
/**
|
||||
* Debounced autosave for the envelope editor (recipients, fields, settings).
|
||||
*
|
||||
* Only one save runs at a time and the latest edit always wins. If the user
|
||||
* keeps editing while a save is on the wire, their newest changes get saved
|
||||
* right after, never dropped.
|
||||
*/
|
||||
export function useEnvelopeAutosave<T>(saveFn: (data: T) => Promise<void>, delay = 1000) {
|
||||
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const lastArgsRef = useRef<T | null>(null);
|
||||
const pendingPromiseRef = useRef<Promise<void> | null>(null);
|
||||
|
||||
// The edit waiting to be saved. Wrapped in an object so null always means "nothing queued".
|
||||
const pendingRef = useRef<{ value: T } | null>(null);
|
||||
|
||||
// The save currently running, if any. Shared so we never kick off two at once.
|
||||
const commitPromiseRef = useRef<Promise<void> | null>(null);
|
||||
|
||||
// saveFn closes over editor state, so keep the latest one around without
|
||||
// making triggerSave/flush depend on it.
|
||||
const saveFnRef = useRef(saveFn);
|
||||
saveFnRef.current = saveFn;
|
||||
|
||||
const [isPending, setIsPending] = useState(false);
|
||||
const [isCommiting, setIsCommiting] = useState(false);
|
||||
|
||||
/**
|
||||
* Runs saves one at a time until the queue is empty. Anything queued
|
||||
* mid-save gets picked up on the next loop.
|
||||
*/
|
||||
const commit = useCallback((): Promise<void> => {
|
||||
if (commitPromiseRef.current) {
|
||||
return commitPromiseRef.current;
|
||||
}
|
||||
|
||||
if (!pendingRef.current) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
const pump = (async () => {
|
||||
try {
|
||||
setIsCommiting(true);
|
||||
|
||||
while (pendingRef.current) {
|
||||
const { value } = pendingRef.current;
|
||||
pendingRef.current = null;
|
||||
|
||||
await saveFnRef.current(value);
|
||||
}
|
||||
} finally {
|
||||
// eslint-disable-next-line require-atomic-updates
|
||||
commitPromiseRef.current = null;
|
||||
setIsCommiting(false);
|
||||
setIsPending(false);
|
||||
}
|
||||
})();
|
||||
|
||||
commitPromiseRef.current = pump;
|
||||
|
||||
return pump;
|
||||
}, []);
|
||||
|
||||
const triggerSave = useCallback(
|
||||
(data: T) => {
|
||||
lastArgsRef.current = data;
|
||||
pendingRef.current = { value: data };
|
||||
|
||||
// A debounce or promise means something is pending
|
||||
setIsPending(true);
|
||||
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-misused-promises
|
||||
timeoutRef.current = setTimeout(async () => {
|
||||
if (!lastArgsRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const args = lastArgsRef.current;
|
||||
lastArgsRef.current = null;
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
timeoutRef.current = null;
|
||||
|
||||
setIsCommiting(true);
|
||||
pendingPromiseRef.current = saveFn(args);
|
||||
|
||||
try {
|
||||
await pendingPromiseRef.current;
|
||||
} finally {
|
||||
// eslint-disable-next-line require-atomic-updates
|
||||
pendingPromiseRef.current = null;
|
||||
setIsCommiting(false);
|
||||
setIsPending(false);
|
||||
}
|
||||
void commit();
|
||||
}, delay);
|
||||
},
|
||||
[saveFn, delay],
|
||||
[commit, delay],
|
||||
);
|
||||
|
||||
/**
|
||||
* Skip the debounce and save now. The editor calls this when it needs
|
||||
* everything persisted, e.g. before sending or switching steps.
|
||||
*/
|
||||
const flush = useCallback(async () => {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
timeoutRef.current = null;
|
||||
}
|
||||
|
||||
if (pendingPromiseRef.current) {
|
||||
// Already running → wait for it
|
||||
await pendingPromiseRef.current;
|
||||
return;
|
||||
}
|
||||
|
||||
if (lastArgsRef.current) {
|
||||
const args = lastArgsRef.current;
|
||||
lastArgsRef.current = null;
|
||||
|
||||
setIsCommiting(true);
|
||||
setIsPending(true);
|
||||
|
||||
pendingPromiseRef.current = saveFn(args);
|
||||
try {
|
||||
await pendingPromiseRef.current;
|
||||
} finally {
|
||||
// eslint-disable-next-line require-atomic-updates
|
||||
pendingPromiseRef.current = null;
|
||||
setIsCommiting(false);
|
||||
setIsPending(false);
|
||||
}
|
||||
}
|
||||
}, [saveFn]);
|
||||
await commit();
|
||||
}, [commit]);
|
||||
|
||||
// Last-ditch attempt to save if the tab closes with unsaved edits.
|
||||
useEffect(() => {
|
||||
const handleBeforeUnload = () => {
|
||||
if (timeoutRef.current || pendingPromiseRef.current) {
|
||||
if (timeoutRef.current || pendingRef.current || commitPromiseRef.current) {
|
||||
void flush();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -15,7 +15,7 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { EnvelopeType, Prisma, ReadStatus, SendStatus, SigningStatus } from '@prisma/client';
|
||||
import type React from 'react';
|
||||
import { createContext, useCallback, useContext, useMemo, useRef, useState } from 'react';
|
||||
import { createContext, useCallback, useContext, useMemo, useRef, useState, useSyncExternalStore } from 'react';
|
||||
import { useSearchParams } from 'react-router';
|
||||
|
||||
import type { TDocumentEmailSettings } from '../../types/document-email';
|
||||
@@ -107,7 +107,39 @@ export const EnvelopeEditorProvider = ({
|
||||
|
||||
const [_searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
const [envelope, _setEnvelope] = useState(initialEnvelope);
|
||||
/**
|
||||
* The envelope is kept in a ref-backed external store instead of useState so
|
||||
* that async consumers (debounced autosave callbacks, flushAutosave, resetForms)
|
||||
* can synchronously read the latest value via `getEnvelope`.
|
||||
*
|
||||
* React subscribes to the store through useSyncExternalStore, keeping renders in
|
||||
* sync without maintaining a separate copy of the state.
|
||||
*/
|
||||
const envelopeStoreRef = useRef(initialEnvelope);
|
||||
const envelopeStoreSubscribersRef = useRef(new Set<() => void>());
|
||||
|
||||
const subscribeToEnvelopeStore = useCallback((onStoreChange: () => void) => {
|
||||
envelopeStoreSubscribersRef.current.add(onStoreChange);
|
||||
|
||||
return () => {
|
||||
envelopeStoreSubscribersRef.current.delete(onStoreChange);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const getEnvelope = useCallback(() => envelopeStoreRef.current, []);
|
||||
|
||||
const setEnvelope = useCallback((action: React.SetStateAction<TEditorEnvelope>) => {
|
||||
const next = typeof action === 'function' ? action(envelopeStoreRef.current) : action;
|
||||
|
||||
envelopeStoreRef.current = next;
|
||||
|
||||
for (const onStoreChange of envelopeStoreSubscribersRef.current) {
|
||||
onStoreChange();
|
||||
}
|
||||
}, []);
|
||||
|
||||
const envelope = useSyncExternalStore(subscribeToEnvelopeStore, getEnvelope, getEnvelope);
|
||||
|
||||
const [autosaveError, setAutosaveError] = useState<boolean>(false);
|
||||
|
||||
const isCscMode = IS_INSTANCE_CSC_MODE();
|
||||
@@ -135,8 +167,6 @@ export const EnvelopeEditorProvider = ({
|
||||
};
|
||||
}, [isCscMode, providedEditorConfig]);
|
||||
|
||||
const envelopeRef = useRef(initialEnvelope);
|
||||
|
||||
const externalFlushCallbacksRef = useRef<Map<string, () => Promise<void>>>(new Map());
|
||||
const pendingMutationsRef = useRef<Set<Promise<unknown>>>(new Set());
|
||||
|
||||
@@ -156,14 +186,6 @@ export const EnvelopeEditorProvider = ({
|
||||
});
|
||||
}, []);
|
||||
|
||||
const setEnvelope: typeof _setEnvelope = (action) => {
|
||||
_setEnvelope((prev) => {
|
||||
const next = typeof action === 'function' ? action(prev) : action;
|
||||
envelopeRef.current = next;
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const isEmbedded = editorConfig.embedded !== undefined;
|
||||
|
||||
const editorFields = useEditorFields({
|
||||
@@ -192,16 +214,18 @@ export const EnvelopeEditorProvider = ({
|
||||
try {
|
||||
let recipients: TEditorEnvelope['recipients'] = [];
|
||||
|
||||
const currentEnvelope = getEnvelope();
|
||||
|
||||
if (!isEmbedded) {
|
||||
const response = await setRecipientsMutation.mutateAsync({
|
||||
envelopeId: envelope.id,
|
||||
envelopeType: envelope.type,
|
||||
envelopeId: currentEnvelope.id,
|
||||
envelopeType: currentEnvelope.type,
|
||||
recipients: localRecipients,
|
||||
});
|
||||
|
||||
recipients = response.data;
|
||||
} else {
|
||||
recipients = mapLocalRecipientsToRecipients({ envelope, localRecipients });
|
||||
recipients = mapLocalRecipientsToRecipients({ envelope: currentEnvelope, localRecipients });
|
||||
}
|
||||
|
||||
setEnvelope((prev) => ({
|
||||
@@ -211,9 +235,7 @@ export const EnvelopeEditorProvider = ({
|
||||
}));
|
||||
|
||||
// Reset the local fields to ensure deleted recipient fields are removed.
|
||||
editorFields.resetForm(
|
||||
envelope.fields.filter((field) => recipients.some((recipient) => recipient.id === field.recipientId)),
|
||||
);
|
||||
editorFields.resetForm(getEnvelope().fields);
|
||||
|
||||
setAutosaveError(false);
|
||||
} catch (err) {
|
||||
@@ -248,16 +270,18 @@ export const EnvelopeEditorProvider = ({
|
||||
try {
|
||||
let fields: TSetEnvelopeFieldsResponse['data'] = [];
|
||||
|
||||
const currentEnvelope = getEnvelope();
|
||||
|
||||
if (!isEmbedded) {
|
||||
const response = await setFieldsMutation.mutateAsync({
|
||||
envelopeId: envelope.id,
|
||||
envelopeType: envelope.type,
|
||||
envelopeId: currentEnvelope.id,
|
||||
envelopeType: currentEnvelope.type,
|
||||
fields: localFields,
|
||||
});
|
||||
|
||||
fields = response.data;
|
||||
} else {
|
||||
fields = mapLocalFieldsToFields({ envelope, localFields });
|
||||
fields = mapLocalFieldsToFields({ envelope: currentEnvelope, localFields });
|
||||
}
|
||||
|
||||
setEnvelope((prev) => ({
|
||||
@@ -309,7 +333,7 @@ export const EnvelopeEditorProvider = ({
|
||||
try {
|
||||
const response = !isEmbedded
|
||||
? await updateEnvelopeMutation.mutateAsync({
|
||||
envelopeId: envelope.id,
|
||||
envelopeId: getEnvelope().id,
|
||||
data,
|
||||
meta,
|
||||
})
|
||||
@@ -467,12 +491,14 @@ export const EnvelopeEditorProvider = ({
|
||||
};
|
||||
|
||||
const resetForms = () => {
|
||||
const currentEnvelope = getEnvelope();
|
||||
|
||||
editorRecipients.resetForm({
|
||||
recipients: envelopeRef.current.recipients,
|
||||
documentMeta: envelopeRef.current.documentMeta,
|
||||
recipients: currentEnvelope.recipients,
|
||||
documentMeta: currentEnvelope.documentMeta,
|
||||
});
|
||||
|
||||
editorFields.resetForm(envelopeRef.current.fields);
|
||||
editorFields.resetForm(currentEnvelope.fields);
|
||||
};
|
||||
|
||||
const flushAutosave = async (): Promise<TEditorEnvelope> => {
|
||||
@@ -488,7 +514,7 @@ export const EnvelopeEditorProvider = ({
|
||||
await Promise.allSettled(Array.from(pendingMutationsRef.current));
|
||||
}
|
||||
|
||||
return envelopeRef.current;
|
||||
return getEnvelope();
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -6,6 +6,42 @@ export const APP_DOCUMENT_UPLOAD_SIZE_LIMIT = Number(env('NEXT_PUBLIC_DOCUMENT_S
|
||||
|
||||
export const NEXT_PUBLIC_WEBAPP_URL = () => env('NEXT_PUBLIC_WEBAPP_URL') ?? 'http://localhost:3000';
|
||||
|
||||
/**
|
||||
* The sub-path the app is served under (no trailing slash), e.g. "/ESign".
|
||||
* Returns an empty string when served at root.
|
||||
*
|
||||
* Prefers the explicit NEXT_PUBLIC_BASE_PATH (which is the same value baked
|
||||
* into the Vite/React Router build). Falls back to the pathname of
|
||||
* NEXT_PUBLIC_WEBAPP_URL so the function still works in dev when the env
|
||||
* variable is unset.
|
||||
*
|
||||
* Avoid using this to build URLs, use {@link formatPath} instead. Reserve this
|
||||
* for cases where the raw prefix itself is needed, such as path comparisons.
|
||||
*/
|
||||
export const getBasePath = (): string => {
|
||||
const explicit = env('NEXT_PUBLIC_BASE_PATH');
|
||||
|
||||
if (explicit) {
|
||||
return explicit.replace(/\/$/, '');
|
||||
}
|
||||
|
||||
try {
|
||||
return new URL(NEXT_PUBLIC_WEBAPP_URL()).pathname.replace(/\/$/, '');
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Prefix a root-relative path with the app's base path.
|
||||
*
|
||||
* `formatPath('/api/trpc')` -> `/ESign/api/trpc` under sub-path hosting,
|
||||
* `/api/trpc` otherwise.
|
||||
*/
|
||||
export const formatPath = (path: string): string => {
|
||||
return `${getBasePath()}${path}`;
|
||||
};
|
||||
|
||||
export const NEXT_PUBLIC_SIGNING_CONTACT_INFO = () =>
|
||||
env('NEXT_PUBLIC_SIGNING_CONTACT_INFO') ?? NEXT_PUBLIC_WEBAPP_URL();
|
||||
|
||||
@@ -17,6 +53,14 @@ export const NEXT_PRIVATE_INTERNAL_WEBAPP_URL = () =>
|
||||
|
||||
export const IS_BILLING_ENABLED = () => env('NEXT_PUBLIC_FEATURE_BILLING_ENABLED') === 'true';
|
||||
|
||||
/**
|
||||
* Whether this instance is Documenso Cloud (managed SaaS).
|
||||
*
|
||||
* Used so we can show a different UI for Documenso Cloud and self-hosted instances since
|
||||
* there are things like billing, upsells, documenso links, etc that don't make sense for self-hosted instances.
|
||||
*/
|
||||
export const IS_DOCUMENSO_CLOUD = () => env('NEXT_PUBLIC_IS_DOCUMENSO_CLOUD') === 'true';
|
||||
|
||||
export const API_V2_BETA_URL = '/api/v2-beta';
|
||||
export const API_V2_URL = '/api/v2';
|
||||
|
||||
@@ -24,7 +68,20 @@ export const SUPPORT_EMAIL = env('NEXT_PUBLIC_SUPPORT_EMAIL') ?? 'support@docume
|
||||
|
||||
export const USE_INTERNAL_URL_BROWSERLESS = () => env('NEXT_PUBLIC_USE_INTERNAL_URL_BROWSERLESS') === 'true';
|
||||
|
||||
export const IS_AI_FEATURES_CONFIGURED = () => !!env('GOOGLE_VERTEX_PROJECT_ID') && !!env('GOOGLE_VERTEX_API_KEY');
|
||||
/**
|
||||
* Returns whether AI features are configured for this instance.
|
||||
*
|
||||
* Platform-aware:
|
||||
* - On the server, checks the private Vertex credentials are configured.
|
||||
* - On the client, reads the derived public flag injected via `window.__ENV__`.
|
||||
*/
|
||||
export const IS_AI_FEATURES_CONFIGURED = (): boolean => {
|
||||
if (typeof window === 'undefined') {
|
||||
return !!env('GOOGLE_VERTEX_PROJECT_ID') && !!env('GOOGLE_VERTEX_API_KEY');
|
||||
}
|
||||
|
||||
return env('NEXT_PUBLIC_AI_FEATURES_ENABLED') === 'true';
|
||||
};
|
||||
|
||||
/**
|
||||
* Temporary flag to toggle between Playwright-based and Konva-based PDF generation
|
||||
@@ -86,3 +143,5 @@ export const CSC_INSTANCE_SIGNATURE_LEVEL = (): TSignatureLevel => {
|
||||
|
||||
return value;
|
||||
};
|
||||
|
||||
export const DOCUMENSO_CLOUD_ENTERPRISE_CTA_URL = 'https://documen.so/enterprise-cta';
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export const PREFERRED_TEAM_URL_COOKIE = 'preferred-team-url';
|
||||
@@ -36,6 +36,34 @@ export enum AppErrorCode {
|
||||
*/
|
||||
ENVELOPE_TSP_LOCKED = 'ENVELOPE_TSP_LOCKED',
|
||||
|
||||
/**
|
||||
* A completion request was made for a recipient that has already signed.
|
||||
* Thrown for retried, stale or concurrent duplicate submissions so callers
|
||||
* can resolve them idempotently instead of surfacing an error.
|
||||
*/
|
||||
RECIPIENT_ALREADY_SIGNED = 'RECIPIENT_ALREADY_SIGNED',
|
||||
|
||||
/**
|
||||
* A completion request was made for a recipient that still has required
|
||||
* fields which have not been inserted. Usually indicates the client's field
|
||||
* state is out of sync with the server (e.g. a field insert failed to
|
||||
* persist before submission).
|
||||
*/
|
||||
RECIPIENT_HAS_UNSIGNED_FIELDS = 'RECIPIENT_HAS_UNSIGNED_FIELDS',
|
||||
|
||||
/**
|
||||
* A completion request was made by a recipient in a sequential signing flow
|
||||
* before the preceding recipients have signed.
|
||||
*/
|
||||
RECIPIENT_OUT_OF_TURN = 'RECIPIENT_OUT_OF_TURN',
|
||||
|
||||
/**
|
||||
* A signer recipient does not have a signature field assigned. Thrown when
|
||||
* distributing an envelope or using a direct template where at least one
|
||||
* signer has no signature field.
|
||||
*/
|
||||
MISSING_SIGNATURE_FIELD = 'MISSING_SIGNATURE_FIELD',
|
||||
|
||||
/**
|
||||
* CSC (Cloud Signature Consortium) error codes. See the CSC QES V1 spec
|
||||
* for the recovery taxonomy.
|
||||
@@ -84,6 +112,9 @@ export const genericErrorCodeToTrpcErrorCodeMap: Record<string, { code: string;
|
||||
[AppErrorCode.ENVELOPE_CANCELLED]: { code: 'BAD_REQUEST', status: 400 },
|
||||
[AppErrorCode.ENVELOPE_LEGACY]: { code: 'BAD_REQUEST', status: 400 },
|
||||
[AppErrorCode.ENVELOPE_TSP_LOCKED]: { code: 'BAD_REQUEST', status: 400 },
|
||||
[AppErrorCode.MISSING_SIGNATURE_FIELD]: { code: 'BAD_REQUEST', status: 400 },
|
||||
[AppErrorCode.RECIPIENT_HAS_UNSIGNED_FIELDS]: { code: 'BAD_REQUEST', status: 400 },
|
||||
[AppErrorCode.RECIPIENT_OUT_OF_TURN]: { code: 'BAD_REQUEST', status: 400 },
|
||||
[AppErrorCode.CSC_INSTANCE_MODE_MISMATCH]: { code: 'BAD_REQUEST', status: 400 },
|
||||
[AppErrorCode.CSC_UNLICENSED]: { code: 'FORBIDDEN', status: 403 },
|
||||
[AppErrorCode.CSC_PROVIDER_INFO_FAILED]: { code: 'INTERNAL_SERVER_ERROR', status: 500 },
|
||||
@@ -291,6 +322,9 @@ export class AppError extends Error {
|
||||
AppErrorCode.ENVELOPE_CANCELLED,
|
||||
AppErrorCode.ENVELOPE_LEGACY,
|
||||
AppErrorCode.ENVELOPE_TSP_LOCKED,
|
||||
AppErrorCode.MISSING_SIGNATURE_FIELD,
|
||||
AppErrorCode.RECIPIENT_HAS_UNSIGNED_FIELDS,
|
||||
AppErrorCode.RECIPIENT_OUT_OF_TURN,
|
||||
AppErrorCode.CSC_INSTANCE_MODE_MISMATCH,
|
||||
AppErrorCode.CSC_CREDENTIAL_LIST_EMPTY,
|
||||
AppErrorCode.CSC_CERT_INVALID,
|
||||
|
||||
@@ -17,6 +17,7 @@ import { SEND_SIGNING_REJECTION_EMAILS_JOB_DEFINITION } from './definitions/emai
|
||||
import { SEND_SIGNING_EMAIL_JOB_DEFINITION } from './definitions/emails/send-signing-email';
|
||||
import { SEND_TEAM_DELETED_EMAIL_JOB_DEFINITION } from './definitions/emails/send-team-deleted-email';
|
||||
import { ADMIN_DELETE_ORGANISATION_JOB_DEFINITION } from './definitions/internal/admin-delete-organisation';
|
||||
import { ALERT_ORGANISATION_SEAT_DRIFT_JOB_DEFINITION } from './definitions/internal/alert-organisation-seat-drift';
|
||||
import { BACKPORT_SUBSCRIPTION_CLAIM_JOB_DEFINITION } from './definitions/internal/backport-subscription-claims';
|
||||
import { BULK_SEND_TEMPLATE_JOB_DEFINITION } from './definitions/internal/bulk-send-template';
|
||||
import { CANCEL_ORGANISATION_SUBSCRIPTION_JOB_DEFINITION } from './definitions/internal/cancel-organisation-subscription';
|
||||
@@ -29,6 +30,7 @@ import { SEAL_DOCUMENT_JOB_DEFINITION } from './definitions/internal/seal-docume
|
||||
import { SEAL_DOCUMENT_SWEEP_JOB_DEFINITION } from './definitions/internal/seal-document-sweep';
|
||||
import { SEND_SIGNING_REMINDERS_SWEEP_JOB_DEFINITION } from './definitions/internal/send-signing-reminders-sweep';
|
||||
import { SYNC_EMAIL_DOMAINS_JOB_DEFINITION } from './definitions/internal/sync-email-domains';
|
||||
import { SYNC_ORGANISATION_SEATS_JOB_DEFINITION } from './definitions/internal/sync-organisation-seats';
|
||||
|
||||
/**
|
||||
* The `as const` assertion is load bearing as it provides the correct level of type inference for
|
||||
@@ -64,7 +66,9 @@ export const jobsClient = new JobClient([
|
||||
CLEANUP_RATE_LIMITS_JOB_DEFINITION,
|
||||
SYNC_EMAIL_DOMAINS_JOB_DEFINITION,
|
||||
ADMIN_DELETE_ORGANISATION_JOB_DEFINITION,
|
||||
ALERT_ORGANISATION_SEAT_DRIFT_JOB_DEFINITION,
|
||||
CANCEL_ORGANISATION_SUBSCRIPTION_JOB_DEFINITION,
|
||||
SYNC_ORGANISATION_SEATS_JOB_DEFINITION,
|
||||
] as const);
|
||||
|
||||
export const jobs = jobsClient;
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { mailer } from '@documenso/email/mailer';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { IS_BILLING_ENABLED, SUPPORT_EMAIL } from '../../../constants/app';
|
||||
import { DOCUMENSO_INTERNAL_EMAIL } from '../../../constants/email';
|
||||
import type { JobRunIO } from '../../client/_internal/job';
|
||||
import type { TAlertOrganisationSeatDriftJobDefinition } from './alert-organisation-seat-drift';
|
||||
|
||||
/**
|
||||
* Daily check for organisations whose member count exceeds their paid seat
|
||||
* count (`organisationClaim.memberCount`, where `0` means unlimited).
|
||||
*/
|
||||
export const run = async ({ io }: { payload: TAlertOrganisationSeatDriftJobDefinition; io: JobRunIO }) => {
|
||||
if (!IS_BILLING_ENABLED()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const organisations = await prisma.organisation.findMany({
|
||||
where: {
|
||||
// Exclude unlimited-seat plans (memberCount === 0).
|
||||
organisationClaim: {
|
||||
memberCount: {
|
||||
not: 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
organisationClaim: {
|
||||
select: {
|
||||
memberCount: true,
|
||||
},
|
||||
},
|
||||
_count: {
|
||||
select: {
|
||||
members: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const driftedOrganisations = organisations.filter(
|
||||
(organisation) =>
|
||||
organisation.organisationClaim !== null &&
|
||||
organisation._count.members > organisation.organisationClaim.memberCount,
|
||||
);
|
||||
|
||||
if (driftedOrganisations.length === 0) {
|
||||
io.logger.info('No organisations exceed their paid seat count');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
await mailer.sendMail({
|
||||
to: SUPPORT_EMAIL,
|
||||
from: DOCUMENSO_INTERNAL_EMAIL,
|
||||
subject: `[Billing] ${driftedOrganisations.length} organisation(s) exceed their paid seat count`,
|
||||
text: [
|
||||
`${driftedOrganisations.length} organisation(s) have more members than their paid seat count:`,
|
||||
'',
|
||||
...driftedOrganisations.map(
|
||||
(organisation) =>
|
||||
`- ${organisation.name} (${organisation.id}): ${organisation._count.members} members vs ${organisation.organisationClaim?.memberCount ?? 0} paid seats`,
|
||||
),
|
||||
].join('\n'),
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,30 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import type { JobDefinition } from '../../client/_internal/job';
|
||||
|
||||
const ALERT_ORGANISATION_SEAT_DRIFT_JOB_DEFINITION_ID = 'internal.alert-organisation-seat-drift';
|
||||
|
||||
const ALERT_ORGANISATION_SEAT_DRIFT_JOB_DEFINITION_SCHEMA = z.object({});
|
||||
|
||||
export type TAlertOrganisationSeatDriftJobDefinition = z.infer<
|
||||
typeof ALERT_ORGANISATION_SEAT_DRIFT_JOB_DEFINITION_SCHEMA
|
||||
>;
|
||||
|
||||
export const ALERT_ORGANISATION_SEAT_DRIFT_JOB_DEFINITION = {
|
||||
id: ALERT_ORGANISATION_SEAT_DRIFT_JOB_DEFINITION_ID,
|
||||
name: 'Alert Organisation Seat Drift',
|
||||
version: '1.0.0',
|
||||
trigger: {
|
||||
name: ALERT_ORGANISATION_SEAT_DRIFT_JOB_DEFINITION_ID,
|
||||
schema: ALERT_ORGANISATION_SEAT_DRIFT_JOB_DEFINITION_SCHEMA,
|
||||
cron: '0 0 * * *', // Once a day at midnight.
|
||||
},
|
||||
handler: async ({ payload, io }) => {
|
||||
const handler = await import('./alert-organisation-seat-drift.handler');
|
||||
|
||||
await handler.run({ payload, io });
|
||||
},
|
||||
} as const satisfies JobDefinition<
|
||||
typeof ALERT_ORGANISATION_SEAT_DRIFT_JOB_DEFINITION_ID,
|
||||
TAlertOrganisationSeatDriftJobDefinition
|
||||
>;
|
||||
@@ -0,0 +1,54 @@
|
||||
import { syncMemberCountWithStripeSeatPlan } from '@documenso/ee/server-only/stripe/update-subscription-item-quantity';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { SubscriptionStatus } from '@prisma/client';
|
||||
import { IS_BILLING_ENABLED } from '../../../constants/app';
|
||||
import type { JobRunIO } from '../../client/_internal/job';
|
||||
import type { TSyncOrganisationSeatsJobDefinition } from './sync-organisation-seats';
|
||||
|
||||
export const run = async ({ payload }: { payload: TSyncOrganisationSeatsJobDefinition; io: JobRunIO }) => {
|
||||
const { organisationId } = payload;
|
||||
|
||||
if (!IS_BILLING_ENABLED()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const organisation = await prisma.organisation.findUnique({
|
||||
where: {
|
||||
id: organisationId,
|
||||
},
|
||||
include: {
|
||||
subscription: true,
|
||||
organisationClaim: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!organisation || !organisation.subscription) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip canceled/terminal subscriptions — Stripe rejects quantity updates on a
|
||||
// canceled subscription. PAST_DUE is still live and a no-proration shrink is
|
||||
// safe, so it's allowed through.
|
||||
if (organisation.subscription.status === SubscriptionStatus.INACTIVE) {
|
||||
return;
|
||||
}
|
||||
|
||||
const memberCount = await prisma.organisationMember.count({
|
||||
where: {
|
||||
organisationId,
|
||||
},
|
||||
});
|
||||
|
||||
// An organisation always retains its owner; guarding zero avoids writing the
|
||||
// unlimited sentinel to the claim.
|
||||
if (memberCount === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
await syncMemberCountWithStripeSeatPlan(
|
||||
organisation.subscription,
|
||||
organisation.organisationClaim,
|
||||
memberCount,
|
||||
'shrink',
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,29 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import type { JobDefinition } from '../../client/_internal/job';
|
||||
|
||||
const SYNC_ORGANISATION_SEATS_JOB_DEFINITION_ID = 'internal.sync-organisation-seats';
|
||||
|
||||
const SYNC_ORGANISATION_SEATS_JOB_DEFINITION_SCHEMA = z.object({
|
||||
organisationId: z.string(),
|
||||
});
|
||||
|
||||
export type TSyncOrganisationSeatsJobDefinition = z.infer<typeof SYNC_ORGANISATION_SEATS_JOB_DEFINITION_SCHEMA>;
|
||||
|
||||
export const SYNC_ORGANISATION_SEATS_JOB_DEFINITION = {
|
||||
id: SYNC_ORGANISATION_SEATS_JOB_DEFINITION_ID,
|
||||
name: 'Sync Organisation Seats',
|
||||
version: '1.0.0',
|
||||
trigger: {
|
||||
name: SYNC_ORGANISATION_SEATS_JOB_DEFINITION_ID,
|
||||
schema: SYNC_ORGANISATION_SEATS_JOB_DEFINITION_SCHEMA,
|
||||
},
|
||||
handler: async ({ payload, io }) => {
|
||||
const handler = await import('./sync-organisation-seats.handler');
|
||||
|
||||
await handler.run({ payload, io });
|
||||
},
|
||||
} as const satisfies JobDefinition<
|
||||
typeof SYNC_ORGANISATION_SEATS_JOB_DEFINITION_ID,
|
||||
TSyncOrganisationSeatsJobDefinition
|
||||
>;
|
||||
@@ -15,7 +15,7 @@
|
||||
"clean": "rimraf node_modules"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ai-sdk/google-vertex": "3.0.81",
|
||||
"@ai-sdk/google-vertex": "5.0.48",
|
||||
"@aws-sdk/client-s3": "^3.998.0",
|
||||
"@aws-sdk/client-sesv2": "^3.998.0",
|
||||
"@aws-sdk/cloudfront-signer": "^3.998.0",
|
||||
@@ -29,6 +29,7 @@
|
||||
"@documenso/email": "*",
|
||||
"@documenso/prisma": "*",
|
||||
"@documenso/signing": "*",
|
||||
"@documenso/skia-canvas": "^3.0.8-documenso.3",
|
||||
"@lingui/core": "^5.6.0",
|
||||
"@lingui/macro": "^5.6.0",
|
||||
"@lingui/react": "^5.6.0",
|
||||
@@ -42,7 +43,7 @@
|
||||
"@sindresorhus/slugify": "^3.0.0",
|
||||
"@team-plain/typescript-sdk": "^5.11.0",
|
||||
"@vvo/tzdb": "^6.196.0",
|
||||
"ai": "^5.0.104",
|
||||
"ai": "^7.0.58",
|
||||
"bullmq": "^5.71.1",
|
||||
"colord": "^2.9.3",
|
||||
"csv-parse": "^6.1.0",
|
||||
@@ -60,14 +61,13 @@
|
||||
"pino": "^9.14.0",
|
||||
"pino-pretty": "^13.1.2",
|
||||
"playwright": "1.56.1",
|
||||
"postcss": "^8.5.14",
|
||||
"postcss-selector-parser": "^7.1.1",
|
||||
"postcss": "^8.5.19",
|
||||
"postcss-selector-parser": "^7.1.4",
|
||||
"posthog-js": "^1.297.2",
|
||||
"posthog-node": "4.18.0",
|
||||
"react": "^18",
|
||||
"react": "^19.2.7",
|
||||
"remeda": "^2.32.0",
|
||||
"sharp": "0.34.5",
|
||||
"skia-canvas": "^3.0.8",
|
||||
"sharp": "0.35.3",
|
||||
"stripe": "^12.18.0",
|
||||
"ts-pattern": "^5.9.0",
|
||||
"zod": "^3.25.76"
|
||||
|
||||
@@ -0,0 +1,372 @@
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { EnvelopeType } from '@prisma/client';
|
||||
|
||||
export const ADMIN_SEARCH_RESULTS_PER_TYPE = 5;
|
||||
|
||||
const MAX_POSTGRES_INT = 2147483647;
|
||||
|
||||
const GROUP_ORDER = ['document', 'user', 'organisation', 'team', 'recipient', 'subscription'] as const;
|
||||
|
||||
export type AdminGlobalSearchResultType = (typeof GROUP_ORDER)[number];
|
||||
|
||||
export type AdminGlobalSearchResult = {
|
||||
label: string;
|
||||
sublabel?: string;
|
||||
path: string;
|
||||
value: string;
|
||||
};
|
||||
|
||||
export type AdminGlobalSearchGroup = {
|
||||
type: AdminGlobalSearchResultType;
|
||||
results: AdminGlobalSearchResult[];
|
||||
};
|
||||
|
||||
export type AdminGlobalSearchOptions = {
|
||||
query: string;
|
||||
};
|
||||
|
||||
type PartialResults = Partial<Record<AdminGlobalSearchResultType, AdminGlobalSearchResult[]>>;
|
||||
|
||||
export const adminGlobalSearch = async ({ query }: AdminGlobalSearchOptions): Promise<AdminGlobalSearchGroup[]> => {
|
||||
const trimmedQuery = query.trim();
|
||||
|
||||
if (trimmedQuery.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const resultsByType = await resolveSearch(trimmedQuery);
|
||||
|
||||
return GROUP_ORDER.map((type) => ({
|
||||
type,
|
||||
results: (resultsByType[type] ?? []).map((result) => ({
|
||||
...result,
|
||||
// Append the raw query so cmdk's client-side filter never hides
|
||||
// server-verified results.
|
||||
value: `${result.value} ${trimmedQuery}`,
|
||||
})),
|
||||
})).filter((group) => group.results.length > 0);
|
||||
};
|
||||
|
||||
const resolveSearch = async (query: string): Promise<PartialResults> => {
|
||||
// Recognized ID prefixes resolve to a single exact lookup.
|
||||
if (query.startsWith('envelope_')) {
|
||||
return { document: await findDocumentsByExactId({ id: query }) };
|
||||
}
|
||||
|
||||
if (query.startsWith('document_')) {
|
||||
return { document: await findDocumentsByExactId({ secondaryId: query }) };
|
||||
}
|
||||
|
||||
if (query.startsWith('org_')) {
|
||||
return { organisation: await findOrganisationsByIdOrUrl(query) };
|
||||
}
|
||||
|
||||
// Bare numbers are treated as verified ID lookups only. Oversized numbers
|
||||
// fall through to text search.
|
||||
const numericId = Number(query);
|
||||
|
||||
if (/^\d+$/.test(query) && numericId <= MAX_POSTGRES_INT) {
|
||||
const [document, user, team, recipient, subscription] = await Promise.all([
|
||||
findDocumentsByExactId({ secondaryId: `document_${numericId}` }),
|
||||
findUsersById(numericId),
|
||||
findTeamsById(numericId),
|
||||
findRecipientsById(numericId),
|
||||
findSubscriptionsById(numericId),
|
||||
]);
|
||||
|
||||
return { document, user, team, recipient, subscription };
|
||||
}
|
||||
|
||||
// Free text searches all resource types in parallel.
|
||||
const [document, user, organisation, team, recipient, subscription] = await Promise.all([
|
||||
findDocumentsByText(query),
|
||||
findUsersByText(query),
|
||||
findOrganisationsByText(query),
|
||||
findTeamsByText(query),
|
||||
findRecipientsByText(query),
|
||||
findSubscriptionsByText(query),
|
||||
]);
|
||||
|
||||
return {
|
||||
document,
|
||||
user,
|
||||
organisation,
|
||||
team,
|
||||
recipient,
|
||||
subscription,
|
||||
};
|
||||
};
|
||||
|
||||
const joinSublabel = (parts: Array<string | null | undefined>) =>
|
||||
parts.filter((part) => part && part.length > 0).join(' · ') || undefined;
|
||||
|
||||
// ─── Documents ────────────────────────────────────────────────────────────────
|
||||
|
||||
const documentSelect = {
|
||||
id: true,
|
||||
title: true,
|
||||
secondaryId: true,
|
||||
user: { select: { email: true } },
|
||||
} as const;
|
||||
|
||||
type DocumentRow = {
|
||||
id: string;
|
||||
title: string;
|
||||
secondaryId: string;
|
||||
user: { email: string };
|
||||
};
|
||||
|
||||
const mapDocument = (envelope: DocumentRow): AdminGlobalSearchResult => ({
|
||||
label: envelope.title,
|
||||
sublabel: joinSublabel([envelope.secondaryId, envelope.user.email]),
|
||||
path: `/admin/documents/${envelope.id}`,
|
||||
value: `document ${envelope.id} ${envelope.secondaryId} ${envelope.title} ${envelope.user.email}`,
|
||||
});
|
||||
|
||||
const findDocumentsByExactId = async (where: { id: string } | { secondaryId: string }) => {
|
||||
const envelope = await prisma.envelope.findFirst({
|
||||
where: { ...where, type: EnvelopeType.DOCUMENT },
|
||||
select: documentSelect,
|
||||
});
|
||||
|
||||
return envelope ? [mapDocument(envelope)] : [];
|
||||
};
|
||||
|
||||
const findDocumentsByText = async (query: string) => {
|
||||
const envelopes = await prisma.envelope.findMany({
|
||||
where: {
|
||||
type: EnvelopeType.DOCUMENT,
|
||||
title: { contains: query, mode: 'insensitive' },
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: ADMIN_SEARCH_RESULTS_PER_TYPE,
|
||||
select: documentSelect,
|
||||
});
|
||||
|
||||
return envelopes.map(mapDocument);
|
||||
};
|
||||
|
||||
// ─── Users ────────────────────────────────────────────────────────────────────
|
||||
|
||||
const userSelect = {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
} as const;
|
||||
|
||||
type UserRow = { id: number; name: string | null; email: string };
|
||||
|
||||
const mapUser = (user: UserRow): AdminGlobalSearchResult => ({
|
||||
label: user.name || user.email,
|
||||
sublabel: joinSublabel([`#${user.id}`, user.email]),
|
||||
path: `/admin/users/${user.id}`,
|
||||
value: `user ${user.id} ${user.name ?? ''} ${user.email}`,
|
||||
});
|
||||
|
||||
const findUsersById = async (id: number) => {
|
||||
const user = await prisma.user.findFirst({
|
||||
where: { id },
|
||||
select: userSelect,
|
||||
});
|
||||
|
||||
return user ? [mapUser(user)] : [];
|
||||
};
|
||||
|
||||
const findUsersByText = async (query: string) => {
|
||||
const users = await prisma.user.findMany({
|
||||
where: {
|
||||
OR: [{ name: { contains: query, mode: 'insensitive' } }, { email: { contains: query, mode: 'insensitive' } }],
|
||||
},
|
||||
orderBy: { id: 'desc' },
|
||||
take: ADMIN_SEARCH_RESULTS_PER_TYPE,
|
||||
select: userSelect,
|
||||
});
|
||||
|
||||
return users.map(mapUser);
|
||||
};
|
||||
|
||||
// ─── Organisations ────────────────────────────────────────────────────────────
|
||||
|
||||
const organisationSelect = {
|
||||
id: true,
|
||||
name: true,
|
||||
owner: { select: { email: true } },
|
||||
} as const;
|
||||
|
||||
type OrganisationRow = { id: string; name: string; owner: { email: string } };
|
||||
|
||||
const mapOrganisation = (organisation: OrganisationRow): AdminGlobalSearchResult => ({
|
||||
label: organisation.name,
|
||||
sublabel: joinSublabel([organisation.id, organisation.owner.email]),
|
||||
path: `/admin/organisations/${organisation.id}`,
|
||||
value: `organisation ${organisation.id} ${organisation.name} ${organisation.owner.email}`,
|
||||
});
|
||||
|
||||
const findOrganisationsByIdOrUrl = async (query: string) => {
|
||||
const organisations = await prisma.organisation.findMany({
|
||||
where: {
|
||||
OR: [{ id: query }, { url: query }],
|
||||
},
|
||||
take: ADMIN_SEARCH_RESULTS_PER_TYPE,
|
||||
select: organisationSelect,
|
||||
});
|
||||
|
||||
return organisations.map(mapOrganisation);
|
||||
};
|
||||
|
||||
const findOrganisationsByText = async (query: string) => {
|
||||
const organisations = await prisma.organisation.findMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ name: { contains: query, mode: 'insensitive' } },
|
||||
{ url: { contains: query, mode: 'insensitive' } },
|
||||
{ customerId: { contains: query, mode: 'insensitive' } },
|
||||
{ owner: { email: { contains: query, mode: 'insensitive' } } },
|
||||
],
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: ADMIN_SEARCH_RESULTS_PER_TYPE,
|
||||
select: organisationSelect,
|
||||
});
|
||||
|
||||
return organisations.map(mapOrganisation);
|
||||
};
|
||||
|
||||
// ─── Teams ────────────────────────────────────────────────────────────────────
|
||||
|
||||
const teamSelect = {
|
||||
id: true,
|
||||
name: true,
|
||||
url: true,
|
||||
organisation: { select: { name: true } },
|
||||
} as const;
|
||||
|
||||
type TeamRow = { id: number; name: string; url: string; organisation: { name: string } };
|
||||
|
||||
const mapTeam = (team: TeamRow): AdminGlobalSearchResult => ({
|
||||
label: team.name,
|
||||
sublabel: joinSublabel([`#${team.id}`, `/${team.url}`, team.organisation.name]),
|
||||
path: `/admin/teams/${team.id}`,
|
||||
value: `team ${team.id} ${team.name} ${team.url} ${team.organisation.name}`,
|
||||
});
|
||||
|
||||
const findTeamsById = async (id: number) => {
|
||||
const team = await prisma.team.findFirst({
|
||||
where: { id },
|
||||
select: teamSelect,
|
||||
});
|
||||
|
||||
return team ? [mapTeam(team)] : [];
|
||||
};
|
||||
|
||||
const findTeamsByText = async (query: string) => {
|
||||
const teams = await prisma.team.findMany({
|
||||
where: {
|
||||
OR: [{ name: { contains: query, mode: 'insensitive' } }, { url: { contains: query, mode: 'insensitive' } }],
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: ADMIN_SEARCH_RESULTS_PER_TYPE,
|
||||
select: teamSelect,
|
||||
});
|
||||
|
||||
return teams.map(mapTeam);
|
||||
};
|
||||
|
||||
// ─── Recipients ───────────────────────────────────────────────────────────────
|
||||
|
||||
const recipientSelect = {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
envelope: { select: { id: true, title: true } },
|
||||
} as const;
|
||||
|
||||
type RecipientRow = {
|
||||
id: number;
|
||||
name: string;
|
||||
email: string;
|
||||
envelope: { id: string; title: string };
|
||||
};
|
||||
|
||||
const mapRecipient = (recipient: RecipientRow): AdminGlobalSearchResult => ({
|
||||
label: recipient.email,
|
||||
sublabel: joinSublabel([`#${recipient.id}`, recipient.name, recipient.envelope.title]),
|
||||
path: `/admin/documents/${recipient.envelope.id}`,
|
||||
value: `recipient ${recipient.id} ${recipient.name} ${recipient.email} ${recipient.envelope.title}`,
|
||||
});
|
||||
|
||||
const findRecipientsById = async (id: number) => {
|
||||
const recipient = await prisma.recipient.findFirst({
|
||||
where: {
|
||||
id,
|
||||
envelope: { type: EnvelopeType.DOCUMENT },
|
||||
},
|
||||
select: recipientSelect,
|
||||
});
|
||||
|
||||
return recipient ? [mapRecipient(recipient)] : [];
|
||||
};
|
||||
|
||||
const findRecipientsByText = async (query: string) => {
|
||||
const recipients = await prisma.recipient.findMany({
|
||||
where: {
|
||||
envelope: { type: EnvelopeType.DOCUMENT },
|
||||
OR: [{ email: { contains: query, mode: 'insensitive' } }, { name: { contains: query, mode: 'insensitive' } }],
|
||||
},
|
||||
orderBy: { id: 'desc' },
|
||||
take: ADMIN_SEARCH_RESULTS_PER_TYPE,
|
||||
select: recipientSelect,
|
||||
});
|
||||
|
||||
return recipients.map(mapRecipient);
|
||||
};
|
||||
|
||||
// ─── Subscriptions ────────────────────────────────────────────────────────────
|
||||
|
||||
const subscriptionSelect = {
|
||||
id: true,
|
||||
status: true,
|
||||
planId: true,
|
||||
customerId: true,
|
||||
organisationId: true,
|
||||
} as const;
|
||||
|
||||
type SubscriptionRow = {
|
||||
id: number;
|
||||
status: string;
|
||||
planId: string;
|
||||
customerId: string;
|
||||
organisationId: string;
|
||||
};
|
||||
|
||||
const mapSubscription = (subscription: SubscriptionRow): AdminGlobalSearchResult => ({
|
||||
label: `Subscription #${subscription.id}`,
|
||||
sublabel: joinSublabel([subscription.status, subscription.planId]),
|
||||
path: `/admin/organisations/${subscription.organisationId}`,
|
||||
value: `subscription ${subscription.id} ${subscription.planId} ${subscription.customerId}`,
|
||||
});
|
||||
|
||||
const findSubscriptionsById = async (id: number) => {
|
||||
const subscription = await prisma.subscription.findFirst({
|
||||
where: { id },
|
||||
select: subscriptionSelect,
|
||||
});
|
||||
|
||||
return subscription ? [mapSubscription(subscription)] : [];
|
||||
};
|
||||
|
||||
const findSubscriptionsByText = async (query: string) => {
|
||||
const subscriptions = await prisma.subscription.findMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ planId: { contains: query, mode: 'insensitive' } },
|
||||
{ customerId: { contains: query, mode: 'insensitive' } },
|
||||
],
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: ADMIN_SEARCH_RESULTS_PER_TYPE,
|
||||
select: subscriptionSelect,
|
||||
});
|
||||
|
||||
return subscriptions.map(mapSubscription);
|
||||
};
|
||||
@@ -13,7 +13,7 @@ export const getDocumentStats = async () => {
|
||||
},
|
||||
});
|
||||
|
||||
const stats: Record<Exclude<ExtendedDocumentStatus, 'INBOX'>, number> = {
|
||||
const stats: Record<Exclude<ExtendedDocumentStatus, 'INBOX' | 'EXPIRED'>, number> = {
|
||||
[ExtendedDocumentStatus.DRAFT]: 0,
|
||||
[ExtendedDocumentStatus.PENDING]: 0,
|
||||
[ExtendedDocumentStatus.COMPLETED]: 0,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Canvas, Image, Path2D } from '@documenso/skia-canvas';
|
||||
import pMap from 'p-map';
|
||||
import * as pdfjsLib from 'pdfjs-dist/legacy/build/pdf.mjs';
|
||||
import { Canvas, Image, Path2D } from 'skia-canvas';
|
||||
|
||||
// @ts-expect-error napi-rs/canvas satisfies the requirements
|
||||
globalThis.Path2D = Path2D;
|
||||
|
||||
@@ -59,7 +59,7 @@ export const completeDocumentWithToken = async ({
|
||||
nextSigner,
|
||||
recipientOverride,
|
||||
}: CompleteDocumentWithTokenOptions) => {
|
||||
const envelope = await prisma.envelope.findFirstOrThrow({
|
||||
const envelope = await prisma.envelope.findFirst({
|
||||
where: {
|
||||
...unsafeBuildEnvelopeIdQuery(id, EnvelopeType.DOCUMENT),
|
||||
recipients: {
|
||||
@@ -78,24 +78,56 @@ export const completeDocumentWithToken = async ({
|
||||
},
|
||||
});
|
||||
|
||||
const legacyDocumentId = mapSecondaryIdToDocumentId(envelope.secondaryId);
|
||||
|
||||
if (envelope.status !== DocumentStatus.PENDING) {
|
||||
throw new Error(`Document ${envelope.id} must be pending`);
|
||||
// The most common cause is a stale signing page: the document was deleted,
|
||||
// or the recipient was removed, after the link was opened. Surface a
|
||||
// NOT_FOUND instead of leaking a Prisma P2025 as a 500.
|
||||
if (!envelope) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'Document not found for the provided signing token',
|
||||
statusCode: 404,
|
||||
});
|
||||
}
|
||||
|
||||
const legacyDocumentId = mapSecondaryIdToDocumentId(envelope.secondaryId);
|
||||
|
||||
if (envelope.recipients.length === 0) {
|
||||
throw new Error(`Document ${envelope.id} has no recipient with token ${token}`);
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: `Document ${envelope.id} has no recipient with the provided token`,
|
||||
statusCode: 404,
|
||||
});
|
||||
}
|
||||
|
||||
const [recipient] = envelope.recipients;
|
||||
|
||||
assertRecipientNotExpired(recipient);
|
||||
|
||||
// A retried or duplicate completion request for an already signed
|
||||
// recipient throws a code the router resolves idempotently. This must be
|
||||
// checked before the envelope status guard since the envelope may have
|
||||
// been completed and sealed by the recipient's original request.
|
||||
if (recipient.signingStatus === SigningStatus.SIGNED) {
|
||||
throw new Error(`Recipient ${recipient.id} has already signed`);
|
||||
throw new AppError(AppErrorCode.RECIPIENT_ALREADY_SIGNED, {
|
||||
message: `Recipient ${recipient.id} has already signed`,
|
||||
statusCode: 400,
|
||||
});
|
||||
}
|
||||
|
||||
if (envelope.status !== DocumentStatus.PENDING) {
|
||||
const envelopeStatusErrorCode: Record<DocumentStatus, AppErrorCode> = {
|
||||
[DocumentStatus.DRAFT]: AppErrorCode.ENVELOPE_DRAFT,
|
||||
[DocumentStatus.COMPLETED]: AppErrorCode.ENVELOPE_COMPLETED,
|
||||
[DocumentStatus.REJECTED]: AppErrorCode.ENVELOPE_REJECTED,
|
||||
[DocumentStatus.CANCELLED]: AppErrorCode.ENVELOPE_CANCELLED,
|
||||
// Unreachable: guarded by the status check above.
|
||||
[DocumentStatus.PENDING]: AppErrorCode.INVALID_REQUEST,
|
||||
};
|
||||
|
||||
throw new AppError(envelopeStatusErrorCode[envelope.status], {
|
||||
message: `Document ${envelope.id} must be pending to be completed, found ${envelope.status}`,
|
||||
statusCode: 400,
|
||||
});
|
||||
}
|
||||
|
||||
assertRecipientNotExpired(recipient);
|
||||
|
||||
if (recipient.signingStatus === SigningStatus.REJECTED) {
|
||||
throw new AppError(AppErrorCode.UNKNOWN_ERROR, {
|
||||
message: 'Recipient has already rejected the document',
|
||||
@@ -109,7 +141,10 @@ export const completeDocumentWithToken = async ({
|
||||
});
|
||||
|
||||
if (!isRecipientsTurn) {
|
||||
throw new Error(`Recipient ${recipient.id} attempted to complete the document before it was their turn`);
|
||||
throw new AppError(AppErrorCode.RECIPIENT_OUT_OF_TURN, {
|
||||
message: `Recipient ${recipient.id} attempted to complete the document before it was their turn`,
|
||||
statusCode: 400,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -272,13 +307,22 @@ export const completeDocumentWithToken = async ({
|
||||
}
|
||||
|
||||
if (fieldsContainUnsignedRequiredField(fields)) {
|
||||
throw new Error(`Recipient ${recipient.id} has unsigned fields`);
|
||||
throw new AppError(AppErrorCode.RECIPIENT_HAS_UNSIGNED_FIELDS, {
|
||||
message: `Recipient ${recipient.id} has unsigned fields`,
|
||||
statusCode: 400,
|
||||
});
|
||||
}
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await tx.recipient.update({
|
||||
// Conditional update so two concurrent completion requests can't both
|
||||
// proceed: only the request that transitions the recipient to SIGNED
|
||||
// continues, the loser sees a count of 0 and aborts.
|
||||
const { count: updatedRecipientCount } = await tx.recipient.updateMany({
|
||||
where: {
|
||||
id: recipient.id,
|
||||
signingStatus: {
|
||||
not: SigningStatus.SIGNED,
|
||||
},
|
||||
},
|
||||
data: {
|
||||
signingStatus: SigningStatus.SIGNED,
|
||||
@@ -288,6 +332,16 @@ export const completeDocumentWithToken = async ({
|
||||
},
|
||||
});
|
||||
|
||||
// A concurrent request completed the recipient between our initial read
|
||||
// and this transaction. Abort so the winning request handles all side
|
||||
// effects, the router resolves this code idempotently.
|
||||
if (updatedRecipientCount === 0) {
|
||||
throw new AppError(AppErrorCode.RECIPIENT_ALREADY_SIGNED, {
|
||||
message: `Recipient ${recipient.id} has already signed`,
|
||||
statusCode: 400,
|
||||
});
|
||||
}
|
||||
|
||||
if (recipientEmail !== recipient.email || recipientName !== recipient.name) {
|
||||
await tx.documentAuditLog.create({
|
||||
data: createDocumentAuditLogData({
|
||||
|
||||
@@ -16,6 +16,7 @@ import { match } from 'ts-pattern';
|
||||
|
||||
import type { FindResultResponse } from '../../types/search-params';
|
||||
import { maskRecipientTokensForDocument } from '../../utils/mask-recipient-tokens-for-document';
|
||||
import { hasExpiredRecipient } from '../envelope/query-helpers';
|
||||
import { getTeamById } from '../team/get-team';
|
||||
|
||||
export type PeriodSelectorValue = '' | '7d' | '14d' | '30d';
|
||||
@@ -36,6 +37,11 @@ export type FindDocumentsOptions = {
|
||||
senderIds?: number[];
|
||||
query?: string;
|
||||
folderId?: string;
|
||||
/**
|
||||
* When true, restrict results to envelopes with at least one recipient whose signing
|
||||
* link has expired. Orthogonal to `status` — applied additively.
|
||||
*/
|
||||
hasExpiredRecipients?: boolean;
|
||||
/**
|
||||
* When true (default), use a windowed count that caps early for faster pagination.
|
||||
* When false, use a full COUNT(*) for exact totals — preferred for external API consumers.
|
||||
@@ -115,6 +121,7 @@ export const findDocuments = async ({
|
||||
senderIds,
|
||||
query = '',
|
||||
folderId,
|
||||
hasExpiredRecipients,
|
||||
useWindowedCount = true,
|
||||
}: FindDocumentsOptions) => {
|
||||
const user = await prisma.user.findFirstOrThrow({
|
||||
@@ -199,6 +206,11 @@ export const findDocuments = async ({
|
||||
);
|
||||
}
|
||||
|
||||
// Expired recipient filter (orthogonal to status, additive)
|
||||
if (hasExpiredRecipients) {
|
||||
qb = qb.where((eb) => hasExpiredRecipient(eb));
|
||||
}
|
||||
|
||||
return qb;
|
||||
};
|
||||
|
||||
@@ -305,6 +317,15 @@ export const findDocuments = async ({
|
||||
]),
|
||||
),
|
||||
)
|
||||
.with(ExtendedDocumentStatus.EXPIRED, () =>
|
||||
qb.where((eb) =>
|
||||
eb.and([
|
||||
personalDeletedFilter(eb),
|
||||
hasExpiredRecipient(eb),
|
||||
eb.or([eb('Envelope.userId', '=', user.id), recipientExists(eb, user.email)]),
|
||||
]),
|
||||
),
|
||||
)
|
||||
.exhaustive();
|
||||
};
|
||||
|
||||
@@ -455,6 +476,18 @@ export const findDocuments = async ({
|
||||
return eb.and([teamDeletedFilter(eb), visibilityFilter(eb), eb.or(accessBranches)]);
|
||||
}),
|
||||
)
|
||||
.with(ExtendedDocumentStatus.EXPIRED, () =>
|
||||
qb.where((eb) => {
|
||||
const accessBranches = [eb('Envelope.teamId', '=', teamData.id)];
|
||||
|
||||
if (teamEmail) {
|
||||
accessBranches.push(senderEmailIs(eb, teamEmail));
|
||||
accessBranches.push(recipientExists(eb, teamEmail));
|
||||
}
|
||||
|
||||
return eb.and([teamDeletedFilter(eb), visibilityFilter(eb), hasExpiredRecipient(eb), eb.or(accessBranches)]);
|
||||
}),
|
||||
)
|
||||
.exhaustive();
|
||||
};
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import { DateTime } from 'luxon';
|
||||
|
||||
import { STATS_COUNT_CAP } from '../../constants/document';
|
||||
import { TEAM_DOCUMENT_VISIBILITY_MAP } from '../../constants/teams';
|
||||
import { hasExpiredRecipient } from '../envelope/query-helpers';
|
||||
import { getTeamById } from '../team/get-team';
|
||||
|
||||
// Kysely query builder type for Envelope queries.
|
||||
@@ -253,6 +254,19 @@ export const getStats = async ({ userId, teamId, period, search = '', folderId,
|
||||
return eb.and([teamDeletedFilter(eb), visibilityFilter(eb), eb.or(accessBranches)]);
|
||||
});
|
||||
|
||||
// EXPIRED: docs visible to the team/user with at least one expired, unsigned recipient.
|
||||
// Access control mirrors the EXPIRED branch in findDocuments so the count matches the listing.
|
||||
const expiredQuery = buildBaseQuery().where((eb) => {
|
||||
const accessBranches = [eb('Envelope.teamId', '=', team.id)];
|
||||
|
||||
if (teamEmail) {
|
||||
accessBranches.push(senderEmailIs(eb, teamEmail));
|
||||
accessBranches.push(recipientExists(eb, teamEmail));
|
||||
}
|
||||
|
||||
return eb.and([teamDeletedFilter(eb), visibilityFilter(eb), hasExpiredRecipient(eb), eb.or(accessBranches)]);
|
||||
});
|
||||
|
||||
// INBOX: non-draft docs where team email is a NOT_SIGNED, non-CC recipient
|
||||
// Returns 0 if the team has no team email.
|
||||
const inboxQuery = teamEmail
|
||||
@@ -274,15 +288,17 @@ export const getStats = async ({ userId, teamId, period, search = '', folderId,
|
||||
|
||||
// ─── Execute all counts in parallel ──────────────────────────────────
|
||||
|
||||
const [draft, pending, completed, rejected, cancelled, inbox] = await Promise.all([
|
||||
const [draft, pending, completed, rejected, cancelled, expired, inbox] = await Promise.all([
|
||||
cappedCount(draftQuery),
|
||||
cappedCount(pendingQuery),
|
||||
cappedCount(completedQuery),
|
||||
cappedCount(rejectedQuery),
|
||||
cappedCount(cancelledQuery),
|
||||
cappedCount(expiredQuery),
|
||||
inboxQuery ? cappedCount(inboxQuery) : Promise.resolve(0),
|
||||
]);
|
||||
|
||||
// `expired` is intentionally excluded from `all` — it overlaps PENDING.
|
||||
const all = Math.min(draft + pending + completed + rejected + cancelled + inbox, STATS_COUNT_CAP);
|
||||
|
||||
const stats: Record<ExtendedDocumentStatus, number> = {
|
||||
@@ -291,6 +307,7 @@ export const getStats = async ({ userId, teamId, period, search = '', folderId,
|
||||
[ExtendedDocumentStatus.COMPLETED]: completed,
|
||||
[ExtendedDocumentStatus.REJECTED]: rejected,
|
||||
[ExtendedDocumentStatus.CANCELLED]: cancelled,
|
||||
[ExtendedDocumentStatus.EXPIRED]: expired,
|
||||
[ExtendedDocumentStatus.INBOX]: inbox,
|
||||
[ExtendedDocumentStatus.ALL]: all,
|
||||
};
|
||||
|
||||
@@ -194,7 +194,7 @@ export const sendDocument = async ({ id, userId, teamId, sendEmail, requestMetad
|
||||
.map((r) => (r.name ? `${r.name} (${r.email}, id: ${r.id})` : `${r.email} (id: ${r.id})`))
|
||||
.join(', ');
|
||||
|
||||
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||
throw new AppError(AppErrorCode.MISSING_SIGNATURE_FIELD, {
|
||||
message: `The following recipients are missing required fields: ${missingRecipientDescriptions}. Signers must have at least one signature field.`,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { TEAM_DOCUMENT_VISIBILITY_MAP } from '../../constants/teams';
|
||||
import type { FindResultResponse } from '../../types/search-params';
|
||||
import { maskRecipientTokensForDocument } from '../../utils/mask-recipient-tokens-for-document';
|
||||
import { getTeamById } from '../team/get-team';
|
||||
import { hasExpiredRecipient } from './query-helpers';
|
||||
|
||||
export type FindEnvelopesOptions = {
|
||||
userId: number;
|
||||
@@ -23,6 +24,11 @@ export type FindEnvelopesOptions = {
|
||||
};
|
||||
query?: string;
|
||||
folderId?: string;
|
||||
/**
|
||||
* When true, restrict results to envelopes with at least one recipient whose signing
|
||||
* link has expired. Orthogonal to `status` — applied additively.
|
||||
*/
|
||||
hasExpiredRecipients?: boolean;
|
||||
/**
|
||||
* When true (default), use a windowed count that caps early for faster pagination.
|
||||
* When false, use a full COUNT(*) for exact totals — preferred for external API consumers.
|
||||
@@ -106,6 +112,7 @@ export const findEnvelopes = async ({
|
||||
orderBy,
|
||||
query = '',
|
||||
folderId,
|
||||
hasExpiredRecipients,
|
||||
useWindowedCount = true,
|
||||
}: FindEnvelopesOptions) => {
|
||||
const user = await prisma.user.findFirstOrThrow({
|
||||
@@ -182,6 +189,11 @@ export const findEnvelopes = async ({
|
||||
);
|
||||
}
|
||||
|
||||
// Expired recipient filter (orthogonal to status, additive)
|
||||
if (hasExpiredRecipients) {
|
||||
qb = qb.where((eb) => hasExpiredRecipient(eb));
|
||||
}
|
||||
|
||||
// ─── Access control ──────────────────────────────────────────────────
|
||||
//
|
||||
// An envelope is visible if ANY of:
|
||||
|
||||
@@ -5,6 +5,7 @@ import { match } from 'ts-pattern';
|
||||
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||
import { DocumentAccessAuth, type TDocumentAuthMethods } from '../../types/document-auth';
|
||||
import { extractDocumentAuthMethods } from '../../utils/document-auth';
|
||||
import { getRecipientsWithMissingFields } from '../../utils/recipients';
|
||||
import { extractFieldAutoInsertValues } from '../document/send-document';
|
||||
import { getTeamSettings } from '../team/get-team-settings';
|
||||
import type { EnvelopeForSigningResponse } from './get-envelope-for-recipient-signing';
|
||||
@@ -125,6 +126,17 @@ export const getEnvelopeForDirectTemplateSigning = async ({
|
||||
});
|
||||
}
|
||||
|
||||
const recipientsWithMissingFields = getRecipientsWithMissingFields(
|
||||
envelope.recipients,
|
||||
envelope.recipients.flatMap((envelopeRecipient) => envelopeRecipient.fields),
|
||||
);
|
||||
|
||||
if (recipientsWithMissingFields.length > 0) {
|
||||
throw new AppError(AppErrorCode.MISSING_SIGNATURE_FIELD, {
|
||||
message: 'One or more signers on this direct template are missing a signature field',
|
||||
});
|
||||
}
|
||||
|
||||
const settings = await getTeamSettings({ teamId: envelope.teamId });
|
||||
|
||||
const sender = settings.includeSenderDetails
|
||||
|
||||
@@ -5,7 +5,7 @@ import EnvelopeSchema from '@documenso/prisma/generated/zod/modelSchema/Envelope
|
||||
import SignatureSchema from '@documenso/prisma/generated/zod/modelSchema/SignatureSchema';
|
||||
import TeamSchema from '@documenso/prisma/generated/zod/modelSchema/TeamSchema';
|
||||
import UserSchema from '@documenso/prisma/generated/zod/modelSchema/UserSchema';
|
||||
import { DocumentSigningOrder, DocumentStatus, EnvelopeType, SigningStatus } from '@prisma/client';
|
||||
import { DocumentSigningOrder, DocumentStatus, EnvelopeType, RecipientRole, SigningStatus } from '@prisma/client';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||
@@ -266,6 +266,11 @@ export const getEnvelopeForRecipientSigning = async ({
|
||||
|
||||
if (envelope.documentMeta.signingOrder === DocumentSigningOrder.SEQUENTIAL && currentRecipientIndex !== -1) {
|
||||
for (let i = 0; i < currentRecipientIndex; i++) {
|
||||
// CC recipients have no action to take, so they can never block the flow.
|
||||
if (envelope.recipients[i].role === RecipientRole.CC) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (envelope.recipients[i].signingStatus !== SigningStatus.SIGNED) {
|
||||
isRecipientsTurn = false;
|
||||
break;
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { sql } from '@documenso/prisma';
|
||||
import type { DB } from '@documenso/prisma/generated/types';
|
||||
import { RecipientRole, SigningStatus } from '@prisma/client';
|
||||
import type { ExpressionBuilder } from 'kysely';
|
||||
|
||||
// Expression builder type scoped to the Envelope table context.
|
||||
type EnvelopeExpressionBuilder = ExpressionBuilder<DB, 'Envelope'>;
|
||||
|
||||
/**
|
||||
* Reusable EXISTS subquery: checks that the envelope has at least one recipient whose
|
||||
* signing link has expired — `expiresAt` in the past, still unsigned, and not a CC.
|
||||
*
|
||||
* This is the single source of truth for the "expired recipient" predicate used by
|
||||
* `findDocuments`, `findEnvelopes`, and `getStats`. It must stay in sync with
|
||||
* `isRecipientExpired` (packages/lib/utils/recipients.ts).
|
||||
*/
|
||||
export const hasExpiredRecipient = (eb: EnvelopeExpressionBuilder) =>
|
||||
eb.exists(
|
||||
eb
|
||||
.selectFrom('Recipient')
|
||||
.whereRef('Recipient.envelopeId', '=', 'Envelope.id')
|
||||
.where('Recipient.expiresAt', 'is not', null)
|
||||
.where('Recipient.expiresAt', '<=', new Date())
|
||||
.where('Recipient.signingStatus', '=', sql.lit(SigningStatus.NOT_SIGNED))
|
||||
.where('Recipient.role', '!=', sql.lit(RecipientRole.CC))
|
||||
.select(sql.lit(1).as('one')),
|
||||
);
|
||||
@@ -2,8 +2,9 @@
|
||||
* !: This is a workaround to fix the memory leak in the skia-canvas library.
|
||||
* !: Internals are ported from the original `konva/skia-backend.js` file.
|
||||
*/
|
||||
|
||||
import { Canvas, DOMMatrix, Image, Path2D } from '@documenso/skia-canvas';
|
||||
import { Konva } from 'konva/lib/_CoreInternals';
|
||||
import { Canvas, DOMMatrix, Image, Path2D } from 'skia-canvas';
|
||||
|
||||
// @ts-expect-error skia-canvas satisfies the requirements
|
||||
global.DOMMatrix = DOMMatrix;
|
||||
@@ -37,6 +38,6 @@ Konva.Util.createImageElement = () => {
|
||||
return node as unknown as HTMLImageElement;
|
||||
};
|
||||
|
||||
Konva._renderBackend = 'skia-canvas';
|
||||
Konva._renderBackend = '@documenso/skia-canvas';
|
||||
|
||||
export default Konva;
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import {
|
||||
assertMemberCountWithinCap,
|
||||
syncMemberCountWithStripeSeatPlan,
|
||||
} from '@documenso/ee/server-only/stripe/update-subscription-item-quantity';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import type { OrganisationGroup, OrganisationMemberRole } from '@prisma/client';
|
||||
import { OrganisationGroupType, OrganisationMemberInviteStatus } from '@prisma/client';
|
||||
import { OrganisationGroupType, OrganisationMemberInviteStatus, SubscriptionStatus } from '@prisma/client';
|
||||
|
||||
import { IS_BILLING_ENABLED } from '../../constants/app';
|
||||
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||
import { jobs } from '../../jobs/client';
|
||||
import { generateDatabaseId } from '../../universal/id';
|
||||
@@ -22,6 +27,13 @@ export const acceptOrganisationInvitation = async ({ token }: AcceptOrganisation
|
||||
organisation: {
|
||||
include: {
|
||||
groups: true,
|
||||
organisationClaim: true,
|
||||
subscription: true,
|
||||
members: {
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -66,6 +78,35 @@ export const acceptOrganisationInvitation = async ({ token }: AcceptOrganisation
|
||||
return;
|
||||
}
|
||||
|
||||
const newMemberCount = organisation.members.length + 1;
|
||||
|
||||
// Billing occurs when a user accepts an invite.
|
||||
// Assert that the new member count is within the cap and sync the seat plan with Stripe.
|
||||
if (IS_BILLING_ENABLED()) {
|
||||
const { subscription, organisationClaim } = organisation;
|
||||
|
||||
// A canceled subscription cannot have its seat quantity updated in Stripe,
|
||||
// and an organisation with lapsed billing should not gain new members.
|
||||
// Throw a deliberate error so the invite page can render an accurate
|
||||
// message instead of an opaque Stripe failure.
|
||||
if (subscription && subscription.status === SubscriptionStatus.INACTIVE) {
|
||||
throw new AppError('SUBSCRIPTION_INACTIVE', {
|
||||
message: 'The organisation subscription is inactive',
|
||||
});
|
||||
}
|
||||
|
||||
// Organisations can exist without a subscription (e.g. after being
|
||||
// downgraded to the free plan). The claim cap remains authoritative in
|
||||
// that case, surfacing LIMIT_EXCEEDED instead of an opaque "subscription
|
||||
// not found" error.
|
||||
await assertMemberCountWithinCap(subscription, organisationClaim, newMemberCount);
|
||||
|
||||
if (subscription) {
|
||||
await syncMemberCountWithStripeSeatPlan(subscription, organisationClaim, newMemberCount, 'grow');
|
||||
}
|
||||
}
|
||||
|
||||
// Todo: Logging
|
||||
await addUserToOrganisation({
|
||||
userId: user.id,
|
||||
organisationId: organisation.id,
|
||||
|
||||
@@ -1,7 +1,3 @@
|
||||
import {
|
||||
assertMemberCountWithinCap,
|
||||
syncMemberCountWithStripeSeatPlan,
|
||||
} from '@documenso/ee/server-only/stripe/update-subscription-item-quantity';
|
||||
import { OrganisationInviteEmailTemplate } from '@documenso/email/templates/organisation-invite';
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import { ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/organisations';
|
||||
@@ -17,7 +13,6 @@ import { createElement } from 'react';
|
||||
|
||||
import { getI18nInstance } from '../../client-only/providers/i18n-server';
|
||||
import { generateDatabaseId } from '../../universal/id';
|
||||
import { validateIfSubscriptionIsRequired } from '../../utils/billing';
|
||||
import { buildOrganisationWhereQuery } from '../../utils/organisations';
|
||||
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
|
||||
import { getEmailContext } from '../email/get-email-context';
|
||||
@@ -62,8 +57,6 @@ export const createOrganisationMemberInvites = async ({
|
||||
},
|
||||
},
|
||||
organisationGlobalSettings: true,
|
||||
organisationClaim: true,
|
||||
subscription: true,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -71,10 +64,6 @@ export const createOrganisationMemberInvites = async ({
|
||||
throw new AppError(AppErrorCode.NOT_FOUND);
|
||||
}
|
||||
|
||||
const { organisationClaim } = organisation;
|
||||
|
||||
const subscription = validateIfSubscriptionIsRequired(organisation.subscription);
|
||||
|
||||
const currentOrganisationMemberRole = await getMemberOrganisationRole({
|
||||
organisationId: organisation.id,
|
||||
reference: {
|
||||
@@ -120,19 +109,6 @@ export const createOrganisationMemberInvites = async ({
|
||||
}),
|
||||
);
|
||||
|
||||
const numberOfCurrentMembers = organisation.members.length;
|
||||
const numberOfCurrentInvites = organisation.invites.length;
|
||||
const numberOfNewInvites = organisationMemberInvites.length;
|
||||
|
||||
const totalMemberCountWithInvites = numberOfCurrentMembers + numberOfCurrentInvites + numberOfNewInvites;
|
||||
|
||||
// Enforce the seat cap and sync billing for seat based plans.
|
||||
if (subscription) {
|
||||
await assertMemberCountWithinCap(subscription, organisationClaim, totalMemberCountWithInvites);
|
||||
|
||||
await syncMemberCountWithStripeSeatPlan(subscription, organisationClaim, totalMemberCountWithInvites);
|
||||
}
|
||||
|
||||
await prisma.organisationMemberInvite.createMany({
|
||||
data: organisationMemberInvites,
|
||||
});
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import path from 'node:path';
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { FontLibrary } from '@documenso/skia-canvas';
|
||||
import type { Recipient } from '@prisma/client';
|
||||
import { FieldType } from '@prisma/client';
|
||||
import { FontLibrary } from 'skia-canvas';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
/**
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
import '../konva/skia-backend';
|
||||
|
||||
import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
|
||||
import type { Canvas } from '@documenso/skia-canvas';
|
||||
import Konva from 'konva';
|
||||
import type { Canvas } from 'skia-canvas';
|
||||
|
||||
import { renderField } from '../../universal/field-renderer/render-field';
|
||||
import { ensureFontLibrary } from './helpers';
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
// sort-imports-ignore
|
||||
import '../konva/skia-backend';
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import type { Canvas } from '@documenso/skia-canvas';
|
||||
import { Image as SkiaImage } from '@documenso/skia-canvas';
|
||||
import type { I18n } from '@lingui/core';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import type { DocumentMeta, Envelope, RecipientRole } from '@prisma/client';
|
||||
import Konva from 'konva';
|
||||
import 'konva/skia-backend';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import type { DateTimeFormatOptions } from 'luxon';
|
||||
import { DateTime } from 'luxon';
|
||||
import type { Canvas } from 'skia-canvas';
|
||||
import { Image as SkiaImage } from 'skia-canvas';
|
||||
import { match, P } from 'ts-pattern';
|
||||
import { UAParser } from 'ua-parser-js';
|
||||
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
// sort-imports-ignore
|
||||
import '../konva/skia-backend';
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import type { Canvas } from '@documenso/skia-canvas';
|
||||
import { Image as SkiaImage } from '@documenso/skia-canvas';
|
||||
import type { I18n } from '@lingui/core';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import type { Field, RecipientRole, Signature } from '@prisma/client';
|
||||
import { SigningStatus } from '@prisma/client';
|
||||
import Konva from 'konva';
|
||||
import 'konva/skia-backend';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { DateTime } from 'luxon';
|
||||
import type { Canvas } from 'skia-canvas';
|
||||
import { Image as SkiaImage } from 'skia-canvas';
|
||||
import { UAParser } from 'ua-parser-js';
|
||||
import { renderSVG } from 'uqr';
|
||||
|
||||
|
||||
@@ -84,13 +84,13 @@ export const syncSubscriptionRateLimit = createRateLimit({
|
||||
|
||||
export const apiV1RateLimit = createRateLimit({
|
||||
action: 'api.v1',
|
||||
max: 100,
|
||||
max: 1000,
|
||||
window: '1m',
|
||||
});
|
||||
|
||||
export const apiV2RateLimit = createRateLimit({
|
||||
action: 'api.v2',
|
||||
max: 100,
|
||||
max: 1000,
|
||||
window: '1m',
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { DocumentSigningOrder, EnvelopeType, SigningStatus } from '@prisma/client';
|
||||
import { DocumentSigningOrder, EnvelopeType, RecipientRole, SigningStatus } from '@prisma/client';
|
||||
|
||||
export type GetIsRecipientTurnOptions = {
|
||||
token: string;
|
||||
@@ -38,6 +38,11 @@ export async function getIsRecipientsTurnToSign({ token }: GetIsRecipientTurnOpt
|
||||
}
|
||||
|
||||
for (let i = 0; i < currentRecipientIndex; i++) {
|
||||
// CC recipients have no action to take, so they can never block the flow.
|
||||
if (recipients[i].role === RecipientRole.CC) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (recipients[i].signingStatus !== SigningStatus.SIGNED) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { EnvelopeType } from '@prisma/client';
|
||||
import { EnvelopeType, RecipientRole } from '@prisma/client';
|
||||
|
||||
import { mapDocumentIdToSecondaryId } from '../../utils/envelope';
|
||||
|
||||
@@ -16,6 +16,11 @@ export const getNextPendingRecipient = async ({
|
||||
type: EnvelopeType.DOCUMENT,
|
||||
secondaryId: mapDocumentIdToSecondaryId(documentId),
|
||||
},
|
||||
// CC recipients are informational only and never take part in signing,
|
||||
// so they must never be offered as the next pending recipient.
|
||||
role: {
|
||||
not: RecipientRole.CC,
|
||||
},
|
||||
},
|
||||
orderBy: [
|
||||
{
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { buildTeamWhereQuery } from '../../utils/teams';
|
||||
|
||||
export type GetTeamEmailByEmailOptions = {
|
||||
email: string;
|
||||
};
|
||||
|
||||
export const getTeamEmailByEmail = async ({ email }: GetTeamEmailByEmailOptions) => {
|
||||
return await prisma.teamEmail.findFirst({
|
||||
where: {
|
||||
email,
|
||||
},
|
||||
include: {
|
||||
team: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
url: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const getTeamWithEmail = async ({ userId, teamUrl }: { userId: number; teamUrl: string }) => {
|
||||
return await prisma.team.findFirstOrThrow({
|
||||
where: {
|
||||
...buildTeamWhereQuery({ teamId: undefined, userId }),
|
||||
url: teamUrl,
|
||||
},
|
||||
include: {
|
||||
teamEmail: true,
|
||||
emailVerification: true,
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -15,12 +15,12 @@ export type GetTeamsOptions = {
|
||||
};
|
||||
|
||||
export const ZGetTeamsResponseSchema = TeamSchema.extend({
|
||||
teamRole: z.nativeEnum(TeamMemberRole),
|
||||
currentTeamRole: z.nativeEnum(TeamMemberRole),
|
||||
}).array();
|
||||
|
||||
export type TGetTeamsResponse = z.infer<typeof ZGetTeamsResponseSchema>;
|
||||
|
||||
export const getTeams = async ({ userId, teamId }: GetTeamsOptions) => {
|
||||
export const getTeams = async ({ userId, teamId }: GetTeamsOptions): Promise<TGetTeamsResponse> => {
|
||||
const teams = await prisma.team.findMany({
|
||||
where: buildTeamWhereQuery({ teamId, userId }),
|
||||
include: {
|
||||
|
||||
@@ -40,6 +40,7 @@ import {
|
||||
extractDocumentAuthMethods,
|
||||
} from '../../utils/document-auth';
|
||||
import { mapSecondaryIdToTemplateId } from '../../utils/envelope';
|
||||
import { getRecipientsWithMissingFields } from '../../utils/recipients';
|
||||
import { sendDocument } from '../document/send-document';
|
||||
import { validateFieldAuth } from '../document/validate-field-auth';
|
||||
import { incrementDocumentId } from '../envelope/increment-id';
|
||||
@@ -172,6 +173,17 @@ export const createDocumentFromDirectTemplate = async ({
|
||||
});
|
||||
}
|
||||
|
||||
const recipientsWithMissingFields = getRecipientsWithMissingFields(
|
||||
recipients,
|
||||
recipients.flatMap((recipient) => recipient.fields),
|
||||
);
|
||||
|
||||
if (recipientsWithMissingFields.length > 0) {
|
||||
throw new AppError(AppErrorCode.MISSING_SIGNATURE_FIELD, {
|
||||
message: 'One or more signers on this direct template are missing a signature field',
|
||||
});
|
||||
}
|
||||
|
||||
if (directTemplateEnvelope.updatedAt.getTime() !== templateUpdatedAt.getTime()) {
|
||||
throw new AppError(AppErrorCode.INVALID_REQUEST, { message: 'Template no longer matches' });
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||
import { jobs } from '../../jobs/client';
|
||||
import { deleteOrganisation } from '../organisation/delete-organisation';
|
||||
|
||||
export type DeleteUserOptions = {
|
||||
@@ -59,6 +60,13 @@ export const deleteUser = async ({ id }: DeleteUserOptions) => {
|
||||
})),
|
||||
);
|
||||
|
||||
// Organisations the user is a member of (but not owner). Owned organisations
|
||||
// are fully torn down below (including subscription cancellation), so only
|
||||
// these need a seat sync after the user's memberships cascade away.
|
||||
const memberOrganisationIds = user.organisationMember
|
||||
.filter((member) => member.organisation.ownerUserId !== user.id)
|
||||
.map((member) => member.organisationId);
|
||||
|
||||
// For organisations the user owns - fully tear them down (orphan envelopes,
|
||||
// delete the organisation, and cancel any Stripe subscription). Without this
|
||||
// the organisations would only cascade away when the user row is deleted,
|
||||
@@ -82,9 +90,21 @@ export const deleteUser = async ({ id }: DeleteUserOptions) => {
|
||||
}),
|
||||
);
|
||||
|
||||
return await prisma.user.delete({
|
||||
const deletedUser = await prisma.user.delete({
|
||||
where: {
|
||||
id: user.id,
|
||||
},
|
||||
});
|
||||
|
||||
// The user's memberships were cascade-deleted with the user row — queue a
|
||||
// seat sync for each organisation they belonged to so the Stripe quantity
|
||||
// trues down to the new member count (no proration, no credit).
|
||||
for (const organisationId of memberOrganisationIds) {
|
||||
await jobs.triggerJob({
|
||||
name: 'internal.sync-organisation-seats',
|
||||
payload: { organisationId },
|
||||
});
|
||||
}
|
||||
|
||||
return deletedUser;
|
||||
};
|
||||
|
||||
@@ -3,6 +3,7 @@ import { DateTime } from 'luxon';
|
||||
|
||||
import { EMAIL_VERIFICATION_STATE, USER_SIGNUP_VERIFICATION_TOKEN_IDENTIFIER } from '../../constants/email';
|
||||
import { jobsClient } from '../../jobs/client';
|
||||
import { getMostRecentEmailVerificationToken } from './get-most-recent-email-verification-token';
|
||||
|
||||
export type VerifyEmailProps = {
|
||||
token: string;
|
||||
@@ -36,13 +37,8 @@ export const verifyEmail = async ({ token }: VerifyEmailProps) => {
|
||||
const valid = verificationToken.expires > new Date();
|
||||
|
||||
if (!valid) {
|
||||
const mostRecentToken = await prisma.verificationToken.findFirst({
|
||||
where: {
|
||||
userId: verificationToken.userId,
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: 'desc',
|
||||
},
|
||||
const mostRecentToken = await getMostRecentEmailVerificationToken({
|
||||
userId: verificationToken.userId,
|
||||
});
|
||||
|
||||
// If there isn't a recent token or it's older than 1 hour, send a new token
|
||||
@@ -80,6 +76,7 @@ export const verifyEmail = async ({ token }: VerifyEmailProps) => {
|
||||
prisma.verificationToken.updateMany({
|
||||
where: {
|
||||
userId: verificationToken.userId,
|
||||
identifier: USER_SIGNUP_VERIFICATION_TOKEN_IDENTIFIER,
|
||||
},
|
||||
data: {
|
||||
completed: true,
|
||||
@@ -89,6 +86,7 @@ export const verifyEmail = async ({ token }: VerifyEmailProps) => {
|
||||
prisma.verificationToken.deleteMany({
|
||||
where: {
|
||||
userId: verificationToken.userId,
|
||||
identifier: USER_SIGNUP_VERIFICATION_TOKEN_IDENTIFIER,
|
||||
expires: {
|
||||
lt: new Date(),
|
||||
},
|
||||
|
||||
@@ -1,24 +1,26 @@
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { TEAM_MEMBER_ROLE_PERMISSIONS_MAP } from '../../constants/teams';
|
||||
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||
import { buildTeamWhereQuery } from '../../utils/teams';
|
||||
|
||||
export const getWebhooksByTeamId = async (teamId: number, userId: number) => {
|
||||
const team = await prisma.team.findFirst({
|
||||
where: buildTeamWhereQuery({
|
||||
teamId,
|
||||
userId,
|
||||
roles: TEAM_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_TEAM'],
|
||||
}),
|
||||
});
|
||||
|
||||
if (!team) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'Team not found',
|
||||
});
|
||||
}
|
||||
|
||||
return await prisma.webhook.findMany({
|
||||
where: {
|
||||
team: {
|
||||
id: teamId,
|
||||
teamGroups: {
|
||||
some: {
|
||||
organisationGroup: {
|
||||
organisationGroupMembers: {
|
||||
some: {
|
||||
organisationMember: {
|
||||
userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
teamId,
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: 'desc',
|
||||
|
||||
+723
-315
File diff suppressed because it is too large
Load Diff
+723
-315
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user