mirror of
https://github.com/documenso/documenso.git
synced 2026-08-23 23:02:22 +10:00
Merge branch 'main' into feat/template-document-name-options
This commit is contained in:
@@ -338,7 +338,7 @@ test('[ADMIN]: verify role hierarchy after promotion', async ({ page }) => {
|
||||
});
|
||||
|
||||
// Verify they can access organisation settings (owner permission)
|
||||
await expect(page.getByText('Organisation Settings')).toBeVisible();
|
||||
await expect(page.getByTestId('unified-settings-sidebar')).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'Delete' })).toBeVisible();
|
||||
});
|
||||
|
||||
@@ -524,7 +524,7 @@ test('[ADMIN]: verify organisation access after ownership change', async ({ page
|
||||
});
|
||||
|
||||
// Should be able to access organisation settings
|
||||
await expect(page.getByText('Organisation Settings')).toBeVisible();
|
||||
await expect(page.getByTestId('unified-settings-sidebar')).toBeVisible();
|
||||
await expect(page.getByLabel('Organisation Name*')).toBeVisible();
|
||||
await expect(page.getByLabel('Organisation Name*')).toBeEnabled();
|
||||
|
||||
@@ -539,5 +539,5 @@ test('[ADMIN]: verify organisation access after ownership change', async ({ page
|
||||
});
|
||||
|
||||
// Should still be able to access settings (as they should now be an admin)
|
||||
await expect(page.getByText('Organisation Settings')).toBeVisible();
|
||||
await expect(page.getByTestId('unified-settings-sidebar')).toBeVisible();
|
||||
});
|
||||
|
||||
@@ -15,11 +15,11 @@ test('[ENVELOPE_EXPIRATION]: set custom expiration period at organisation level'
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
redirectPath: `/o/${organisation.url}/settings/document`,
|
||||
redirectPath: `/o/${organisation.url}/settings/reminders`,
|
||||
});
|
||||
|
||||
// Wait for the form to load.
|
||||
await expect(page.getByTestId('document-language-trigger')).toBeVisible();
|
||||
await expect(page.getByTestId('envelope-expiration-mode')).toBeVisible();
|
||||
|
||||
// Change the amount to 2.
|
||||
const amountInput = page.getByTestId('envelope-expiration-amount');
|
||||
@@ -36,7 +36,7 @@ test('[ENVELOPE_EXPIRATION]: set custom expiration period at organisation level'
|
||||
await page.getByRole('option', { name: 'Weeks' }).click();
|
||||
|
||||
await page.getByRole('button', { name: 'Save changes' }).first().click();
|
||||
await expect(page.getByText('Your document preferences have been updated').first()).toBeVisible();
|
||||
await expect(page.getByText('Your reminder preferences have been updated').first()).toBeVisible();
|
||||
|
||||
// Verify via database.
|
||||
const orgSettings = await prisma.organisationGlobalSettings.findUniqueOrThrow({
|
||||
@@ -54,18 +54,18 @@ test('[ENVELOPE_EXPIRATION]: disable expiration at organisation level', async ({
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
redirectPath: `/o/${organisation.url}/settings/document`,
|
||||
redirectPath: `/o/${organisation.url}/settings/reminders`,
|
||||
});
|
||||
|
||||
await expect(page.getByTestId('document-language-trigger')).toBeVisible();
|
||||
|
||||
// Find the mode select (shows "Custom duration") and change to "Never expires".
|
||||
const modeTrigger = page.getByTestId('envelope-expiration-mode');
|
||||
await expect(modeTrigger).toBeVisible();
|
||||
|
||||
await modeTrigger.click();
|
||||
await page.getByRole('option', { name: 'Never expires' }).click();
|
||||
|
||||
await page.getByRole('button', { name: 'Save changes' }).first().click();
|
||||
await expect(page.getByText('Your document preferences have been updated').first()).toBeVisible();
|
||||
await expect(page.getByText('Your reminder preferences have been updated').first()).toBeVisible();
|
||||
|
||||
// Verify via database.
|
||||
const orgSettings = await prisma.organisationGlobalSettings.findUniqueOrThrow({
|
||||
@@ -106,11 +106,9 @@ test('[ENVELOPE_EXPIRATION]: team overrides organisation expiration', async ({ p
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
redirectPath: `/t/${team.url}/settings/document`,
|
||||
redirectPath: `/t/${team.url}/settings/reminders`,
|
||||
});
|
||||
|
||||
await expect(page.getByTestId('document-language-trigger')).toBeVisible();
|
||||
|
||||
// The expiration picker mode select should show "Inherit from organisation" by default.
|
||||
const modeTrigger = page.getByTestId('envelope-expiration-mode');
|
||||
await expect(modeTrigger).toBeVisible();
|
||||
@@ -129,7 +127,7 @@ test('[ENVELOPE_EXPIRATION]: team overrides organisation expiration', async ({ p
|
||||
await page.getByRole('option', { name: 'Days' }).click();
|
||||
|
||||
await page.getByRole('button', { name: 'Save changes' }).first().click();
|
||||
await expect(page.getByText('Your document preferences have been updated').first()).toBeVisible();
|
||||
await expect(page.getByText('Your reminder preferences have been updated').first()).toBeVisible();
|
||||
|
||||
// Verify team setting is overridden.
|
||||
const teamSettings = await getTeamSettings({ teamId: team.id });
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { FieldType } from '@documenso/prisma/client';
|
||||
import { seedPendingDocumentWithFullFields } from '@documenso/prisma/seed/documents';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { type APIRequestContext, expect, test } from '@playwright/test';
|
||||
|
||||
import { apiSeedPendingDocument } from '../fixtures/api-seeds';
|
||||
import { apiSignin } from '../fixtures/authentication';
|
||||
import { signSignaturePad } from '../fixtures/signature';
|
||||
|
||||
@@ -128,3 +130,82 @@ test('[ENVELOPE_EXPIRATION]: expired recipient cannot complete signing', async (
|
||||
}).toPass({ timeout: 10_000 });
|
||||
}
|
||||
});
|
||||
|
||||
const trpcMutation = async (request: APIRequestContext, procedure: string, input: Record<string, unknown>) => {
|
||||
return await request.post(`${NEXT_PUBLIC_WEBAPP_URL()}/api/trpc/${procedure}`, {
|
||||
headers: { 'content-type': 'application/json' },
|
||||
data: JSON.stringify({ json: input }),
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* The signing page loader only redirects expired recipients, which a direct API call
|
||||
* bypasses. The tests above exercise the V1 signing path; this covers the V2 route
|
||||
* (`envelope.field.sign`), which must reject on the server regardless of the UI.
|
||||
*/
|
||||
test('[ENVELOPE_EXPIRATION]: expired recipient cannot sign a field via the V2 API', async ({ request }) => {
|
||||
const { envelope, distributeResult } = await apiSeedPendingDocument(request, {
|
||||
title: '[TEST] Expired recipient V2 signing',
|
||||
recipients: [
|
||||
{
|
||||
email: `expired-v2-${Date.now()}@test.documenso.com`,
|
||||
name: 'Expired Signer',
|
||||
role: 'SIGNER',
|
||||
signingOrder: 1,
|
||||
},
|
||||
],
|
||||
fieldsPerRecipient: [
|
||||
[
|
||||
{ type: FieldType.SIGNATURE, page: 1, positionX: 5, positionY: 5, width: 5, height: 5 },
|
||||
{ type: FieldType.TEXT, page: 1, positionX: 5, positionY: 15, width: 5, height: 5 },
|
||||
],
|
||||
],
|
||||
});
|
||||
|
||||
const recipient = distributeResult.recipients[0];
|
||||
|
||||
const seededEnvelope = await prisma.envelope.findUniqueOrThrow({
|
||||
where: { id: envelope.id },
|
||||
include: { fields: true },
|
||||
});
|
||||
|
||||
const textField = seededEnvelope.fields.find((field) => field.type === FieldType.TEXT);
|
||||
|
||||
if (!textField) {
|
||||
throw new Error('TEXT field not found on the seeded envelope');
|
||||
}
|
||||
|
||||
// Sanity check: the recipient can sign while the signing window is open.
|
||||
const beforeExpiry = await trpcMutation(request, 'envelope.field.sign', {
|
||||
token: recipient.token,
|
||||
fieldId: textField.id,
|
||||
fieldValue: { type: FieldType.TEXT, value: 'before' },
|
||||
});
|
||||
|
||||
expect(beforeExpiry.ok()).toBeTruthy();
|
||||
|
||||
await prisma.field.update({
|
||||
where: { id: textField.id },
|
||||
data: { inserted: false, customText: '' },
|
||||
});
|
||||
|
||||
await prisma.recipient.update({
|
||||
where: { id: recipient.id },
|
||||
data: { expiresAt: new Date(Date.now() - 60_000) },
|
||||
});
|
||||
|
||||
const afterExpiry = await trpcMutation(request, 'envelope.field.sign', {
|
||||
token: recipient.token,
|
||||
fieldId: textField.id,
|
||||
fieldValue: { type: FieldType.TEXT, value: 'after' },
|
||||
});
|
||||
|
||||
expect(afterExpiry.ok()).toBeFalsy();
|
||||
|
||||
const fieldAfter = await prisma.field.findUniqueOrThrow({
|
||||
where: { id: textField.id },
|
||||
});
|
||||
|
||||
expect(fieldAfter.inserted).toBe(false);
|
||||
expect(fieldAfter.customText).toBe('');
|
||||
});
|
||||
|
||||
@@ -313,20 +313,14 @@ test.describe('Signing Certificate Tests', () => {
|
||||
await apiSignin({
|
||||
page,
|
||||
email: owner.email,
|
||||
redirectPath: `/t/${team.url}/settings/document`,
|
||||
redirectPath: `/t/${team.url}/settings/certificates`,
|
||||
});
|
||||
|
||||
await page
|
||||
.getByRole('group')
|
||||
.locator('div')
|
||||
.filter({ hasText: 'Include the Signing' })
|
||||
.getByRole('combobox')
|
||||
.click();
|
||||
await page.getByTestId('include-signing-certificate-trigger').click();
|
||||
await page.getByRole('option', { name: 'No' }).click();
|
||||
|
||||
await page.getByRole('button', { name: 'Save changes' }).first().click();
|
||||
|
||||
await page.waitForTimeout(1000);
|
||||
await expect(page.getByText('Your certificate preferences have been updated').first()).toBeVisible();
|
||||
|
||||
// Verify the setting was saved
|
||||
const updatedTeam = await prisma.team.findFirstOrThrow({
|
||||
@@ -337,23 +331,21 @@ test.describe('Signing Certificate Tests', () => {
|
||||
expect(updatedTeam.teamGlobalSettings?.includeSigningCertificate).toBe(false);
|
||||
|
||||
// Toggle the setting back to true
|
||||
await page
|
||||
.getByRole('group')
|
||||
.locator('div')
|
||||
.filter({ hasText: 'Include the Signing' })
|
||||
.getByRole('combobox')
|
||||
.click();
|
||||
await page.getByTestId('include-signing-certificate-trigger').click();
|
||||
await page.getByRole('option', { name: 'Yes' }).click();
|
||||
await page.getByRole('button', { name: 'Save changes' }).first().click();
|
||||
|
||||
await page.waitForTimeout(1000);
|
||||
// The toast from the first save may still be visible, so poll the database
|
||||
// for the saved value instead of waiting on UI signals.
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const updatedTeam = await prisma.team.findFirstOrThrow({
|
||||
where: { id: team.id },
|
||||
include: { teamGlobalSettings: true },
|
||||
});
|
||||
|
||||
// Verify the setting was saved
|
||||
const updatedTeam2 = await prisma.team.findFirstOrThrow({
|
||||
where: { id: team.id },
|
||||
include: { teamGlobalSettings: true },
|
||||
});
|
||||
|
||||
expect(updatedTeam2.teamGlobalSettings?.includeSigningCertificate).toBe(true);
|
||||
return updatedTeam.teamGlobalSettings?.includeSigningCertificate;
|
||||
})
|
||||
.toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -35,12 +35,24 @@ test('[ORGANISATIONS]: manage document preferences', async ({ page }) => {
|
||||
await page.getByTestId('signature-types-trigger').click();
|
||||
await page.getByRole('option', { name: 'Draw' }).click();
|
||||
await page.getByRole('option', { name: 'Upload' }).click();
|
||||
await page.keyboard.press('Escape');
|
||||
|
||||
await page.getByRole('button', { name: 'Save changes' }).first().click();
|
||||
await expect(page.getByText('Your document preferences have been updated').first()).toBeVisible();
|
||||
|
||||
// Sender details moved to the email preferences page.
|
||||
await page.goto(`/o/${organisation.url}/settings/email`);
|
||||
await page.getByTestId('include-sender-details-trigger').click();
|
||||
await page.getByRole('option', { name: 'No' }).click();
|
||||
await page.getByRole('button', { name: 'Save changes' }).first().click();
|
||||
await expect(page.getByText('Your email preferences have been updated').first()).toBeVisible();
|
||||
|
||||
// The signing certificate toggle moved to the certificates page.
|
||||
await page.goto(`/o/${organisation.url}/settings/certificates`);
|
||||
await page.getByTestId('include-signing-certificate-trigger').click();
|
||||
await page.getByRole('option', { name: 'No' }).click();
|
||||
await page.getByRole('button', { name: 'Save changes' }).first().click();
|
||||
await expect(page.getByText('Your document preferences have been updated').first()).toBeVisible();
|
||||
await expect(page.getByText('Your certificate preferences have been updated').first()).toBeVisible();
|
||||
|
||||
const teamSettings = await getTeamSettings({
|
||||
teamId: team.id,
|
||||
@@ -236,8 +248,14 @@ test('[ORGANISATIONS]: manage email preferences', async ({ page }) => {
|
||||
await page.getByRole('textbox', { name: 'Reply to email' }).click();
|
||||
await page.getByRole('textbox', { name: 'Reply to email' }).fill('team@example.com');
|
||||
|
||||
// Change email document settings inheritance to controlled
|
||||
await page.getByRole('combobox').filter({ hasText: 'Inherit from organisation' }).click();
|
||||
// Change email document settings inheritance to controlled. Scope to the
|
||||
// email-document-settings field — the sender-details select on this page also
|
||||
// renders an "Inherit from organisation" value.
|
||||
await page
|
||||
.getByTestId('inheritable-email-document-settings')
|
||||
.getByRole('combobox')
|
||||
.filter({ hasText: 'Inherit from organisation' })
|
||||
.click();
|
||||
await page.getByRole('option', { name: 'Override organisation settings' }).click();
|
||||
|
||||
// Update some email settings
|
||||
|
||||
@@ -93,3 +93,34 @@ test('[PUBLIC_PROFILE]: create team profile', async ({ page }) => {
|
||||
await expect(page.getByRole('heading', { name: 'Document Signed' })).toBeVisible();
|
||||
await expect(page.getByRole('heading')).toContainText('Document Signed');
|
||||
});
|
||||
|
||||
test('[PUBLIC_PROFILE]: empty-profile settings hint only shows to team managers', async ({ page }) => {
|
||||
const { user, team } = await seedUser();
|
||||
|
||||
// Enable the team's public profile with no linked templates so the empty
|
||||
// state (and its "manage your profile" hint) renders.
|
||||
await prisma.teamProfile.upsert({
|
||||
where: { teamId: team.id },
|
||||
update: { enabled: true },
|
||||
create: { teamId: team.id, enabled: true },
|
||||
});
|
||||
|
||||
// The team owner manages the team → sees the hint linking straight to the
|
||||
// team's public-profile settings.
|
||||
await apiSignin({ page, email: user.email });
|
||||
await page.goto(`${NEXT_PUBLIC_WEBAPP_URL()}/p/${team.url}`);
|
||||
|
||||
const settingsLink = page.getByRole('link', { name: 'public profile settings' });
|
||||
await expect(settingsLink).toBeVisible();
|
||||
await expect(settingsLink).toHaveAttribute('href', `/t/${team.url}/settings/public-profile`);
|
||||
|
||||
// A different signed-in user who doesn't manage this team sees the empty state
|
||||
// but no settings hint.
|
||||
const { user: stranger } = await seedUser();
|
||||
|
||||
await apiSignin({ page, email: stranger.email });
|
||||
await page.goto(`${NEXT_PUBLIC_WEBAPP_URL()}/p/${team.url}`);
|
||||
|
||||
await expect(page.getByText("hasn't added any documents")).toBeVisible();
|
||||
await expect(page.getByRole('link', { name: 'public profile settings' })).toHaveCount(0);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import { seedOrganisationMembers } from '@documenso/prisma/seed/organisations';
|
||||
import { seedTeam } from '@documenso/prisma/seed/teams';
|
||||
import type { Page } from '@playwright/test';
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { OrganisationMemberRole } from '@prisma/client';
|
||||
|
||||
import { apiSignin } from '../fixtures/authentication';
|
||||
|
||||
const readPreferredTeamUrl = async (page: Page) => {
|
||||
const cookies = await page.context().cookies();
|
||||
|
||||
return cookies.find((cookie) => cookie.name === 'preferred-team-url')?.value ?? null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Two organisations the signed-in user administers, each with its own team.
|
||||
*/
|
||||
const seedTwoOrganisations = async () => {
|
||||
const { owner, team: teamA, organisation: orgA } = await seedTeam();
|
||||
const { organisation: orgB, team: teamB } = await seedTeam();
|
||||
|
||||
await seedOrganisationMembers({
|
||||
members: [{ email: owner.email, organisationRole: OrganisationMemberRole.ADMIN }],
|
||||
organisationId: orgB.id,
|
||||
});
|
||||
|
||||
return { owner, orgA, teamA, orgB, teamB };
|
||||
};
|
||||
|
||||
const switchOrganisationInSettings = async (page: Page, organisationUrl: string) => {
|
||||
const sidebar = page.getByTestId('unified-settings-sidebar');
|
||||
|
||||
await sidebar.getByTestId('settings-org-switcher-trigger').click();
|
||||
await page.getByTestId(`settings-org-switcher-item-${organisationUrl}`).click();
|
||||
await page.waitForURL(`/o/${organisationUrl}/settings/general`);
|
||||
};
|
||||
|
||||
test.describe('Preferred team cookie', () => {
|
||||
test('switching organisation in settings records a team from that organisation', async ({ page }) => {
|
||||
const { owner, teamA, orgB, teamB } = await seedTwoOrganisations();
|
||||
|
||||
await apiSignin({ page, email: owner.email });
|
||||
|
||||
await page.goto(`/t/${teamA.url}/settings/general`);
|
||||
expect(await readPreferredTeamUrl(page)).toBe(teamA.url);
|
||||
|
||||
await switchOrganisationInSettings(page, orgB.url);
|
||||
|
||||
// Recorded by the settings layout, which posts asynchronously rather than blocking the
|
||||
// navigation, so the swap lands shortly after the URL changes.
|
||||
await expect.poll(() => readPreferredTeamUrl(page)).toBe(teamB.url);
|
||||
});
|
||||
|
||||
test('app root redirects into the organisation last selected in settings', async ({ page }) => {
|
||||
const { owner, teamA, orgB, teamB } = await seedTwoOrganisations();
|
||||
|
||||
await apiSignin({ page, email: owner.email });
|
||||
|
||||
await page.goto(`/t/${teamA.url}/settings/general`);
|
||||
await switchOrganisationInSettings(page, orgB.url);
|
||||
|
||||
await expect.poll(() => readPreferredTeamUrl(page)).toBe(teamB.url);
|
||||
|
||||
await page.goto('/');
|
||||
await expect(page).toHaveURL(`/t/${teamB.url}/documents`);
|
||||
});
|
||||
|
||||
test('switching team in settings records the newly selected team', async ({ page }) => {
|
||||
const { owner, teamA, orgB, teamB } = await seedTwoOrganisations();
|
||||
|
||||
await apiSignin({ page, email: owner.email });
|
||||
|
||||
await page.goto(`/t/${teamB.url}/settings/general`);
|
||||
expect(await readPreferredTeamUrl(page)).toBe(teamB.url);
|
||||
|
||||
await page.goto(`/t/${teamA.url}/settings/general`);
|
||||
expect(await readPreferredTeamUrl(page)).toBe(teamA.url);
|
||||
|
||||
await page.goto('/');
|
||||
await expect(page).toHaveURL(`/t/${teamA.url}/documents`);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,554 @@
|
||||
import { createTeam } from '@documenso/lib/server-only/team/create-team';
|
||||
import { nanoid } from '@documenso/lib/universal/id';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { seedTeam, seedTeamMember } from '@documenso/prisma/seed/teams';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { TeamMemberRole } from '@prisma/client';
|
||||
|
||||
import { apiSignin } from '../fixtures/authentication';
|
||||
|
||||
/**
|
||||
* Every seeded user is given their own organisation. Removing it leaves the user with only
|
||||
* the access that was explicitly granted, which is how we reach the "team access only" and
|
||||
* "no organisations at all" states.
|
||||
*/
|
||||
const deleteOwnedOrganisations = async (userId: number) => {
|
||||
await prisma.organisation.deleteMany({
|
||||
where: {
|
||||
ownerUserId: userId,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
test.describe('Unified Settings', () => {
|
||||
test('shows both groups for the team owner at team scope', async ({ page }) => {
|
||||
const { owner, team, organisation } = await seedTeam();
|
||||
|
||||
await apiSignin({ page, email: owner.email });
|
||||
await page.goto(`/t/${team.url}/settings`);
|
||||
|
||||
const sidebar = page.getByTestId('unified-settings-sidebar');
|
||||
await expect(sidebar).toBeVisible();
|
||||
|
||||
const groups = sidebar.getByTestId('unified-settings-sidebar-group');
|
||||
// Organisation + Team groups, plus the always-visible Account group.
|
||||
await expect(groups).toHaveCount(3);
|
||||
|
||||
await expect(sidebar.getByTestId('settings-org-switcher-trigger')).toContainText(organisation.name);
|
||||
await expect(sidebar.getByTestId('settings-team-switcher-trigger')).toContainText(team.name);
|
||||
|
||||
// Nav item labels are lingui `msg` descriptors resolved to strings at render —
|
||||
// assert the visible text so a broken translation (blank / [object Object])
|
||||
// would fail here. Test ids are scope-qualified because item keys repeat across groups.
|
||||
await expect(sidebar.getByTestId('unified-settings-nav-team-members')).toContainText('Members');
|
||||
await expect(sidebar.getByTestId('unified-settings-nav-team-preferences')).toContainText('Preferences');
|
||||
await expect(sidebar.getByTestId('unified-settings-nav-organisation-members')).toContainText('Members');
|
||||
await expect(sidebar.getByTestId('unified-settings-nav-account-profile')).toContainText('Profile');
|
||||
});
|
||||
|
||||
test('shows both groups for the team owner at org scope (team-fallback)', async ({ page }) => {
|
||||
const { owner, team, organisation } = await seedTeam();
|
||||
|
||||
await apiSignin({ page, email: owner.email });
|
||||
// At org scope `useOptionalCurrentTeam()` is null, but the layout falls
|
||||
// back to the user's first manageable team in the current org so both
|
||||
// groups still render.
|
||||
await page.goto(`/o/${organisation.url}/settings`);
|
||||
|
||||
const sidebar = page.getByTestId('unified-settings-sidebar');
|
||||
await expect(sidebar).toBeVisible();
|
||||
|
||||
const groups = sidebar.getByTestId('unified-settings-sidebar-group');
|
||||
// Organisation + Team groups, plus the always-visible Account group.
|
||||
await expect(groups).toHaveCount(3);
|
||||
|
||||
await expect(sidebar.getByTestId('settings-org-switcher-trigger')).toContainText(organisation.name);
|
||||
// Team switcher shows the fallback team (the user's first manageable team in this org).
|
||||
await expect(sidebar.getByTestId('settings-team-switcher-trigger')).toContainText(team.name);
|
||||
|
||||
// The empty state is only for users who can't manage the organisation.
|
||||
await expect(sidebar.getByTestId('unified-settings-organisation-empty-state')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('sidebar is flush with the left viewport edge', async ({ page }) => {
|
||||
const { owner, organisation } = await seedTeam();
|
||||
|
||||
// Wide viewport — a centered max-w-screen-xl container would offset the
|
||||
// sidebar by (1600 - 1280) / 2 = 160px+, while flush-left is ~16px (the
|
||||
// aside's own internal padding).
|
||||
await page.setViewportSize({ width: 1600, height: 900 });
|
||||
|
||||
await apiSignin({ page, email: owner.email });
|
||||
await page.goto(`/o/${organisation.url}/settings`);
|
||||
|
||||
const sidebar = page.getByTestId('unified-settings-sidebar');
|
||||
await expect(sidebar).toBeVisible();
|
||||
|
||||
const box = await sidebar.boundingBox();
|
||||
|
||||
expect(box?.x ?? Number.MAX_SAFE_INTEGER).toBeLessThan(100);
|
||||
|
||||
// The app header stretches to the full viewport width on settings pages.
|
||||
const headerContainer = page.getByTestId('app-header-container');
|
||||
const headerBox = await headerContainer.boundingBox();
|
||||
|
||||
expect(headerBox?.x ?? Number.MAX_SAFE_INTEGER).toBeLessThan(50);
|
||||
expect((headerBox?.x ?? 0) + (headerBox?.width ?? 0)).toBeGreaterThan(1550);
|
||||
|
||||
// Outside of settings the header keeps its centered max-w-screen-xl container.
|
||||
await page.goto(`/o/${organisation.url}`);
|
||||
await expect(headerContainer).toBeVisible();
|
||||
|
||||
const centeredHeaderBox = await headerContainer.boundingBox();
|
||||
|
||||
expect(centeredHeaderBox?.x ?? 0).toBeGreaterThan(100);
|
||||
});
|
||||
|
||||
test('content is centered within the pane beside the sidebar', async ({ page }) => {
|
||||
const { owner, organisation } = await seedTeam();
|
||||
|
||||
await page.setViewportSize({ width: 1600, height: 900 });
|
||||
|
||||
await apiSignin({ page, email: owner.email });
|
||||
await page.goto(`/o/${organisation.url}/settings`);
|
||||
|
||||
const content = page.getByTestId('unified-settings-content');
|
||||
await expect(content).toBeVisible();
|
||||
|
||||
const contentBox = await content.boundingBox();
|
||||
|
||||
// The pane spans from the sidebar's right edge (fixed 320px aside) to the
|
||||
// viewport edge. The content container should be centered within it.
|
||||
const paneCenter = (320 + 1600) / 2;
|
||||
const contentCenter = (contentBox?.x ?? 0) + (contentBox?.width ?? 0) / 2;
|
||||
|
||||
expect(Math.abs(contentCenter - paneCenter)).toBeLessThan(24);
|
||||
});
|
||||
|
||||
test('keeps current section when switching teams', async ({ page }) => {
|
||||
// Seed one team, then add a second team to the same organisation.
|
||||
const { owner, team: team1, organisation } = await seedTeam();
|
||||
|
||||
const team2Url = `team-two-${nanoid()}`;
|
||||
|
||||
await createTeam({
|
||||
userId: owner.id,
|
||||
teamName: 'Team Two',
|
||||
teamUrl: team2Url,
|
||||
organisationId: organisation.id,
|
||||
inheritMembers: true,
|
||||
});
|
||||
|
||||
await apiSignin({ page, email: owner.email });
|
||||
await page.goto(`/t/${team1.url}/settings/members`);
|
||||
|
||||
// Scope to the desktop sidebar — the mobile sidebar also renders the
|
||||
// same testid.
|
||||
const sidebar = page.getByTestId('unified-settings-sidebar');
|
||||
await sidebar.getByTestId('settings-team-switcher-trigger').click();
|
||||
|
||||
// The popover content matches the trigger width.
|
||||
const triggerBox = await sidebar.getByTestId('settings-team-switcher-trigger').boundingBox();
|
||||
const contentBox = await page.getByTestId('settings-team-switcher-content').boundingBox();
|
||||
|
||||
expect(Math.abs((contentBox?.width ?? 0) - (triggerBox?.width ?? -1))).toBeLessThan(2);
|
||||
|
||||
await page.getByTestId(`settings-team-switcher-item-${team2Url}`).click();
|
||||
|
||||
await page.waitForURL(`/t/${team2Url}/settings/members`);
|
||||
await expect(page).toHaveURL(`/t/${team2Url}/settings/members`);
|
||||
});
|
||||
|
||||
test('account settings keep the organisation the user was working in', async ({ page }) => {
|
||||
// The user administers their own organisation, but only manages a team in the seeded
|
||||
// one — so the two differ in whether organisation settings are reachable.
|
||||
const { team: teamInOtherOrg, organisation: otherOrganisation } = await seedTeam();
|
||||
|
||||
const user = await seedTeamMember({ teamId: teamInOtherOrg.id, role: TeamMemberRole.MANAGER });
|
||||
|
||||
const ownedOrganisation = await prisma.organisation.findFirstOrThrow({
|
||||
where: { ownerUserId: user.id },
|
||||
include: { teams: true },
|
||||
});
|
||||
|
||||
// Both are seeded as "Personal Organisation", so rename them to tell the switcher apart.
|
||||
await prisma.organisation.update({ where: { id: ownedOrganisation.id }, data: { name: 'Org I Administer' } });
|
||||
await prisma.organisation.update({
|
||||
where: { id: otherOrganisation.id },
|
||||
data: { name: 'Org I Only Have A Team In' },
|
||||
});
|
||||
|
||||
await apiSignin({ page, email: user.email });
|
||||
|
||||
const sidebar = page.getByTestId('unified-settings-sidebar');
|
||||
const orgTrigger = sidebar.getByTestId('settings-org-switcher-trigger');
|
||||
|
||||
// `organisations` comes back unordered, so which one account scope falls back to isn't
|
||||
// fixed. Read it cold, then work in the *other* one — otherwise the test can pass just
|
||||
// because the fallback already happened to be the right organisation.
|
||||
await page.goto('/settings/profile');
|
||||
|
||||
const fallbackIsOwned = ((await orgTrigger.textContent()) ?? '').includes('Org I Administer');
|
||||
|
||||
const target = fallbackIsOwned
|
||||
? { name: 'Org I Only Have A Team In', teamUrl: teamInOtherOrg.url }
|
||||
: { name: 'Org I Administer', teamUrl: ownedOrganisation.teams[0].url };
|
||||
|
||||
await page.goto(`/t/${target.teamUrl}/settings/general`);
|
||||
await expect(orgTrigger).toContainText(target.name);
|
||||
|
||||
// Account scope has no organisation in the URL either, so it must not silently jump
|
||||
// back to whichever organisation happens to be first.
|
||||
await sidebar.getByTestId('unified-settings-nav-account-profile').click();
|
||||
await page.waitForURL('/settings/profile');
|
||||
await expect(page.getByTestId('settings-scope-breadcrumb-chip')).toContainText('Account Settings');
|
||||
|
||||
await expect(orgTrigger).toContainText(target.name);
|
||||
});
|
||||
|
||||
test('team switcher keeps the selected team when moving to organisation scope', async ({ page }) => {
|
||||
const { owner, team: team1, organisation } = await seedTeam();
|
||||
|
||||
// Lowercased to match what `ZTeamUrlSchema` stores — `createTeam` is called directly
|
||||
// here, bypassing the tRPC input schema that would normalise it in the real flow.
|
||||
const team2Url = `team-two-${nanoid()}`.toLowerCase();
|
||||
|
||||
await createTeam({
|
||||
userId: owner.id,
|
||||
teamName: 'Team Two',
|
||||
teamUrl: team2Url,
|
||||
organisationId: organisation.id,
|
||||
inheritMembers: true,
|
||||
});
|
||||
|
||||
await apiSignin({ page, email: owner.email });
|
||||
|
||||
const sidebar = page.getByTestId('unified-settings-sidebar');
|
||||
const trigger = sidebar.getByTestId('settings-team-switcher-trigger');
|
||||
|
||||
// `organisation.teams` comes back unordered, so which team the sidebar falls back to
|
||||
// isn't fixed. Read it first, then deliberately select the *other* one — otherwise the
|
||||
// test can pass simply because the fallback already happened to be the right team.
|
||||
await page.goto(`/o/${organisation.url}/settings/general`);
|
||||
|
||||
const fallbackIsTeam2 = ((await trigger.textContent()) ?? '').includes('Team Two');
|
||||
const selected = fallbackIsTeam2 ? { url: team1.url, name: team1.name } : { url: team2Url, name: 'Team Two' };
|
||||
|
||||
await page.goto(`/t/${team2Url}/settings/general`);
|
||||
await trigger.click();
|
||||
await page.getByTestId(`settings-team-switcher-item-${selected.url}`).click();
|
||||
await page.waitForURL(`/t/${selected.url}/settings/general`);
|
||||
await expect(trigger).toContainText(selected.name);
|
||||
|
||||
// Organisation scope has no team in the URL, so the sidebar has to remember which team
|
||||
// the user picked rather than falling back to whichever one happens to be first.
|
||||
await sidebar.getByTestId('unified-settings-nav-organisation-general').click();
|
||||
await page.waitForURL(`/o/${organisation.url}/settings/general`);
|
||||
|
||||
// Wait for the organisation page to actually render — asserting straight after
|
||||
// `waitForURL` can read the previous scope's still-mounted sidebar and pass falsely.
|
||||
await expect(page.getByTestId('settings-scope-breadcrumb-chip')).toContainText('Organisation Settings');
|
||||
|
||||
await expect(trigger).toContainText(selected.name);
|
||||
|
||||
// The selection must also survive in the cookie — otherwise the app root would send the
|
||||
// user back to the wrong team.
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const cookies = await page.context().cookies();
|
||||
|
||||
return cookies.find((cookie) => cookie.name === 'preferred-team-url')?.value ?? null;
|
||||
})
|
||||
.toBe(selected.url);
|
||||
});
|
||||
|
||||
test('inheritable field toggles between INHERITED and OVERRIDDEN', async ({ page }) => {
|
||||
const { owner, team } = await seedTeam();
|
||||
|
||||
await apiSignin({ page, email: owner.email });
|
||||
await page.goto(`/t/${team.url}/settings/document`);
|
||||
|
||||
const langStatus = page.getByTestId('document-language-status');
|
||||
await expect(langStatus).toHaveText(/inherited/i);
|
||||
|
||||
// Open the language select and pick a non-default value.
|
||||
await page.getByTestId('document-language-trigger').click();
|
||||
await page
|
||||
.getByRole('option', { name: /english/i })
|
||||
.first()
|
||||
.click();
|
||||
|
||||
await expect(langStatus).toHaveText(/override/i);
|
||||
|
||||
// Selecting the inherit option stages the field back to inherited.
|
||||
await page.getByTestId('document-language-trigger').click();
|
||||
await page.getByRole('option', { name: /inherit from organisation/i }).click();
|
||||
|
||||
await expect(langStatus).toHaveText(/inherited/i);
|
||||
});
|
||||
|
||||
test('branding fields toggle between INHERITED and OVERRIDDEN', async ({ page }) => {
|
||||
const { owner, team } = await seedTeam();
|
||||
|
||||
await apiSignin({ page, email: owner.email });
|
||||
await page.goto(`/t/${team.url}/settings/branding`);
|
||||
|
||||
const enabledStatus = page.getByTestId('branding-enabled-status');
|
||||
const urlStatus = page.getByTestId('branding-url-status');
|
||||
|
||||
await expect(enabledStatus).toHaveText(/inherited/i);
|
||||
await expect(urlStatus).toHaveText(/inherited/i);
|
||||
|
||||
// Enable branding — unlocks the other fields and overrides the tri-state select.
|
||||
await page.getByTestId('enable-branding').click();
|
||||
await page.getByRole('option', { name: /yes/i }).click();
|
||||
|
||||
await expect(enabledStatus).toHaveText(/override/i);
|
||||
|
||||
// Override the brand website (inherit sentinel is the empty string).
|
||||
await page.getByPlaceholder('https://example.com').fill('https://example.org');
|
||||
|
||||
await expect(urlStatus).toHaveText(/override/i);
|
||||
|
||||
// Clearing the field stages it back to its inherit sentinel (empty string).
|
||||
await page.getByPlaceholder('https://example.com').fill('');
|
||||
|
||||
await expect(urlStatus).toHaveText(/inherited/i);
|
||||
});
|
||||
|
||||
test('reminders page renders extracted fields with inheritance badges', async ({ page }) => {
|
||||
const { owner, team } = await seedTeam();
|
||||
|
||||
await apiSignin({ page, email: owner.email });
|
||||
await page.goto(`/t/${team.url}/settings/reminders`);
|
||||
|
||||
await expect(page.getByTestId('envelope-expiration-period-status')).toHaveText(/inherited/i);
|
||||
await expect(page.getByTestId('reminder-settings-status')).toHaveText(/inherited/i);
|
||||
|
||||
// The fields were extracted out of the document preferences page.
|
||||
await page.goto(`/t/${team.url}/settings/document`);
|
||||
await expect(page.getByTestId('document-language-status')).toBeVisible();
|
||||
await expect(page.getByTestId('envelope-expiration-period-status')).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('certificates page renders extracted fields with inheritance badges', async ({ page }) => {
|
||||
const { owner, team } = await seedTeam();
|
||||
|
||||
await apiSignin({ page, email: owner.email });
|
||||
await page.goto(`/t/${team.url}/settings/certificates`);
|
||||
|
||||
await expect(page.getByTestId('include-signing-certificate-status')).toHaveText(/inherited/i);
|
||||
await expect(page.getByTestId('include-audit-log-status')).toHaveText(/inherited/i);
|
||||
});
|
||||
|
||||
test('send on behalf of team lives on the email preferences page', async ({ page }) => {
|
||||
const { owner, team } = await seedTeam();
|
||||
|
||||
await apiSignin({ page, email: owner.email });
|
||||
await page.goto(`/t/${team.url}/settings/email`);
|
||||
|
||||
await expect(page.getByTestId('include-sender-details-status')).toHaveText(/inherited/i);
|
||||
|
||||
// Moved out of the document preferences page.
|
||||
await page.goto(`/t/${team.url}/settings/document`);
|
||||
await expect(page.getByTestId('document-language-status')).toBeVisible();
|
||||
await expect(page.getByTestId('include-sender-details-status')).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('account settings render inside the unified layout', async ({ page }) => {
|
||||
const { owner } = await seedTeam();
|
||||
|
||||
await apiSignin({ page, email: owner.email });
|
||||
await page.goto('/settings/profile');
|
||||
|
||||
const sidebar = page.getByTestId('unified-settings-sidebar');
|
||||
await expect(sidebar).toBeVisible();
|
||||
|
||||
// Org + team groups render via the manageable-organisation fallback, and the
|
||||
// Account group is always present.
|
||||
await expect(sidebar.getByTestId('unified-settings-sidebar-group')).toHaveCount(3);
|
||||
|
||||
await expect(page.getByTestId('settings-scope-breadcrumb-chip')).toContainText('Account Settings');
|
||||
});
|
||||
|
||||
test('personal team can save email preferences', async ({ page }) => {
|
||||
const { user, team } = await seedUser({ isPersonalOrganisation: true });
|
||||
|
||||
await apiSignin({ page, email: user.email });
|
||||
await page.goto(`/t/${team.url}/settings/email`);
|
||||
|
||||
// The sender-details field is hidden for personal orgs and its unchanged
|
||||
// inherit sentinel is echoed back on submit — the server must drop it as a
|
||||
// no-op rather than rejecting the whole update.
|
||||
await page.getByPlaceholder('noreply@example.com').fill('replies@example.com');
|
||||
|
||||
await page.getByRole('button', { name: /save changes/i }).click();
|
||||
|
||||
await expect(page.getByText('Email preferences updated').first()).toBeVisible({ timeout: 15_000 });
|
||||
});
|
||||
|
||||
test('content pane scrolls back to top when navigating between sections', async ({ page }) => {
|
||||
const { owner, team } = await seedTeam();
|
||||
|
||||
// Short (but still md+) viewport so the document preferences page overflows
|
||||
// the internally-scrolling content pane.
|
||||
await page.setViewportSize({ width: 1280, height: 720 });
|
||||
|
||||
await apiSignin({ page, email: owner.email });
|
||||
await page.goto(`/t/${team.url}/settings/document`);
|
||||
|
||||
// Wait for the preferences form itself — the pane only overflows once the
|
||||
// form has loaded (the query-loading spinner is shorter than the pane).
|
||||
await expect(page.getByTestId('document-language-trigger')).toBeVisible();
|
||||
|
||||
// The content pane is the <main> wrapping the content container.
|
||||
const contentPane = page.getByTestId('unified-settings-content').locator('..');
|
||||
|
||||
// Scroll the pane down (the document preferences page overflows it).
|
||||
await contentPane.evaluate((el) => el.scrollTo(0, el.scrollHeight));
|
||||
|
||||
const scrolledOffset = await contentPane.evaluate((el) => el.scrollTop);
|
||||
expect(scrolledOffset).toBeGreaterThan(0);
|
||||
|
||||
// Navigate to another section via the sidebar (the members testid exists in
|
||||
// both scope groups, so target the team group's link by href).
|
||||
await page.getByTestId('unified-settings-sidebar').locator(`a[href="/t/${team.url}/settings/members"]`).click();
|
||||
await expect(page).toHaveURL(`/t/${team.url}/settings/members`);
|
||||
|
||||
await expect.poll(async () => await contentPane.evaluate((el) => el.scrollTop)).toBe(0);
|
||||
});
|
||||
|
||||
test('deleted personal-layout URL returns 404', async ({ page }) => {
|
||||
const { user } = await seedUser();
|
||||
|
||||
await apiSignin({ page, email: user.email });
|
||||
const response = await page.goto('/settings/document');
|
||||
|
||||
expect(response?.status()).toBe(404);
|
||||
});
|
||||
|
||||
test('team-only access shows the org switcher but no organisation pages', async ({ page }) => {
|
||||
const { team, organisation } = await seedTeam();
|
||||
|
||||
const manager = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.MANAGER });
|
||||
await deleteOwnedOrganisations(manager.id);
|
||||
|
||||
await apiSignin({ page, email: manager.email });
|
||||
await page.goto(`/t/${team.url}/settings/general`);
|
||||
|
||||
const sidebar = page.getByTestId('unified-settings-sidebar');
|
||||
await expect(sidebar).toBeVisible();
|
||||
|
||||
// The Organisation group still renders so it can host the switcher — that's the only
|
||||
// way this user can move between organisations — but it exposes no pages.
|
||||
await expect(sidebar.getByTestId('settings-org-switcher-trigger')).toContainText(organisation.name);
|
||||
await expect(sidebar.getByTestId('unified-settings-nav-organisation-general')).toHaveCount(0);
|
||||
await expect(sidebar.getByTestId('unified-settings-nav-organisation-members')).toHaveCount(0);
|
||||
await expect(sidebar.getByTestId('unified-settings-nav-organisation-billing')).toHaveCount(0);
|
||||
|
||||
// An empty group would just look broken, so it explains itself directly under the switcher.
|
||||
const emptyState = sidebar.getByTestId('unified-settings-organisation-empty-state');
|
||||
await expect(emptyState).toBeVisible();
|
||||
await expect(emptyState).toContainText(/permission to manage this organisation/i);
|
||||
|
||||
// Team and account pages remain navigable.
|
||||
await expect(sidebar.getByTestId('unified-settings-nav-team-general')).toBeVisible();
|
||||
await expect(sidebar.getByTestId('unified-settings-nav-team-members')).toBeVisible();
|
||||
await expect(sidebar.getByTestId('unified-settings-nav-account-profile')).toBeVisible();
|
||||
});
|
||||
|
||||
test('team-only access is rejected from organisation settings', async ({ page }) => {
|
||||
const { team, organisation } = await seedTeam();
|
||||
|
||||
const manager = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.MANAGER });
|
||||
await deleteOwnedOrganisations(manager.id);
|
||||
|
||||
await apiSignin({ page, email: manager.email });
|
||||
|
||||
// Managing a team must not grant access to the organisation scope.
|
||||
await page.goto(`/o/${organisation.url}/settings/general`);
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Unauthorized' })).toBeVisible();
|
||||
await expect(page.getByRole('link', { name: /go to your settings/i })).toBeVisible();
|
||||
await expect(page.getByTestId('unified-settings-sidebar')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('team member without manage permission is rejected from team settings', async ({ page }) => {
|
||||
const { team } = await seedTeam();
|
||||
|
||||
const member = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.MEMBER });
|
||||
|
||||
await apiSignin({ page, email: member.email });
|
||||
await page.goto(`/t/${team.url}/settings/general`);
|
||||
|
||||
// The team settings loader redirects out of the settings tree on a full page load.
|
||||
await expect(page).not.toHaveURL(/\/settings\//);
|
||||
await expect(page.getByTestId('unified-settings-sidebar')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('user with no organisations only sees account settings', async ({ page }) => {
|
||||
const { user } = await seedUser();
|
||||
|
||||
await deleteOwnedOrganisations(user.id);
|
||||
|
||||
await apiSignin({ page, email: user.email });
|
||||
await page.goto('/settings/profile');
|
||||
|
||||
const sidebar = page.getByTestId('unified-settings-sidebar');
|
||||
await expect(sidebar).toBeVisible();
|
||||
|
||||
await expect(sidebar.getByTestId('unified-settings-sidebar-group')).toHaveCount(1);
|
||||
await expect(sidebar.getByTestId('unified-settings-nav-account-profile')).toBeVisible();
|
||||
await expect(sidebar.getByTestId('unified-settings-nav-account-security')).toBeVisible();
|
||||
|
||||
// No organisation in context means no switcher and no scoped groups.
|
||||
await expect(sidebar.getByTestId('settings-org-switcher-trigger')).toHaveCount(0);
|
||||
await expect(sidebar.getByTestId('settings-team-switcher-trigger')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('switching to an organisation the user cannot manage lands in team scope', async ({ page }) => {
|
||||
const { team: otherTeam, organisation: otherOrganisation } = await seedTeam();
|
||||
|
||||
// `seedTeamMember` seeds the user with their own organisation (which they own) and
|
||||
// then grants them a team role in the seeded organisation — exactly the mixed-access
|
||||
// shape the switcher has to handle.
|
||||
const manager = await seedTeamMember({ teamId: otherTeam.id, role: TeamMemberRole.MANAGER });
|
||||
|
||||
const ownedOrganisation = await prisma.organisation.findFirstOrThrow({
|
||||
where: { ownerUserId: manager.id },
|
||||
});
|
||||
|
||||
await apiSignin({ page, email: manager.email });
|
||||
await page.goto(`/o/${ownedOrganisation.url}/settings/members`);
|
||||
|
||||
const sidebar = page.getByTestId('unified-settings-sidebar');
|
||||
await sidebar.getByTestId('settings-org-switcher-trigger').click();
|
||||
await page.getByTestId(`settings-org-switcher-item-${otherOrganisation.url}`).click();
|
||||
|
||||
// `members` exists under both scopes so the section carries over, but the scope drops
|
||||
// to team because the user can't manage the destination organisation.
|
||||
await page.waitForURL(`/t/${otherTeam.url}/settings/members`);
|
||||
});
|
||||
|
||||
test('switching scope falls back to General when the section does not exist there', async ({ page }) => {
|
||||
const { team: otherTeam, organisation: otherOrganisation } = await seedTeam();
|
||||
|
||||
const manager = await seedTeamMember({ teamId: otherTeam.id, role: TeamMemberRole.MANAGER });
|
||||
|
||||
const ownedOrganisation = await prisma.organisation.findFirstOrThrow({
|
||||
where: { ownerUserId: manager.id },
|
||||
});
|
||||
|
||||
await apiSignin({ page, email: manager.email });
|
||||
|
||||
// `teams` only exists under organisation scope.
|
||||
await page.goto(`/o/${ownedOrganisation.url}/settings/teams`);
|
||||
|
||||
const sidebar = page.getByTestId('unified-settings-sidebar');
|
||||
await sidebar.getByTestId('settings-org-switcher-trigger').click();
|
||||
await page.getByTestId(`settings-org-switcher-item-${otherOrganisation.url}`).click();
|
||||
|
||||
await page.waitForURL(`/t/${otherTeam.url}/settings/general`);
|
||||
});
|
||||
});
|
||||
@@ -69,5 +69,6 @@ test('[TEAMS]: update team', async ({ page }) => {
|
||||
await page.getByRole('button', { name: 'Save changes' }).click();
|
||||
|
||||
// Check we have been redirected to the new team URL and the name is updated.
|
||||
await page.waitForURL(`${NEXT_PUBLIC_WEBAPP_URL()}/t/${updatedTeamId}/settings`);
|
||||
// The team settings index redirects to the explicit General route.
|
||||
await page.waitForURL(`${NEXT_PUBLIC_WEBAPP_URL()}/t/${updatedTeamId}/settings/general`);
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { seedTeamEmailVerification } from '@documenso/prisma/seed/teams';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
import { expect, test } from '@playwright/test';
|
||||
@@ -29,7 +30,33 @@ test('[TEAMS]: send team email request', async ({ page }) => {
|
||||
});
|
||||
|
||||
test('[TEAMS]: accept team email request', async ({ page }) => {
|
||||
const { user, team } = await seedUser();
|
||||
const { team } = await seedUser();
|
||||
|
||||
const teamEmailVerification = await seedTeamEmailVerification({
|
||||
email: `team-email-verification--${team.url}@test.documenso.com`,
|
||||
teamId: team.id,
|
||||
});
|
||||
|
||||
const getTeamEmail = async () => prisma.teamEmail.findUnique({ where: { teamId: team.id } });
|
||||
|
||||
expect(await getTeamEmail()).toBeNull();
|
||||
|
||||
await page.goto(`${NEXT_PUBLIC_WEBAPP_URL()}/team/verify/email/${teamEmailVerification.token}`);
|
||||
|
||||
// Visiting the page (GET) must not verify the team email. An automated email link
|
||||
// scanner or prefetcher must not be able to complete the verification.
|
||||
await expect(page.getByRole('heading', { name: 'Verify team email' })).toBeVisible();
|
||||
expect(await getTeamEmail()).toBeNull();
|
||||
|
||||
await page.getByRole('button', { name: 'Verify email' }).click();
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Team email verified!' })).toBeVisible();
|
||||
|
||||
expect(await getTeamEmail()).not.toBeNull();
|
||||
});
|
||||
|
||||
test('[TEAMS]: team email verification link is invalid once completed', async ({ page }) => {
|
||||
const { team } = await seedUser();
|
||||
|
||||
const teamEmailVerification = await seedTeamEmailVerification({
|
||||
email: `team-email-verification--${team.url}@test.documenso.com`,
|
||||
@@ -37,7 +64,11 @@ test('[TEAMS]: accept team email request', async ({ page }) => {
|
||||
});
|
||||
|
||||
await page.goto(`${NEXT_PUBLIC_WEBAPP_URL()}/team/verify/email/${teamEmailVerification.token}`);
|
||||
await expect(page.getByRole('heading')).toContainText('Team email verified!');
|
||||
await page.getByRole('button', { name: 'Verify email' }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Team email verified!' })).toBeVisible();
|
||||
|
||||
await page.goto(`${NEXT_PUBLIC_WEBAPP_URL()}/team/verify/email/${teamEmailVerification.token}`);
|
||||
await expect(page.getByRole('heading', { name: 'Team email already verified!' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('[TEAMS]: delete team email', async ({ page }) => {
|
||||
|
||||
@@ -51,6 +51,10 @@ test('[ORGANISATIONS]: settings save bar floats when the form footer is off-scre
|
||||
isPersonalOrganisation: false,
|
||||
});
|
||||
|
||||
// Short (but still md+) viewport so the document preferences form overflows
|
||||
// the internally-scrolling settings content pane.
|
||||
await page.setViewportSize({ width: 1280, height: 720 });
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
@@ -71,8 +75,10 @@ test('[ORGANISATIONS]: settings save bar floats when the form footer is off-scre
|
||||
await expect(page.getByRole('button', { name: 'Save changes' })).toBeVisible();
|
||||
|
||||
// Scroll to the footer → the floating pill merges into the docked buttons and the
|
||||
// notice disappears.
|
||||
await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));
|
||||
// notice disappears. The settings layout scrolls its content pane internally,
|
||||
// so scroll that pane rather than the window.
|
||||
const contentPane = page.getByTestId('unified-settings-content').locator('..');
|
||||
await contentPane.evaluate((el) => el.scrollTo(0, el.scrollHeight));
|
||||
|
||||
await expect(page.getByText('You have unsaved changes')).not.toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'Save changes' })).toBeVisible();
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { seedTeam, seedTeamMember } from '@documenso/prisma/seed/teams';
|
||||
import type { Page } from '@playwright/test';
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { TeamMemberRole, WebhookTriggerEvents } from '@prisma/client';
|
||||
|
||||
import { apiSignin } from '../fixtures/authentication';
|
||||
|
||||
/**
|
||||
* Calls the procedure the way an attacker would — directly, from the authenticated browser
|
||||
* context, bypassing the UI entirely. The settings page is gated on MANAGE_TEAM, so going
|
||||
* through the UI would only prove the page is hidden, not that the data is protected.
|
||||
*/
|
||||
const callGetTeamWebhooks = async (page: Page, teamId: number) =>
|
||||
await page.evaluate(async (id) => {
|
||||
const response = await fetch('/api/trpc/webhook.getTeamWebhooks', {
|
||||
method: 'GET',
|
||||
headers: { 'content-type': 'application/json', 'x-team-id': String(id) },
|
||||
});
|
||||
|
||||
return { status: response.status, body: await response.text() };
|
||||
}, teamId);
|
||||
|
||||
test.describe('Webhook secret access', () => {
|
||||
test('team managers can read webhook secrets', async ({ page }) => {
|
||||
const { owner, team } = await seedTeam();
|
||||
|
||||
await prisma.webhook.create({
|
||||
data: {
|
||||
webhookUrl: 'https://example.com/hook',
|
||||
eventTriggers: [WebhookTriggerEvents.DOCUMENT_SENT],
|
||||
secret: 'super-secret-signing-key',
|
||||
enabled: true,
|
||||
userId: owner.id,
|
||||
teamId: team.id,
|
||||
},
|
||||
});
|
||||
|
||||
await apiSignin({ page, email: owner.email });
|
||||
await page.goto(`/t/${team.url}/settings/webhooks`);
|
||||
|
||||
const { status, body } = await callGetTeamWebhooks(page, team.id);
|
||||
|
||||
expect(status).toBe(200);
|
||||
// The edit dialog reads the secret straight off these rows, so managers must get it.
|
||||
expect(body).toContain('super-secret-signing-key');
|
||||
});
|
||||
|
||||
test('team members without manage permission cannot read webhook secrets', async ({ page }) => {
|
||||
const { owner, team } = await seedTeam();
|
||||
|
||||
await prisma.webhook.create({
|
||||
data: {
|
||||
webhookUrl: 'https://example.com/hook',
|
||||
eventTriggers: [WebhookTriggerEvents.DOCUMENT_SENT],
|
||||
secret: 'super-secret-signing-key',
|
||||
enabled: true,
|
||||
userId: owner.id,
|
||||
teamId: team.id,
|
||||
},
|
||||
});
|
||||
|
||||
const member = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.MEMBER });
|
||||
|
||||
await apiSignin({ page, email: member.email });
|
||||
await page.goto(`/t/${team.url}/documents`);
|
||||
|
||||
const { status, body } = await callGetTeamWebhooks(page, team.id);
|
||||
|
||||
// Whatever the failure mode, the signing key must never appear in the response.
|
||||
expect(body).not.toContain('super-secret-signing-key');
|
||||
expect(status).not.toBe(200);
|
||||
});
|
||||
});
|
||||
@@ -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() {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export const PREFERRED_TEAM_URL_COOKIE = 'preferred-team-url';
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -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',
|
||||
|
||||
+639
-272
File diff suppressed because it is too large
Load Diff
+639
-272
File diff suppressed because it is too large
Load Diff
+639
-272
File diff suppressed because it is too large
Load Diff
+640
-273
File diff suppressed because it is too large
Load Diff
+639
-272
File diff suppressed because it is too large
Load Diff
+639
-272
File diff suppressed because it is too large
Load Diff
+639
-272
File diff suppressed because it is too large
Load Diff
+639
-272
File diff suppressed because it is too large
Load Diff
+676
-309
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+639
-272
File diff suppressed because it is too large
Load Diff
@@ -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();
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
};
|
||||
@@ -0,0 +1,57 @@
|
||||
export type ComputeSwitcherContinuityPathArgs = {
|
||||
currentPath: string;
|
||||
|
||||
/**
|
||||
* Every settings path navigable in the destination scope — pass the destination's
|
||||
* `SettingsNavGroup.items` paths from `getSettingsNavGroups`.
|
||||
*
|
||||
* Sourcing these from the nav builder (rather than a hardcoded list) means the switcher
|
||||
* can only ever land on a page that is actually reachable there: permission and
|
||||
* claim/billing gating are already applied when the group is built.
|
||||
*/
|
||||
destinationPaths: string[];
|
||||
|
||||
/** Where to land when the current section has no equivalent in the destination. */
|
||||
fallbackPath: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Extract the "section" portion of a settings URL. Returns null if not a scoped
|
||||
* settings URL (account settings and non-settings pages have no equivalent to carry over).
|
||||
*
|
||||
* Examples:
|
||||
* /o/acme/settings/members → 'members'
|
||||
* /o/acme/settings → 'general' (bare index redirects to General)
|
||||
* /t/eng/settings/webhooks/42 → 'webhooks'
|
||||
* /o/acme/documents → null
|
||||
* /settings/profile → null
|
||||
*/
|
||||
const parseSettingsSection = (path: string): string | null => {
|
||||
const match = /^\/[ot]\/[^/]+\/settings(?:\/([^/?#]+))?(?:[/?#]|$)/.exec(path);
|
||||
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return match[1] ?? 'general';
|
||||
};
|
||||
|
||||
/**
|
||||
* Compute the destination URL when the user switches organisation or team via the
|
||||
* unified settings sidebar's switcher.
|
||||
*
|
||||
* Rule: stay on the same section if the destination has one; else use the fallback.
|
||||
*/
|
||||
export const computeSwitcherContinuityPath = ({
|
||||
currentPath,
|
||||
destinationPaths,
|
||||
fallbackPath,
|
||||
}: ComputeSwitcherContinuityPathArgs): string => {
|
||||
const currentSection = parseSettingsSection(currentPath);
|
||||
|
||||
if (!currentSection) {
|
||||
return fallbackPath;
|
||||
}
|
||||
|
||||
return destinationPaths.find((path) => parseSettingsSection(path) === currentSection) ?? fallbackPath;
|
||||
};
|
||||
@@ -1,6 +1,9 @@
|
||||
import macrosPlugin from 'vite-plugin-babel-macros';
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
// Transform lingui macros (e.g. `msg`) used by the code under test.
|
||||
plugins: [macrosPlugin()],
|
||||
test: {
|
||||
include: ['**/*.test.ts'],
|
||||
},
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { FindResultResponse } from '@documenso/lib/types/search-params';
|
||||
import { mapEnvelopesToDocumentMany } from '@documenso/lib/utils/document';
|
||||
import { maskRecipientTokensForDocument } from '@documenso/lib/utils/mask-recipient-tokens-for-document';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import type { Envelope, Prisma } from '@prisma/client';
|
||||
import { DocumentStatus, EnvelopeType, RecipientRole } from '@prisma/client';
|
||||
@@ -106,12 +105,15 @@ export const findInbox = async ({ userId, page = 1, perPage = 10, orderBy }: Fin
|
||||
}),
|
||||
]);
|
||||
|
||||
const maskedData = data.map((document) =>
|
||||
maskRecipientTokensForDocument({
|
||||
document,
|
||||
user,
|
||||
}),
|
||||
);
|
||||
// Not using the maskRecipientTokensForDocument helper here because it needs a
|
||||
// rework due to recipients vs Recipient.
|
||||
const maskedData = data.map((document) => ({
|
||||
...document,
|
||||
recipients: document.recipients.map((recipient) => ({
|
||||
...recipient,
|
||||
token: recipient.email === user.email ? recipient.token : '',
|
||||
})),
|
||||
}));
|
||||
|
||||
return {
|
||||
data: maskedData,
|
||||
|
||||
@@ -12,7 +12,7 @@ import { ZCreateSubscriptionRequestSchema } from './create-subscription.types';
|
||||
export const createSubscriptionRoute = authenticatedProcedure
|
||||
.input(ZCreateSubscriptionRequestSchema)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { organisationId, priceId, isPersonalLayoutMode } = input;
|
||||
const { organisationId, priceId } = input;
|
||||
|
||||
ctx.logger.info({
|
||||
input: {
|
||||
@@ -70,9 +70,7 @@ export const createSubscriptionRoute = authenticatedProcedure
|
||||
});
|
||||
}
|
||||
|
||||
const returnUrl = isPersonalLayoutMode
|
||||
? `${NEXT_PUBLIC_WEBAPP_URL()}/settings/billing-personal`
|
||||
: `${NEXT_PUBLIC_WEBAPP_URL()}/o/${organisation.url}/settings/billing`;
|
||||
const returnUrl = `${NEXT_PUBLIC_WEBAPP_URL()}/o/${organisation.url}/settings/billing`;
|
||||
|
||||
const redirectUrl = await createCheckoutSession({
|
||||
customerId,
|
||||
|
||||
@@ -3,5 +3,4 @@ import { z } from 'zod';
|
||||
export const ZCreateSubscriptionRequestSchema = z.object({
|
||||
organisationId: z.string().describe('The organisation to create the subscription for'),
|
||||
priceId: z.string().describe('The price to create the subscription for'),
|
||||
isPersonalLayoutMode: z.boolean().optional(),
|
||||
});
|
||||
|
||||
@@ -12,7 +12,7 @@ import { ZManageSubscriptionRequestSchema } from './manage-subscription.types';
|
||||
export const manageSubscriptionRoute = authenticatedProcedure
|
||||
.input(ZManageSubscriptionRequestSchema)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { organisationId, isPersonalLayoutMode } = input;
|
||||
const { organisationId } = input;
|
||||
|
||||
ctx.logger.info({
|
||||
input: {
|
||||
@@ -93,9 +93,7 @@ export const manageSubscriptionRoute = authenticatedProcedure
|
||||
});
|
||||
}
|
||||
|
||||
const returnUrl = isPersonalLayoutMode
|
||||
? `${NEXT_PUBLIC_WEBAPP_URL()}/settings/billing-personal`
|
||||
: `${NEXT_PUBLIC_WEBAPP_URL()}/o/${organisation.url}/settings/billing`;
|
||||
const returnUrl = `${NEXT_PUBLIC_WEBAPP_URL()}/o/${organisation.url}/settings/billing`;
|
||||
|
||||
const redirectUrl = await getPortalSession({
|
||||
customerId,
|
||||
|
||||
@@ -2,5 +2,4 @@ import { z } from 'zod';
|
||||
|
||||
export const ZManageSubscriptionRequestSchema = z.object({
|
||||
organisationId: z.string().describe('The organisation to manage the subscription for'),
|
||||
isPersonalLayoutMode: z.boolean().optional(),
|
||||
});
|
||||
|
||||
@@ -4,6 +4,7 @@ import { validateFieldAuth } from '@documenso/lib/server-only/document/validate-
|
||||
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
|
||||
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
|
||||
import { extractFieldInsertionValues } from '@documenso/lib/utils/envelope-signing';
|
||||
import { assertRecipientNotExpired } from '@documenso/lib/utils/recipients';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { DocumentStatus, FieldType, RecipientRole, SigningStatus } from '@prisma/client';
|
||||
import { match } from 'ts-pattern';
|
||||
@@ -108,6 +109,12 @@ export const signEnvelopeFieldRoute = procedure
|
||||
});
|
||||
}
|
||||
|
||||
// Both are checked because an assistant may insert values into a field belonging to
|
||||
// another recipient, and neither signing window may have closed. For every other
|
||||
// role these reference the same recipient.
|
||||
assertRecipientNotExpired(recipient);
|
||||
assertRecipientNotExpired(field.recipient);
|
||||
|
||||
if (recipient.signingStatus === SigningStatus.SIGNED || field.recipient.signingStatus === SigningStatus.SIGNED) {
|
||||
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||
message: `Recipient ${recipient.id} has already signed`,
|
||||
|
||||
@@ -124,11 +124,10 @@ export const updateOrganisationSettingsRoute = authenticatedProcedure
|
||||
const isChangingIncludeSenderDetails =
|
||||
includeSenderDetails !== undefined && includeSenderDetails !== currentIncludeSenderDetails;
|
||||
|
||||
if (isPersonalOrganisation && isChangingIncludeSenderDetails) {
|
||||
throw new AppError(AppErrorCode.INVALID_BODY, {
|
||||
message: 'Personal organisations cannot update the sender details',
|
||||
});
|
||||
}
|
||||
// Personal teams cannot change the sender details — drop the field (no-op)
|
||||
// instead of rejecting the whole update.
|
||||
const derivedIncludeSenderDetails =
|
||||
isPersonalOrganisation && isChangingIncludeSenderDetails ? undefined : includeSenderDetails;
|
||||
|
||||
// Sanitize custom branding CSS at write time so we can store the safe
|
||||
// result and skip per-render sanitisation. Warnings are returned to the
|
||||
@@ -160,7 +159,7 @@ export const updateOrganisationSettingsRoute = authenticatedProcedure
|
||||
documentLanguage,
|
||||
documentTimezone,
|
||||
documentDateFormat,
|
||||
includeSenderDetails,
|
||||
includeSenderDetails: derivedIncludeSenderDetails,
|
||||
includeSigningCertificate,
|
||||
includeAuditLog,
|
||||
typedSignatureEnabled,
|
||||
|
||||
@@ -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>;
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -68,7 +68,7 @@ const AvatarWithText = ({
|
||||
|
||||
<div className={cn('flex flex-col truncate text-left font-normal text-sm', textSectionClassName)}>
|
||||
<span className="truncate text-foreground">{primaryText}</span>
|
||||
<span className="truncate text-muted-foreground text-xs">{secondaryText}</span>
|
||||
{secondaryText && <span className="truncate text-muted-foreground text-xs">{secondaryText}</span>}
|
||||
</div>
|
||||
|
||||
{rightSideComponent}
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||
import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT, IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
|
||||
import { getAllowedUploadMimeTypes } from '@documenso/lib/constants/document-conversion';
|
||||
import { megabytesToBytes } from '@documenso/lib/universal/unit-convertions';
|
||||
import { isPersonalLayout } from '@documenso/lib/utils/organisations';
|
||||
import type { MessageDescriptor } from '@lingui/core';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
@@ -45,12 +43,8 @@ export const DocumentUploadButton = ({
|
||||
}: DocumentUploadButtonProps) => {
|
||||
const { _ } = useLingui();
|
||||
|
||||
const { organisations } = useSession();
|
||||
|
||||
const organisation = useCurrentOrganisation();
|
||||
|
||||
const isPersonalLayoutMode = isPersonalLayout(organisations);
|
||||
|
||||
const { getRootProps, getInputProps } = useDropzone({
|
||||
accept: getAllowedUploadMimeTypes(),
|
||||
multiple: internalVersion === '2',
|
||||
@@ -76,7 +70,7 @@ export const DocumentUploadButton = ({
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button className="bg-warning hover:bg-warning/80" asChild>
|
||||
<Link to={isPersonalLayoutMode ? `/settings/billing` : `/o/${organisation.url}/settings/billing`}>
|
||||
<Link to={`/o/${organisation.url}/settings/billing`}>
|
||||
<Trans>Upgrade</Trans>
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
@@ -243,6 +243,38 @@
|
||||
display: none;
|
||||
}
|
||||
|
||||
/*
|
||||
* Scrollbar hidden until the element is hovered, then a thin thumb is revealed.
|
||||
* The track keeps a constant width so revealing the thumb doesn't shift layout;
|
||||
* the thumb colour transitions in on WebKit (Firefox snaps, which is fine).
|
||||
*/
|
||||
.hover-scrollbar {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: transparent transparent;
|
||||
}
|
||||
|
||||
.hover-scrollbar:hover {
|
||||
scrollbar-color: hsl(var(--muted-foreground) / 0.4) transparent;
|
||||
}
|
||||
|
||||
.hover-scrollbar::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.hover-scrollbar::-webkit-scrollbar-thumb {
|
||||
background-color: transparent;
|
||||
border: 2px solid transparent;
|
||||
background-clip: padding-box;
|
||||
border-radius: 9999px;
|
||||
transition: background-color 200ms ease;
|
||||
}
|
||||
|
||||
.hover-scrollbar:hover::-webkit-scrollbar-thumb {
|
||||
background-color: hsl(var(--muted-foreground) / 0.4);
|
||||
}
|
||||
|
||||
/* .custom-scrollbar::-webkit-scrollbar-track {
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user