Files
documenso/packages/app-tests/e2e/settings/unified-settings.spec.ts

555 lines
25 KiB
TypeScript

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