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
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`); }); });