feat: unify settings (#3128)

This commit is contained in:
David Nguyen
2026-08-09 16:00:55 +10:00
committed by GitHub
parent f0ab7c112e
commit d6cf3fec4b
120 changed files with 4181 additions and 1859 deletions
@@ -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();
});
@@ -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);
});
});
@@ -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
@@ -93,3 +93,34 @@ test('[PUBLIC_PROFILE]: create team profile', async ({ page }) => {
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`);
});
});
@@ -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`);
});
@@ -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();
@@ -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);
});
});