mirror of
https://github.com/documenso/documenso.git
synced 2026-08-21 22:11:49 +10:00
555 lines
25 KiB
TypeScript
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`);
|
|
});
|
|
});
|