mirror of
https://github.com/documenso/documenso.git
synced 2026-08-15 02:53:32 +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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import { useMatches } from 'react-router';
|
||||
|
||||
/**
|
||||
* The layout treatment a route wants from its parent layout(s).
|
||||
*
|
||||
* - `'settings'` — the full-height unified settings layout: no centered page
|
||||
* container, a full-width app header, and a viewport-height flex column so the
|
||||
* settings shell can fill the available space and scroll internally.
|
||||
* - `null` — the default layout (centered `<PageContainer />`, normal flow).
|
||||
*/
|
||||
export type LayoutMode = 'settings' | null;
|
||||
|
||||
/**
|
||||
* Typed route `handle` export. Controls layout rendering.
|
||||
*
|
||||
* - `hideAppHeader` — tells the parent layout to skip rendering `<AppHeader />`.
|
||||
* - `layoutMode` — selects the layout treatment the parent layout(s) apply. See
|
||||
* {@link LayoutMode}.
|
||||
*/
|
||||
export type RouteHandle = {
|
||||
hideAppHeader?: boolean;
|
||||
layoutMode?: LayoutMode;
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns layout flags from the deepest matching route that sets any.
|
||||
* Layouts call this to decide whether to render certain elements.
|
||||
*/
|
||||
export function useChildRouteFlags(): { hideAppHeader: boolean; layoutMode: LayoutMode } {
|
||||
const matches = useMatches();
|
||||
|
||||
let hideAppHeader = false;
|
||||
let layoutMode: LayoutMode = null;
|
||||
|
||||
// Walk from deepest match backward so the leaf route wins per flag.
|
||||
for (let i = matches.length - 1; i >= 0; i--) {
|
||||
const handle = matches[i].handle;
|
||||
|
||||
if (handle == null || typeof handle !== 'object') {
|
||||
continue;
|
||||
}
|
||||
|
||||
const h = handle as RouteHandle;
|
||||
|
||||
if (layoutMode === null && h.layoutMode) {
|
||||
layoutMode = h.layoutMode;
|
||||
}
|
||||
|
||||
if (!hideAppHeader && h.hideAppHeader) {
|
||||
hideAppHeader = true;
|
||||
}
|
||||
}
|
||||
|
||||
return { hideAppHeader, layoutMode };
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export const PREFERRED_TEAM_URL_COOKIE = 'preferred-team-url';
|
||||
@@ -1,37 +0,0 @@
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { buildTeamWhereQuery } from '../../utils/teams';
|
||||
|
||||
export type GetTeamEmailByEmailOptions = {
|
||||
email: string;
|
||||
};
|
||||
|
||||
export const getTeamEmailByEmail = async ({ email }: GetTeamEmailByEmailOptions) => {
|
||||
return await prisma.teamEmail.findFirst({
|
||||
where: {
|
||||
email,
|
||||
},
|
||||
include: {
|
||||
team: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
url: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const getTeamWithEmail = async ({ userId, teamUrl }: { userId: number; teamUrl: string }) => {
|
||||
return await prisma.team.findFirstOrThrow({
|
||||
where: {
|
||||
...buildTeamWhereQuery({ teamId: undefined, userId }),
|
||||
url: teamUrl,
|
||||
},
|
||||
include: {
|
||||
teamEmail: true,
|
||||
emailVerification: true,
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -15,12 +15,12 @@ export type GetTeamsOptions = {
|
||||
};
|
||||
|
||||
export const ZGetTeamsResponseSchema = TeamSchema.extend({
|
||||
teamRole: z.nativeEnum(TeamMemberRole),
|
||||
currentTeamRole: z.nativeEnum(TeamMemberRole),
|
||||
}).array();
|
||||
|
||||
export type TGetTeamsResponse = z.infer<typeof ZGetTeamsResponseSchema>;
|
||||
|
||||
export const getTeams = async ({ userId, teamId }: GetTeamsOptions) => {
|
||||
export const getTeams = async ({ userId, teamId }: GetTeamsOptions): Promise<TGetTeamsResponse> => {
|
||||
const teams = await prisma.team.findMany({
|
||||
where: buildTeamWhereQuery({ teamId, userId }),
|
||||
include: {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { DateTime } from 'luxon';
|
||||
|
||||
import { EMAIL_VERIFICATION_STATE, USER_SIGNUP_VERIFICATION_TOKEN_IDENTIFIER } from '../../constants/email';
|
||||
import { jobsClient } from '../../jobs/client';
|
||||
import { getMostRecentEmailVerificationToken } from './get-most-recent-email-verification-token';
|
||||
|
||||
export type VerifyEmailProps = {
|
||||
token: string;
|
||||
@@ -36,13 +37,8 @@ export const verifyEmail = async ({ token }: VerifyEmailProps) => {
|
||||
const valid = verificationToken.expires > new Date();
|
||||
|
||||
if (!valid) {
|
||||
const mostRecentToken = await prisma.verificationToken.findFirst({
|
||||
where: {
|
||||
userId: verificationToken.userId,
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: 'desc',
|
||||
},
|
||||
const mostRecentToken = await getMostRecentEmailVerificationToken({
|
||||
userId: verificationToken.userId,
|
||||
});
|
||||
|
||||
// If there isn't a recent token or it's older than 1 hour, send a new token
|
||||
@@ -80,6 +76,7 @@ export const verifyEmail = async ({ token }: VerifyEmailProps) => {
|
||||
prisma.verificationToken.updateMany({
|
||||
where: {
|
||||
userId: verificationToken.userId,
|
||||
identifier: USER_SIGNUP_VERIFICATION_TOKEN_IDENTIFIER,
|
||||
},
|
||||
data: {
|
||||
completed: true,
|
||||
@@ -89,6 +86,7 @@ export const verifyEmail = async ({ token }: VerifyEmailProps) => {
|
||||
prisma.verificationToken.deleteMany({
|
||||
where: {
|
||||
userId: verificationToken.userId,
|
||||
identifier: USER_SIGNUP_VERIFICATION_TOKEN_IDENTIFIER,
|
||||
expires: {
|
||||
lt: new Date(),
|
||||
},
|
||||
|
||||
@@ -1,24 +1,26 @@
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { TEAM_MEMBER_ROLE_PERMISSIONS_MAP } from '../../constants/teams';
|
||||
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||
import { buildTeamWhereQuery } from '../../utils/teams';
|
||||
|
||||
export const getWebhooksByTeamId = async (teamId: number, userId: number) => {
|
||||
const team = await prisma.team.findFirst({
|
||||
where: buildTeamWhereQuery({
|
||||
teamId,
|
||||
userId,
|
||||
roles: TEAM_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_TEAM'],
|
||||
}),
|
||||
});
|
||||
|
||||
if (!team) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'Team not found',
|
||||
});
|
||||
}
|
||||
|
||||
return await prisma.webhook.findMany({
|
||||
where: {
|
||||
team: {
|
||||
id: teamId,
|
||||
teamGroups: {
|
||||
some: {
|
||||
organisationGroup: {
|
||||
organisationGroupMembers: {
|
||||
some: {
|
||||
organisationMember: {
|
||||
userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
teamId,
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: 'desc',
|
||||
|
||||
@@ -0,0 +1,298 @@
|
||||
import type { MessageDescriptor } from '@lingui/core';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import type { OrganisationMemberRole, TeamMemberRole } from '@prisma/client';
|
||||
import {
|
||||
BracesIcon,
|
||||
Building2Icon,
|
||||
CreditCardIcon,
|
||||
Globe2Icon,
|
||||
GroupIcon,
|
||||
LockIcon,
|
||||
MailboxIcon,
|
||||
Settings2Icon,
|
||||
SettingsIcon,
|
||||
ShieldCheckIcon,
|
||||
UserIcon,
|
||||
Users2Icon,
|
||||
WebhookIcon,
|
||||
} from 'lucide-react';
|
||||
import type { ComponentType } from 'react';
|
||||
import { FaUsers } from 'react-icons/fa6';
|
||||
import { IS_BILLING_ENABLED } from '../constants/app';
|
||||
import { canExecuteOrganisationAction } from './organisations';
|
||||
import { canExecuteTeamAction } from './teams';
|
||||
|
||||
export type SettingsNavScope = 'organisation' | 'team' | 'account';
|
||||
|
||||
export type SettingsNavItem = {
|
||||
key: string;
|
||||
path: string;
|
||||
label: MessageDescriptor;
|
||||
icon?: ComponentType<{ className?: string }>;
|
||||
isSubNav?: boolean;
|
||||
isSubNavParent?: boolean;
|
||||
};
|
||||
|
||||
export type SettingsNavGroup = {
|
||||
scope: SettingsNavScope;
|
||||
items: SettingsNavItem[];
|
||||
};
|
||||
|
||||
export type SettingsNavGroups = {
|
||||
organisation: SettingsNavGroup | null;
|
||||
team: SettingsNavGroup | null;
|
||||
account: SettingsNavGroup;
|
||||
};
|
||||
|
||||
export type GetSettingsNavGroupsArgs = {
|
||||
organisation: {
|
||||
url: string;
|
||||
currentOrganisationRole: OrganisationMemberRole;
|
||||
organisationClaim: { flags: { emailDomains?: boolean; authenticationPortal?: boolean } };
|
||||
} | null;
|
||||
team: {
|
||||
url: string;
|
||||
currentTeamRole: TeamMemberRole;
|
||||
} | null;
|
||||
hasManageableBillingOrgs: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Build the nav-group structure for the unified settings sidebar.
|
||||
*
|
||||
* Pure data helper — given a current organisation, optional current team, and the billing-enabled
|
||||
* flag, returns the items that should appear in each scope group. Groups the user has no manage
|
||||
* permission for are returned as `null` (not empty arrays) so consumers can branch on visibility.
|
||||
*
|
||||
* Item ordering, claim-flag gating, and which sections are scope-specific are encoded here as the
|
||||
* single source of truth for the unified-settings sidebar.
|
||||
*/
|
||||
export const getSettingsNavGroups = ({
|
||||
organisation,
|
||||
team,
|
||||
hasManageableBillingOrgs,
|
||||
}: GetSettingsNavGroupsArgs): SettingsNavGroups => {
|
||||
const isBillingEnabled = IS_BILLING_ENABLED();
|
||||
|
||||
const canManageOrg =
|
||||
organisation !== null && canExecuteOrganisationAction('MANAGE_ORGANISATION', organisation.currentOrganisationRole);
|
||||
|
||||
const canManageTeam = team !== null && canExecuteTeamAction('MANAGE_TEAM', team.currentTeamRole);
|
||||
|
||||
const orgGroup: SettingsNavGroup | null = canManageOrg
|
||||
? {
|
||||
scope: 'organisation',
|
||||
items: [
|
||||
{
|
||||
key: 'general',
|
||||
path: `/o/${organisation.url}/settings/general`,
|
||||
label: msg`General`,
|
||||
icon: Building2Icon,
|
||||
},
|
||||
{
|
||||
key: 'preferences',
|
||||
path: `/o/${organisation.url}/settings/document`,
|
||||
label: msg`Preferences`,
|
||||
icon: Settings2Icon,
|
||||
isSubNavParent: true,
|
||||
},
|
||||
{
|
||||
key: 'preferences-document',
|
||||
path: `/o/${organisation.url}/settings/document`,
|
||||
label: msg`General`,
|
||||
isSubNav: true,
|
||||
},
|
||||
{
|
||||
key: 'preferences-branding',
|
||||
path: `/o/${organisation.url}/settings/branding`,
|
||||
label: msg`Branding`,
|
||||
isSubNav: true,
|
||||
},
|
||||
{
|
||||
key: 'preferences-email',
|
||||
path: `/o/${organisation.url}/settings/email`,
|
||||
label: msg`Email`,
|
||||
isSubNav: true,
|
||||
},
|
||||
{
|
||||
key: 'preferences-reminders',
|
||||
path: `/o/${organisation.url}/settings/reminders`,
|
||||
label: msg`Reminders`,
|
||||
isSubNav: true,
|
||||
},
|
||||
{
|
||||
key: 'preferences-certificates',
|
||||
path: `/o/${organisation.url}/settings/certificates`,
|
||||
label: msg`Certificates`,
|
||||
isSubNav: true,
|
||||
},
|
||||
...(isBillingEnabled && organisation.organisationClaim.flags.emailDomains
|
||||
? [
|
||||
{
|
||||
key: 'email-domains',
|
||||
path: `/o/${organisation.url}/settings/email-domains`,
|
||||
label: msg`Email Domains`,
|
||||
icon: MailboxIcon,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
key: 'teams',
|
||||
path: `/o/${organisation.url}/settings/teams`,
|
||||
label: msg`Teams`,
|
||||
icon: FaUsers,
|
||||
},
|
||||
{
|
||||
key: 'members',
|
||||
path: `/o/${organisation.url}/settings/members`,
|
||||
label: msg`Members`,
|
||||
icon: Users2Icon,
|
||||
},
|
||||
{
|
||||
key: 'groups',
|
||||
path: `/o/${organisation.url}/settings/groups`,
|
||||
label: msg`Groups`,
|
||||
icon: GroupIcon,
|
||||
},
|
||||
...(isBillingEnabled && organisation.organisationClaim.flags.authenticationPortal
|
||||
? [
|
||||
{
|
||||
key: 'sso',
|
||||
path: `/o/${organisation.url}/settings/sso`,
|
||||
label: msg`SSO`,
|
||||
icon: ShieldCheckIcon,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(isBillingEnabled
|
||||
? [
|
||||
{
|
||||
key: 'billing',
|
||||
path: `/o/${organisation.url}/settings/billing`,
|
||||
label: msg`Billing`,
|
||||
icon: CreditCardIcon,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
],
|
||||
}
|
||||
: null;
|
||||
|
||||
const teamGroup: SettingsNavGroup | null =
|
||||
canManageTeam && team
|
||||
? {
|
||||
scope: 'team',
|
||||
items: [
|
||||
{
|
||||
key: 'general',
|
||||
path: `/t/${team.url}/settings/general`,
|
||||
label: msg`General`,
|
||||
icon: SettingsIcon,
|
||||
},
|
||||
{
|
||||
key: 'preferences',
|
||||
path: `/t/${team.url}/settings/document`,
|
||||
label: msg`Preferences`,
|
||||
icon: Settings2Icon,
|
||||
isSubNavParent: true,
|
||||
},
|
||||
{
|
||||
key: 'preferences-document',
|
||||
path: `/t/${team.url}/settings/document`,
|
||||
label: msg`General`,
|
||||
isSubNav: true,
|
||||
},
|
||||
{
|
||||
key: 'preferences-branding',
|
||||
path: `/t/${team.url}/settings/branding`,
|
||||
label: msg`Branding`,
|
||||
isSubNav: true,
|
||||
},
|
||||
{
|
||||
key: 'preferences-email',
|
||||
path: `/t/${team.url}/settings/email`,
|
||||
label: msg`Email`,
|
||||
isSubNav: true,
|
||||
},
|
||||
{
|
||||
key: 'preferences-reminders',
|
||||
path: `/t/${team.url}/settings/reminders`,
|
||||
label: msg`Reminders`,
|
||||
isSubNav: true,
|
||||
},
|
||||
{
|
||||
key: 'preferences-certificates',
|
||||
path: `/t/${team.url}/settings/certificates`,
|
||||
label: msg`Certificates`,
|
||||
isSubNav: true,
|
||||
},
|
||||
{
|
||||
key: 'members',
|
||||
path: `/t/${team.url}/settings/members`,
|
||||
label: msg`Members`,
|
||||
icon: Users2Icon,
|
||||
},
|
||||
{
|
||||
key: 'groups',
|
||||
path: `/t/${team.url}/settings/groups`,
|
||||
label: msg`Groups`,
|
||||
icon: GroupIcon,
|
||||
},
|
||||
{
|
||||
key: 'public-profile',
|
||||
path: `/t/${team.url}/settings/public-profile`,
|
||||
label: msg`Public Profile`,
|
||||
icon: Globe2Icon,
|
||||
},
|
||||
{
|
||||
key: 'tokens',
|
||||
path: `/t/${team.url}/settings/tokens`,
|
||||
label: msg`API Tokens`,
|
||||
icon: BracesIcon,
|
||||
},
|
||||
{
|
||||
key: 'webhooks',
|
||||
path: `/t/${team.url}/settings/webhooks`,
|
||||
label: msg`Webhooks`,
|
||||
icon: WebhookIcon,
|
||||
},
|
||||
],
|
||||
}
|
||||
: null;
|
||||
|
||||
const accountGroup: SettingsNavGroup = {
|
||||
scope: 'account',
|
||||
items: [
|
||||
{
|
||||
key: 'profile',
|
||||
path: '/settings/profile',
|
||||
label: msg`Profile`,
|
||||
icon: UserIcon,
|
||||
},
|
||||
{
|
||||
key: 'organisations',
|
||||
path: '/settings/organisations',
|
||||
label: msg`Organisations`,
|
||||
icon: Building2Icon,
|
||||
},
|
||||
{
|
||||
key: 'security',
|
||||
path: '/settings/security',
|
||||
label: msg`Security`,
|
||||
icon: LockIcon,
|
||||
},
|
||||
...(IS_BILLING_ENABLED() && hasManageableBillingOrgs
|
||||
? [
|
||||
{
|
||||
key: 'billing',
|
||||
path: '/settings/billing',
|
||||
label: msg`Billing`,
|
||||
icon: CreditCardIcon,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
],
|
||||
};
|
||||
|
||||
return { organisation: orgGroup, team: teamGroup, account: accountGroup };
|
||||
};
|
||||
@@ -0,0 +1,57 @@
|
||||
export type ComputeSwitcherContinuityPathArgs = {
|
||||
currentPath: string;
|
||||
|
||||
/**
|
||||
* Every settings path navigable in the destination scope — pass the destination's
|
||||
* `SettingsNavGroup.items` paths from `getSettingsNavGroups`.
|
||||
*
|
||||
* Sourcing these from the nav builder (rather than a hardcoded list) means the switcher
|
||||
* can only ever land on a page that is actually reachable there: permission and
|
||||
* claim/billing gating are already applied when the group is built.
|
||||
*/
|
||||
destinationPaths: string[];
|
||||
|
||||
/** Where to land when the current section has no equivalent in the destination. */
|
||||
fallbackPath: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Extract the "section" portion of a settings URL. Returns null if not a scoped
|
||||
* settings URL (account settings and non-settings pages have no equivalent to carry over).
|
||||
*
|
||||
* Examples:
|
||||
* /o/acme/settings/members → 'members'
|
||||
* /o/acme/settings → 'general' (bare index redirects to General)
|
||||
* /t/eng/settings/webhooks/42 → 'webhooks'
|
||||
* /o/acme/documents → null
|
||||
* /settings/profile → null
|
||||
*/
|
||||
const parseSettingsSection = (path: string): string | null => {
|
||||
const match = /^\/[ot]\/[^/]+\/settings(?:\/([^/?#]+))?(?:[/?#]|$)/.exec(path);
|
||||
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return match[1] ?? 'general';
|
||||
};
|
||||
|
||||
/**
|
||||
* Compute the destination URL when the user switches organisation or team via the
|
||||
* unified settings sidebar's switcher.
|
||||
*
|
||||
* Rule: stay on the same section if the destination has one; else use the fallback.
|
||||
*/
|
||||
export const computeSwitcherContinuityPath = ({
|
||||
currentPath,
|
||||
destinationPaths,
|
||||
fallbackPath,
|
||||
}: ComputeSwitcherContinuityPathArgs): string => {
|
||||
const currentSection = parseSettingsSection(currentPath);
|
||||
|
||||
if (!currentSection) {
|
||||
return fallbackPath;
|
||||
}
|
||||
|
||||
return destinationPaths.find((path) => parseSettingsSection(path) === currentSection) ?? fallbackPath;
|
||||
};
|
||||
@@ -1,6 +1,9 @@
|
||||
import macrosPlugin from 'vite-plugin-babel-macros';
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
// Transform lingui macros (e.g. `msg`) used by the code under test.
|
||||
plugins: [macrosPlugin()],
|
||||
test: {
|
||||
include: ['**/*.test.ts'],
|
||||
},
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { FindResultResponse } from '@documenso/lib/types/search-params';
|
||||
import { mapEnvelopesToDocumentMany } from '@documenso/lib/utils/document';
|
||||
import { maskRecipientTokensForDocument } from '@documenso/lib/utils/mask-recipient-tokens-for-document';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import type { Envelope, Prisma } from '@prisma/client';
|
||||
import { DocumentStatus, EnvelopeType, RecipientRole } from '@prisma/client';
|
||||
@@ -106,12 +105,15 @@ export const findInbox = async ({ userId, page = 1, perPage = 10, orderBy }: Fin
|
||||
}),
|
||||
]);
|
||||
|
||||
const maskedData = data.map((document) =>
|
||||
maskRecipientTokensForDocument({
|
||||
document,
|
||||
user,
|
||||
}),
|
||||
);
|
||||
// Not using the maskRecipientTokensForDocument helper here because it needs a
|
||||
// rework due to recipients vs Recipient.
|
||||
const maskedData = data.map((document) => ({
|
||||
...document,
|
||||
recipients: document.recipients.map((recipient) => ({
|
||||
...recipient,
|
||||
token: recipient.email === user.email ? recipient.token : '',
|
||||
})),
|
||||
}));
|
||||
|
||||
return {
|
||||
data: maskedData,
|
||||
|
||||
@@ -12,7 +12,7 @@ import { ZCreateSubscriptionRequestSchema } from './create-subscription.types';
|
||||
export const createSubscriptionRoute = authenticatedProcedure
|
||||
.input(ZCreateSubscriptionRequestSchema)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { organisationId, priceId, isPersonalLayoutMode } = input;
|
||||
const { organisationId, priceId } = input;
|
||||
|
||||
ctx.logger.info({
|
||||
input: {
|
||||
@@ -70,9 +70,7 @@ export const createSubscriptionRoute = authenticatedProcedure
|
||||
});
|
||||
}
|
||||
|
||||
const returnUrl = isPersonalLayoutMode
|
||||
? `${NEXT_PUBLIC_WEBAPP_URL()}/settings/billing-personal`
|
||||
: `${NEXT_PUBLIC_WEBAPP_URL()}/o/${organisation.url}/settings/billing`;
|
||||
const returnUrl = `${NEXT_PUBLIC_WEBAPP_URL()}/o/${organisation.url}/settings/billing`;
|
||||
|
||||
const redirectUrl = await createCheckoutSession({
|
||||
customerId,
|
||||
|
||||
@@ -3,5 +3,4 @@ import { z } from 'zod';
|
||||
export const ZCreateSubscriptionRequestSchema = z.object({
|
||||
organisationId: z.string().describe('The organisation to create the subscription for'),
|
||||
priceId: z.string().describe('The price to create the subscription for'),
|
||||
isPersonalLayoutMode: z.boolean().optional(),
|
||||
});
|
||||
|
||||
@@ -12,7 +12,7 @@ import { ZManageSubscriptionRequestSchema } from './manage-subscription.types';
|
||||
export const manageSubscriptionRoute = authenticatedProcedure
|
||||
.input(ZManageSubscriptionRequestSchema)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { organisationId, isPersonalLayoutMode } = input;
|
||||
const { organisationId } = input;
|
||||
|
||||
ctx.logger.info({
|
||||
input: {
|
||||
@@ -93,9 +93,7 @@ export const manageSubscriptionRoute = authenticatedProcedure
|
||||
});
|
||||
}
|
||||
|
||||
const returnUrl = isPersonalLayoutMode
|
||||
? `${NEXT_PUBLIC_WEBAPP_URL()}/settings/billing-personal`
|
||||
: `${NEXT_PUBLIC_WEBAPP_URL()}/o/${organisation.url}/settings/billing`;
|
||||
const returnUrl = `${NEXT_PUBLIC_WEBAPP_URL()}/o/${organisation.url}/settings/billing`;
|
||||
|
||||
const redirectUrl = await getPortalSession({
|
||||
customerId,
|
||||
|
||||
@@ -2,5 +2,4 @@ import { z } from 'zod';
|
||||
|
||||
export const ZManageSubscriptionRequestSchema = z.object({
|
||||
organisationId: z.string().describe('The organisation to manage the subscription for'),
|
||||
isPersonalLayoutMode: z.boolean().optional(),
|
||||
});
|
||||
|
||||
@@ -4,6 +4,7 @@ import { validateFieldAuth } from '@documenso/lib/server-only/document/validate-
|
||||
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
|
||||
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
|
||||
import { extractFieldInsertionValues } from '@documenso/lib/utils/envelope-signing';
|
||||
import { assertRecipientNotExpired } from '@documenso/lib/utils/recipients';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { DocumentStatus, FieldType, RecipientRole, SigningStatus } from '@prisma/client';
|
||||
import { match } from 'ts-pattern';
|
||||
@@ -108,6 +109,12 @@ export const signEnvelopeFieldRoute = procedure
|
||||
});
|
||||
}
|
||||
|
||||
// Both are checked because an assistant may insert values into a field belonging to
|
||||
// another recipient, and neither signing window may have closed. For every other
|
||||
// role these reference the same recipient.
|
||||
assertRecipientNotExpired(recipient);
|
||||
assertRecipientNotExpired(field.recipient);
|
||||
|
||||
if (recipient.signingStatus === SigningStatus.SIGNED || field.recipient.signingStatus === SigningStatus.SIGNED) {
|
||||
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||
message: `Recipient ${recipient.id} has already signed`,
|
||||
|
||||
@@ -124,11 +124,10 @@ export const updateOrganisationSettingsRoute = authenticatedProcedure
|
||||
const isChangingIncludeSenderDetails =
|
||||
includeSenderDetails !== undefined && includeSenderDetails !== currentIncludeSenderDetails;
|
||||
|
||||
if (isPersonalOrganisation && isChangingIncludeSenderDetails) {
|
||||
throw new AppError(AppErrorCode.INVALID_BODY, {
|
||||
message: 'Personal organisations cannot update the sender details',
|
||||
});
|
||||
}
|
||||
// Personal teams cannot change the sender details — drop the field (no-op)
|
||||
// instead of rejecting the whole update.
|
||||
const derivedIncludeSenderDetails =
|
||||
isPersonalOrganisation && isChangingIncludeSenderDetails ? undefined : includeSenderDetails;
|
||||
|
||||
// Sanitize custom branding CSS at write time so we can store the safe
|
||||
// result and skip per-render sanitisation. Warnings are returned to the
|
||||
@@ -160,7 +159,7 @@ export const updateOrganisationSettingsRoute = authenticatedProcedure
|
||||
documentLanguage,
|
||||
documentTimezone,
|
||||
documentDateFormat,
|
||||
includeSenderDetails,
|
||||
includeSenderDetails: derivedIncludeSenderDetails,
|
||||
includeSigningCertificate,
|
||||
includeAuditLog,
|
||||
typedSignatureEnabled,
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { isTokenExpired } from '@documenso/lib/utils/token-verification';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { procedure } from '../trpc';
|
||||
import {
|
||||
ZCompleteTeamEmailVerificationRequestSchema,
|
||||
ZCompleteTeamEmailVerificationResponseSchema,
|
||||
} from './complete-team-email-verification.types';
|
||||
|
||||
/**
|
||||
* Unauthenicated procedure.
|
||||
*/
|
||||
export const completeTeamEmailVerificationRoute = procedure
|
||||
.input(ZCompleteTeamEmailVerificationRequestSchema)
|
||||
.output(ZCompleteTeamEmailVerificationResponseSchema)
|
||||
.mutation(async ({ input }) => {
|
||||
const { token } = input;
|
||||
|
||||
const teamEmailVerification = await prisma.teamEmailVerification.findUnique({
|
||||
where: {
|
||||
token,
|
||||
},
|
||||
include: {
|
||||
team: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!teamEmailVerification || isTokenExpired(teamEmailVerification.expiresAt)) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'Verification token is invalid or has expired.',
|
||||
});
|
||||
}
|
||||
|
||||
const { team, email, name } = teamEmailVerification;
|
||||
|
||||
if (teamEmailVerification.completed) {
|
||||
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||
message: 'Team email verification has already been completed.',
|
||||
});
|
||||
}
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
const existingTeamEmail = await tx.teamEmail.findFirst({
|
||||
where: {
|
||||
OR: [{ email }, { teamId: team.id }],
|
||||
},
|
||||
});
|
||||
|
||||
if (existingTeamEmail) {
|
||||
throw new AppError(AppErrorCode.ALREADY_EXISTS, {
|
||||
message: 'Email already taken by another team, or this team already has an email.',
|
||||
});
|
||||
}
|
||||
|
||||
await tx.teamEmailVerification.updateMany({
|
||||
where: {
|
||||
teamId: team.id,
|
||||
email,
|
||||
},
|
||||
data: {
|
||||
completed: true,
|
||||
},
|
||||
});
|
||||
|
||||
await tx.teamEmailVerification.deleteMany({
|
||||
where: {
|
||||
teamId: team.id,
|
||||
expiresAt: {
|
||||
lt: new Date(),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await tx.teamEmail.create({
|
||||
data: {
|
||||
teamId: team.id,
|
||||
email,
|
||||
name,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const ZCompleteTeamEmailVerificationRequestSchema = z.object({
|
||||
token: z.string().min(1),
|
||||
});
|
||||
|
||||
export const ZCompleteTeamEmailVerificationResponseSchema = z.void();
|
||||
|
||||
export type TCompleteTeamEmailVerificationRequest = z.infer<typeof ZCompleteTeamEmailVerificationRequestSchema>;
|
||||
|
||||
export type TCompleteTeamEmailVerificationResponse = z.infer<typeof ZCompleteTeamEmailVerificationResponseSchema>;
|
||||
@@ -1,11 +1,11 @@
|
||||
import { createTeamEmailVerification } from '@documenso/lib/server-only/team/create-team-email-verification';
|
||||
import { deleteTeamEmail } from '@documenso/lib/server-only/team/delete-team-email';
|
||||
import { deleteTeamEmailVerification } from '@documenso/lib/server-only/team/delete-team-email-verification';
|
||||
import { getTeamEmailByEmail } from '@documenso/lib/server-only/team/get-team-email-by-email';
|
||||
import { resendTeamEmailVerification } from '@documenso/lib/server-only/team/resend-team-email-verification';
|
||||
import { updateTeamEmail } from '@documenso/lib/server-only/team/update-team-email';
|
||||
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { authenticatedProcedure, router } from '../trpc';
|
||||
import { completeTeamEmailVerificationRoute } from './complete-team-email-verification';
|
||||
import { createTeamRoute } from './create-team';
|
||||
import { createTeamGroupsRoute } from './create-team-groups';
|
||||
import { createTeamMembersRoute } from './create-team-members';
|
||||
@@ -58,7 +58,22 @@ export const teamRouter = router({
|
||||
// Todo: Refactor into routes.
|
||||
email: {
|
||||
get: authenticatedProcedure.query(async ({ ctx }) => {
|
||||
return await getTeamEmailByEmail({ email: ctx.user.email });
|
||||
const teamEmail = await prisma.teamEmail.findUnique({
|
||||
where: {
|
||||
email: ctx.user.email,
|
||||
},
|
||||
include: {
|
||||
team: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
url: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return teamEmail || null;
|
||||
}),
|
||||
update: authenticatedProcedure.input(ZUpdateTeamEmailMutationSchema).mutation(async ({ input, ctx }) => {
|
||||
ctx.logger.info({
|
||||
@@ -67,7 +82,7 @@ export const teamRouter = router({
|
||||
},
|
||||
});
|
||||
|
||||
return await updateTeamEmail({
|
||||
await updateTeamEmail({
|
||||
userId: ctx.user.id,
|
||||
...input,
|
||||
});
|
||||
@@ -81,7 +96,7 @@ export const teamRouter = router({
|
||||
},
|
||||
});
|
||||
|
||||
return await deleteTeamEmail({
|
||||
await deleteTeamEmail({
|
||||
userId: ctx.user.id,
|
||||
userEmail: ctx.user.email,
|
||||
teamId,
|
||||
@@ -99,7 +114,7 @@ export const teamRouter = router({
|
||||
},
|
||||
});
|
||||
|
||||
return await createTeamEmailVerification({
|
||||
await createTeamEmailVerification({
|
||||
teamId,
|
||||
userId: ctx.user.id,
|
||||
data: {
|
||||
@@ -108,6 +123,7 @@ export const teamRouter = router({
|
||||
},
|
||||
});
|
||||
}),
|
||||
complete: completeTeamEmailVerificationRoute,
|
||||
resend: authenticatedProcedure
|
||||
.input(ZResendTeamEmailVerificationMutationSchema)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
@@ -135,7 +151,7 @@ export const teamRouter = router({
|
||||
},
|
||||
});
|
||||
|
||||
return await deleteTeamEmailVerification({
|
||||
await deleteTeamEmailVerification({
|
||||
userId: ctx.user.id,
|
||||
teamId,
|
||||
});
|
||||
|
||||
@@ -124,11 +124,10 @@ export const updateTeamSettingsRoute = authenticatedProcedure
|
||||
const isChangingIncludeSenderDetails =
|
||||
includeSenderDetails !== undefined && includeSenderDetails !== currentIncludeSenderDetails;
|
||||
|
||||
if (isPersonalOrganisation && isChangingIncludeSenderDetails) {
|
||||
throw new AppError(AppErrorCode.INVALID_BODY, {
|
||||
message: 'Personal teams cannot update the sender details',
|
||||
});
|
||||
}
|
||||
// Personal teams cannot change the sender details — drop the field (no-op)
|
||||
// instead of rejecting the whole update.
|
||||
const derivedIncludeSenderDetails =
|
||||
isPersonalOrganisation && isChangingIncludeSenderDetails ? undefined : includeSenderDetails;
|
||||
|
||||
// Sanitize custom branding CSS at write time. `null` means inherit-from-org
|
||||
// for teams, so only run the sanitiser when an explicit string is provided.
|
||||
@@ -163,7 +162,7 @@ export const updateTeamSettingsRoute = authenticatedProcedure
|
||||
documentLanguage,
|
||||
documentTimezone,
|
||||
documentDateFormat,
|
||||
includeSenderDetails,
|
||||
includeSenderDetails: derivedIncludeSenderDetails,
|
||||
includeSigningCertificate,
|
||||
includeAuditLog,
|
||||
typedSignatureEnabled,
|
||||
|
||||
@@ -68,7 +68,7 @@ const AvatarWithText = ({
|
||||
|
||||
<div className={cn('flex flex-col truncate text-left font-normal text-sm', textSectionClassName)}>
|
||||
<span className="truncate text-foreground">{primaryText}</span>
|
||||
<span className="truncate text-muted-foreground text-xs">{secondaryText}</span>
|
||||
{secondaryText && <span className="truncate text-muted-foreground text-xs">{secondaryText}</span>}
|
||||
</div>
|
||||
|
||||
{rightSideComponent}
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||
import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT, IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
|
||||
import { getAllowedUploadMimeTypes } from '@documenso/lib/constants/document-conversion';
|
||||
import { megabytesToBytes } from '@documenso/lib/universal/unit-convertions';
|
||||
import { isPersonalLayout } from '@documenso/lib/utils/organisations';
|
||||
import type { MessageDescriptor } from '@lingui/core';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
@@ -45,12 +43,8 @@ export const DocumentUploadButton = ({
|
||||
}: DocumentUploadButtonProps) => {
|
||||
const { _ } = useLingui();
|
||||
|
||||
const { organisations } = useSession();
|
||||
|
||||
const organisation = useCurrentOrganisation();
|
||||
|
||||
const isPersonalLayoutMode = isPersonalLayout(organisations);
|
||||
|
||||
const { getRootProps, getInputProps } = useDropzone({
|
||||
accept: getAllowedUploadMimeTypes(),
|
||||
multiple: internalVersion === '2',
|
||||
@@ -76,7 +70,7 @@ export const DocumentUploadButton = ({
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button className="bg-warning hover:bg-warning/80" asChild>
|
||||
<Link to={isPersonalLayoutMode ? `/settings/billing` : `/o/${organisation.url}/settings/billing`}>
|
||||
<Link to={`/o/${organisation.url}/settings/billing`}>
|
||||
<Trans>Upgrade</Trans>
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
@@ -243,6 +243,38 @@
|
||||
display: none;
|
||||
}
|
||||
|
||||
/*
|
||||
* Scrollbar hidden until the element is hovered, then a thin thumb is revealed.
|
||||
* The track keeps a constant width so revealing the thumb doesn't shift layout;
|
||||
* the thumb colour transitions in on WebKit (Firefox snaps, which is fine).
|
||||
*/
|
||||
.hover-scrollbar {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: transparent transparent;
|
||||
}
|
||||
|
||||
.hover-scrollbar:hover {
|
||||
scrollbar-color: hsl(var(--muted-foreground) / 0.4) transparent;
|
||||
}
|
||||
|
||||
.hover-scrollbar::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.hover-scrollbar::-webkit-scrollbar-thumb {
|
||||
background-color: transparent;
|
||||
border: 2px solid transparent;
|
||||
background-clip: padding-box;
|
||||
border-radius: 9999px;
|
||||
transition: background-color 200ms ease;
|
||||
}
|
||||
|
||||
.hover-scrollbar:hover::-webkit-scrollbar-thumb {
|
||||
background-color: hsl(var(--muted-foreground) / 0.4);
|
||||
}
|
||||
|
||||
/* .custom-scrollbar::-webkit-scrollbar-track {
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user