mirror of
https://github.com/documenso/documenso.git
synced 2026-08-20 13:31:50 +10:00
feat: unify settings (#3128)
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user