Merge branch 'main' into feat/template-document-name-options

This commit is contained in:
Catalin Pit
2026-08-14 13:09:17 +03:00
committed by GitHub
188 changed files with 12576 additions and 5096 deletions
@@ -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);
});
});
+2 -4
View File
@@ -1,4 +1,4 @@
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { formatPath, NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { AppError } from '@documenso/lib/errors/app-error';
import type { ClientResponse, InferRequestType } from 'hono/client';
import { hc } from 'hono/client';
@@ -36,8 +36,6 @@ type TPasskeySignin = InferRequestType<AuthClientType['passkey']['authorize']['$
export class AuthClient {
public client: AuthClientType;
private signOutredirectPath: string = '/signin';
constructor(options: { baseUrl: string }) {
this.client = hc<AuthAppType>(options.baseUrl);
}
@@ -45,7 +43,7 @@ export class AuthClient {
public async signOut({ redirectPath }: { redirectPath?: string } = {}) {
await this.client.signout.$post();
window.location.href = redirectPath ?? this.signOutredirectPath;
window.location.href = redirectPath ?? formatPath('/signin');
}
public async signOutAllSessions() {
+4 -1
View File
@@ -1,10 +1,13 @@
/**
* Todo: Use library for cookies instead.
*
* Server-side counterpart of `extractCookieFromDocument`, keep their matching
* semantics in step.
*/
export const extractCookieFromHeaders = (cookieName: string, headers: Headers): string | null => {
const cookieHeader = headers.get('cookie') || '';
const cookiePairs = cookieHeader.split(';');
const cookie = cookiePairs.find((pair) => pair.trim().startsWith(cookieName));
const cookie = cookiePairs.find((pair) => pair.trim().startsWith(`${cookieName}=`));
if (!cookie) {
return null;
@@ -1,4 +1,4 @@
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { formatPath, NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import {
isDisposableEmail,
isEmailDomainAllowedForSignup,
@@ -121,7 +121,7 @@ export const handleOAuthCallbackUrl = async (options: HandleOAuthCallbackUrlOpti
// Check if signups are disabled for this provider.
if (!isSignupEnabledForProvider(clientOptions.id as 'google' | 'microsoft' | 'oidc')) {
const errorUrl = new URL('/signin', NEXT_PUBLIC_WEBAPP_URL());
const errorUrl = new URL(formatPath('/signin'), NEXT_PUBLIC_WEBAPP_URL());
errorUrl.searchParams.set('error', AuthenticationErrorCode.SignupDisabled);
@@ -130,7 +130,7 @@ export const handleOAuthCallbackUrl = async (options: HandleOAuthCallbackUrlOpti
// Check domain restriction for new SSO users.
if (!isEmailDomainAllowedForSignup(email)) {
const errorUrl = new URL('/signin', NEXT_PUBLIC_WEBAPP_URL());
const errorUrl = new URL(formatPath('/signin'), NEXT_PUBLIC_WEBAPP_URL());
errorUrl.searchParams.set('error', AuthenticationErrorCode.SignupDisabled);
@@ -141,7 +141,7 @@ export const handleOAuthCallbackUrl = async (options: HandleOAuthCallbackUrlOpti
const additionalBlockedDomains = await getEmailBlocklistDomains();
if (isDisposableEmail(email, additionalBlockedDomains)) {
const errorUrl = new URL('/signin', NEXT_PUBLIC_WEBAPP_URL());
const errorUrl = new URL(formatPath('/signin'), NEXT_PUBLIC_WEBAPP_URL());
errorUrl.searchParams.set('error', AuthenticationErrorCode.SignupDisposableEmail);
@@ -213,15 +213,18 @@ export const validateOauth = async (options: HandleOAuthCallbackUrlOptions) => {
// eslint-disable-next-line prefer-const
let [redirectState, redirectPath] = storedRedirectPath.split(' ');
// The sub-path aware root, e.g. "/" or "/ESign/".
const defaultRedirectPath = formatPath('/');
if (redirectState !== storedState || !redirectPath) {
redirectPath = '/';
redirectPath = defaultRedirectPath;
}
if (!isValidReturnTo(redirectPath)) {
redirectPath = '/';
redirectPath = defaultRedirectPath;
}
redirectPath = normalizeReturnTo(redirectPath) || '/';
redirectPath = normalizeReturnTo(redirectPath) || defaultRedirectPath;
const tokens = await oAuthClient.validateAuthorizationCode(token_endpoint, code, storedCodeVerifier);
@@ -1,4 +1,5 @@
import { sendOrganisationAccountLinkConfirmationEmail } from '@documenso/ee/server-only/lib/send-organisation-account-link-confirmation-email';
import { formatPath } from '@documenso/lib/constants/app';
import { isDisposableEmail, isSignupEnabledForProvider } from '@documenso/lib/constants/auth';
import { AppError } from '@documenso/lib/errors/app-error';
import { getEmailBlocklistDomains } from '@documenso/lib/server-only/site-settings/get-email-blocklist-domains';
@@ -56,7 +57,7 @@ export const handleOAuthOrganisationCallbackUrl = async (options: HandleOAuthOrg
if (existingAccount) {
await onAuthorize({ userId: existingAccount.user.id }, c);
return c.redirect(`/o/${orgUrl}`, 302);
return c.redirect(formatPath(`/o/${orgUrl}`), 302);
}
let userToLink = await prisma.user.findFirst({
+28 -7
View File
@@ -1,5 +1,25 @@
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
/**
* Derive the default redirect target ("/" at the root, "/ESign/" when served under a sub-path).
*/
const getDefaultRedirect = () => {
try {
const pathname = new URL(NEXT_PUBLIC_WEBAPP_URL()).pathname.replace(/\/$/, '');
return `${pathname}/`;
} catch {
return '/';
}
};
const getWebAppOrigin = () => {
try {
return new URL(NEXT_PUBLIC_WEBAPP_URL()).origin;
} catch {
return NEXT_PUBLIC_WEBAPP_URL();
}
};
/**
* Handle an optional redirect path.
*/
@@ -10,19 +30,20 @@ export const handleRequestRedirect = (redirectUrl?: string) => {
const url = new URL(redirectUrl, NEXT_PUBLIC_WEBAPP_URL());
if (url.origin !== NEXT_PUBLIC_WEBAPP_URL()) {
window.location.href = '/';
if (url.origin !== getWebAppOrigin()) {
window.location.href = getDefaultRedirect();
} else {
window.location.href = redirectUrl;
}
};
export const handleSignInRedirect = (redirectUrl: string = '/') => {
const url = new URL(redirectUrl, NEXT_PUBLIC_WEBAPP_URL());
export const handleSignInRedirect = (redirectUrl?: string) => {
const target = redirectUrl ?? getDefaultRedirect();
const url = new URL(target, NEXT_PUBLIC_WEBAPP_URL());
if (url.origin !== NEXT_PUBLIC_WEBAPP_URL()) {
window.location.href = '/';
if (url.origin !== getWebAppOrigin()) {
window.location.href = getDefaultRedirect();
} else {
window.location.href = redirectUrl;
window.location.href = target;
}
};
+4 -1
View File
@@ -12,7 +12,10 @@ export type GetLimitsOptions = {
export const getLimits = async ({ headers, teamId }: GetLimitsOptions) => {
const requestHeaders = headers ?? {};
const url = new URL('/api/limits', NEXT_PUBLIC_WEBAPP_URL());
// Note: the path must be appended rather than passed as the `new URL()` path
// argument, since a leading-slash path replaces the sub-path that
// NEXT_PUBLIC_WEBAPP_URL may carry (e.g. https://host/ESign).
const url = new URL(`${NEXT_PUBLIC_WEBAPP_URL()}/api/limits`);
if (teamId) {
requestHeaders['team-id'] = teamId.toString();
+17
View File
@@ -0,0 +1,17 @@
/**
* Read a cookie value from `document.cookie`.
*
* Client-side counterpart of `extractCookieFromHeaders`. Only works for cookies that
* are not `HttpOnly`, such as the preferred team URL cookie.
*/
export const extractCookieFromDocument = (cookieName: string): string | null => {
const cookiePairs = document.cookie.split(';');
const cookie = cookiePairs.find((pair) => pair.trim().startsWith(`${cookieName}=`));
if (!cookie) {
return null;
}
return cookie.split('=')[1].trim();
};
@@ -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 };
}
+60 -1
View File
@@ -6,6 +6,42 @@ export const APP_DOCUMENT_UPLOAD_SIZE_LIMIT = Number(env('NEXT_PUBLIC_DOCUMENT_S
export const NEXT_PUBLIC_WEBAPP_URL = () => env('NEXT_PUBLIC_WEBAPP_URL') ?? 'http://localhost:3000';
/**
* The sub-path the app is served under (no trailing slash), e.g. "/ESign".
* Returns an empty string when served at root.
*
* Prefers the explicit NEXT_PUBLIC_BASE_PATH (which is the same value baked
* into the Vite/React Router build). Falls back to the pathname of
* NEXT_PUBLIC_WEBAPP_URL so the function still works in dev when the env
* variable is unset.
*
* Avoid using this to build URLs, use {@link formatPath} instead. Reserve this
* for cases where the raw prefix itself is needed, such as path comparisons.
*/
export const getBasePath = (): string => {
const explicit = env('NEXT_PUBLIC_BASE_PATH');
if (explicit) {
return explicit.replace(/\/$/, '');
}
try {
return new URL(NEXT_PUBLIC_WEBAPP_URL()).pathname.replace(/\/$/, '');
} catch {
return '';
}
};
/**
* Prefix a root-relative path with the app's base path.
*
* `formatPath('/api/trpc')` -> `/ESign/api/trpc` under sub-path hosting,
* `/api/trpc` otherwise.
*/
export const formatPath = (path: string): string => {
return `${getBasePath()}${path}`;
};
export const NEXT_PUBLIC_SIGNING_CONTACT_INFO = () =>
env('NEXT_PUBLIC_SIGNING_CONTACT_INFO') ?? NEXT_PUBLIC_WEBAPP_URL();
@@ -17,6 +53,14 @@ export const NEXT_PRIVATE_INTERNAL_WEBAPP_URL = () =>
export const IS_BILLING_ENABLED = () => env('NEXT_PUBLIC_FEATURE_BILLING_ENABLED') === 'true';
/**
* Whether this instance is Documenso Cloud (managed SaaS).
*
* Used so we can show a different UI for Documenso Cloud and self-hosted instances since
* there are things like billing, upsells, documenso links, etc that don't make sense for self-hosted instances.
*/
export const IS_DOCUMENSO_CLOUD = () => env('NEXT_PUBLIC_IS_DOCUMENSO_CLOUD') === 'true';
export const API_V2_BETA_URL = '/api/v2-beta';
export const API_V2_URL = '/api/v2';
@@ -24,7 +68,20 @@ export const SUPPORT_EMAIL = env('NEXT_PUBLIC_SUPPORT_EMAIL') ?? 'support@docume
export const USE_INTERNAL_URL_BROWSERLESS = () => env('NEXT_PUBLIC_USE_INTERNAL_URL_BROWSERLESS') === 'true';
export const IS_AI_FEATURES_CONFIGURED = () => !!env('GOOGLE_VERTEX_PROJECT_ID') && !!env('GOOGLE_VERTEX_API_KEY');
/**
* Returns whether AI features are configured for this instance.
*
* Platform-aware:
* - On the server, checks the private Vertex credentials are configured.
* - On the client, reads the derived public flag injected via `window.__ENV__`.
*/
export const IS_AI_FEATURES_CONFIGURED = (): boolean => {
if (typeof window === 'undefined') {
return !!env('GOOGLE_VERTEX_PROJECT_ID') && !!env('GOOGLE_VERTEX_API_KEY');
}
return env('NEXT_PUBLIC_AI_FEATURES_ENABLED') === 'true';
};
/**
* Temporary flag to toggle between Playwright-based and Konva-based PDF generation
@@ -86,3 +143,5 @@ export const CSC_INSTANCE_SIGNATURE_LEVEL = (): TSignatureLevel => {
return value;
};
export const DOCUMENSO_CLOUD_ENTERPRISE_CTA_URL = 'https://documen.so/enterprise-cta';
+1
View File
@@ -0,0 +1 @@
export const PREFERRED_TEAM_URL_COOKIE = 'preferred-team-url';
+7
View File
@@ -36,6 +36,13 @@ export enum AppErrorCode {
*/
ENVELOPE_TSP_LOCKED = 'ENVELOPE_TSP_LOCKED',
/**
* A completion request was made for a recipient that has already signed.
* Thrown for retried, stale or concurrent duplicate submissions so callers
* can resolve them idempotently instead of surfacing an error.
*/
RECIPIENT_ALREADY_SIGNED = 'RECIPIENT_ALREADY_SIGNED',
/**
* A signer recipient does not have a signature field assigned. Thrown when
* distributing an envelope or using a direct template where at least one
@@ -80,22 +80,29 @@ export const completeDocumentWithToken = async ({
const legacyDocumentId = mapSecondaryIdToDocumentId(envelope.secondaryId);
if (envelope.status !== DocumentStatus.PENDING) {
throw new Error(`Document ${envelope.id} must be pending`);
}
if (envelope.recipients.length === 0) {
throw new Error(`Document ${envelope.id} has no recipient with token ${token}`);
}
const [recipient] = envelope.recipients;
assertRecipientNotExpired(recipient);
// A retried or duplicate completion request for an already signed
// recipient throws a code the router resolves idempotently. This must be
// checked before the envelope status guard since the envelope may have
// been completed and sealed by the recipient's original request.
if (recipient.signingStatus === SigningStatus.SIGNED) {
throw new Error(`Recipient ${recipient.id} has already signed`);
throw new AppError(AppErrorCode.RECIPIENT_ALREADY_SIGNED, {
message: `Recipient ${recipient.id} has already signed`,
statusCode: 400,
});
}
if (envelope.status !== DocumentStatus.PENDING) {
throw new Error(`Document ${envelope.id} must be pending`);
}
assertRecipientNotExpired(recipient);
if (recipient.signingStatus === SigningStatus.REJECTED) {
throw new AppError(AppErrorCode.UNKNOWN_ERROR, {
message: 'Recipient has already rejected the document',
@@ -276,9 +283,15 @@ export const completeDocumentWithToken = async ({
}
await prisma.$transaction(async (tx) => {
await tx.recipient.update({
// Conditional update so two concurrent completion requests can't both
// proceed: only the request that transitions the recipient to SIGNED
// continues, the loser sees a count of 0 and aborts.
const { count: updatedRecipientCount } = await tx.recipient.updateMany({
where: {
id: recipient.id,
signingStatus: {
not: SigningStatus.SIGNED,
},
},
data: {
signingStatus: SigningStatus.SIGNED,
@@ -288,6 +301,16 @@ export const completeDocumentWithToken = async ({
},
});
// A concurrent request completed the recipient between our initial read
// and this transaction. Abort so the winning request handles all side
// effects, the router resolves this code idempotently.
if (updatedRecipientCount === 0) {
throw new AppError(AppErrorCode.RECIPIENT_ALREADY_SIGNED, {
message: `Recipient ${recipient.id} has already signed`,
statusCode: 400,
});
}
if (recipientEmail !== recipient.email || recipientName !== recipient.name) {
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
@@ -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,
},
});
};
+2 -2
View File
@@ -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',
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -1,10 +1,10 @@
/* eslint-disable turbo/no-undeclared-env-vars */
import { NEXT_PUBLIC_WEBAPP_URL } from '../constants/app';
import { NEXT_PUBLIC_WEBAPP_URL, getBasePath } from '../constants/app';
import { env } from '../utils/env';
export const getBaseUrl = () => {
if (typeof window !== 'undefined') {
return '';
return getBasePath();
}
const webAppUrl = NEXT_PUBLIC_WEBAPP_URL();
+3 -1
View File
@@ -2,6 +2,8 @@ import { DocumentDataType } from '@prisma/client';
import { base64 } from '@scure/base';
import { match } from 'ts-pattern';
import { formatPath } from '../../constants/app';
export type GetFileOptions = {
type: DocumentDataType;
data: string;
@@ -36,7 +38,7 @@ const getFileFromBytes64 = (data: string) => {
};
const getFileFromS3 = async (key: string) => {
const getPresignedUrlResponse = await fetch(`/api/files/presigned-get-url`, {
const getPresignedUrlResponse = await fetch(formatPath('/api/files/presigned-get-url'), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
+2 -1
View File
@@ -1,5 +1,6 @@
import type { TUploadPdfResponse } from '@documenso/remix/server/api/files/files.types';
import { formatPath } from '../../constants/app';
import { AppError } from '../../errors/app-error';
type File = {
@@ -38,7 +39,7 @@ export const putPdfFile = async (file: File, options?: PutFileOptions) => {
formData.append('file', properFile);
const response = await fetch('/api/files/upload-pdf', {
const response = await fetch(formatPath('/api/files/upload-pdf'), {
method: 'POST',
headers: buildUploadAuthHeaders(options),
body: formData,
+4
View File
@@ -56,4 +56,8 @@ export const createPublicEnv = () => ({
// Derived from the private transport so the client can detect CSC mode for
// authoring UI gating without exposing the raw transport value.
NEXT_PUBLIC_SIGNING_TRANSPORT_IS_CSC: process.env.NEXT_PRIVATE_SIGNING_TRANSPORT === 'csc' ? 'true' : 'false',
// Derived from the private Vertex credentials so the client can gate AI
// feature UI on a boolean.
NEXT_PUBLIC_AI_FEATURES_ENABLED:
process.env.GOOGLE_VERTEX_PROJECT_ID && process.env.GOOGLE_VERTEX_API_KEY ? 'true' : 'false',
});
+27 -3
View File
@@ -1,4 +1,15 @@
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { getBasePath, NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
/**
* The origin of the web app, ignoring any sub-path NEXT_PUBLIC_WEBAPP_URL carries.
*/
const getWebAppOrigin = () => {
try {
return new URL(NEXT_PUBLIC_WEBAPP_URL()).origin;
} catch {
return NEXT_PUBLIC_WEBAPP_URL();
}
};
export const isValidReturnTo = (returnTo?: string) => {
if (!returnTo) {
@@ -10,7 +21,10 @@ export const isValidReturnTo = (returnTo?: string) => {
const decodedReturnTo = decodeURIComponent(returnTo);
const returnToUrl = new URL(decodedReturnTo, NEXT_PUBLIC_WEBAPP_URL());
if (returnToUrl.origin !== NEXT_PUBLIC_WEBAPP_URL()) {
// Compare against the origin, not the raw env value: when the app is served
// under a sub-path NEXT_PUBLIC_WEBAPP_URL is e.g. "https://host/ESign", which
// never equals a URL's origin ("https://host").
if (returnToUrl.origin !== getWebAppOrigin()) {
return false;
}
@@ -30,7 +44,17 @@ export const normalizeReturnTo = (returnTo?: string) => {
const decodedReturnTo = decodeURIComponent(returnTo);
const returnToUrl = new URL(decodedReturnTo, NEXT_PUBLIC_WEBAPP_URL());
return `${returnToUrl.pathname}${returnToUrl.search}${returnToUrl.hash}`;
const basePath = getBasePath();
let pathname = returnToUrl.pathname;
// A root-relative returnTo ("/inbox") resolves to a pathname without the
// sub-path, so re-apply it when it is missing.
if (basePath && pathname !== basePath && !pathname.startsWith(`${basePath}/`)) {
pathname = `${basePath}${pathname}`;
}
return `${pathname}${returnToUrl.search}${returnToUrl.hash}`;
} catch {
return undefined;
}
+299
View File
@@ -0,0 +1,299 @@
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, IS_DOCUMENSO_CLOUD } 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 isDocumensoCloud = IS_DOCUMENSO_CLOUD();
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) || isDocumensoCloud
? [
{
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) || isDocumensoCloud
? [
{
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 };
};
+57
View File
@@ -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;
};
+3
View File
@@ -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,
@@ -1,5 +1,5 @@
import { prepareCscRecipientSigning } from '@documenso/ee/server-only/signing/csc/prepare-recipient-signing';
import { AppError } from '@documenso/lib/errors/app-error';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { completeDocumentWithToken } from '@documenso/lib/server-only/document/complete-document-with-token';
import { rejectDocumentWithToken } from '@documenso/lib/server-only/document/reject-document-with-token';
import { createEnvelopeRecipients } from '@documenso/lib/server-only/recipient/create-envelope-recipients';
@@ -633,6 +633,17 @@ export const recipientRouter = router({
return { status: 'SIGNED' as const };
} catch (err) {
// Resolve retried, stale or concurrent duplicate completion requests
// idempotently so the client routes the user to the completed page
// instead of surfacing an error for a document that is signed.
if (err instanceof AppError && err.code === AppErrorCode.RECIPIENT_ALREADY_SIGNED) {
ctx.logger.info({
message: 'Recipient attempted to complete a document they have already signed',
});
return { status: 'ALREADY_SIGNED' as const };
}
// Log the error for debugging purposes.
ctx.logger.error({
message: 'Error completing document with token',
@@ -182,12 +182,16 @@ export type TCompleteDocumentWithTokenMutationSchema = z.infer<typeof ZCompleteD
* Discriminated response: SES envelopes return `{ status: 'SIGNED' }` after
* the in-place completion; TSP (AES/QES) envelopes return
* `{ status: 'REDIRECT', redirectUrl }` pointing at the credential-scope
* OAuth authorize endpoint. Frontend callers can branch on `status` —
* existing callers ignored the response and remain compatible.
* OAuth authorize endpoint. `{ status: 'ALREADY_SIGNED' }` is returned when
* the recipient had already signed prior to this request (retries, stale
* tabs, concurrent submissions) so callers can notify the user instead of
* erroring. Frontend callers can branch on `status` — existing callers
* ignored the response and remain compatible.
*/
export const ZCompleteDocumentWithTokenResponseSchema = z.discriminatedUnion('status', [
z.object({ status: z.literal('REDIRECT'), redirectUrl: z.string() }),
z.object({ status: z.literal('SIGNED') }),
z.object({ status: z.literal('ALREADY_SIGNED') }),
]);
export type TCompleteDocumentWithTokenResponseSchema = z.infer<typeof ZCompleteDocumentWithTokenResponseSchema>;
@@ -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>;
+23 -7
View File
@@ -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,
@@ -1,3 +1,4 @@
import { formatPath } from '@documenso/lib/constants/app';
import { SUPPORTED_LANGUAGES } from '@documenso/lib/constants/i18n';
import { dynamicActivate } from '@documenso/lib/utils/i18n';
import { cn } from '@documenso/ui/lib/utils';
@@ -23,7 +24,7 @@ export const LanguageSwitcherDialog = ({ open, setOpen }: LanguageSwitcherDialog
formData.append('lang', lang);
await fetch('/api/locale', {
await fetch(formatPath('/api/locale'), {
method: 'post',
body: formData,
});
+1 -1
View File
@@ -62,7 +62,7 @@
"clsx": "^1.2.1",
"cmdk": "^0.2.1",
"colord": "^2.9.3",
"framer-motion": "^12.23.24",
"framer-motion": "^12.43.0",
"lucide-react": "^0.554.0",
"luxon": "^3.7.2",
"pdfjs-dist": "5.4.296",
+1 -1
View File
@@ -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>
+32
View File
@@ -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;
}