Compare commits

..

1 Commits

Author SHA1 Message Date
ephraimduncan c07eeba178 refactor: unify settings layout, navigation and copy
Consolidate the personal, team and organisation settings surfaces behind a single
system so every page shares one width, one navigation implementation and one copy
standard.

- add shared SettingsNav (aria-current active state, exact matching for the team
  General route, non-interactive section labels) and delete the four divergent nav
  implementations it replaces
- constrain the settings content column to max-w-3xl in all three layouts and strip
  the per-page max-w-2xl/max-w-xl wrappers so forms and tables render identically
- rewrite SettingsHeader subtitles to remove "Here you can" / "On this page" filler,
  normalize title casing and punctuation, and translate the hardcoded team general
  header
- align the billing header with SettingsHeader typography and drop redundant fieldset
  width constraints in the document/email preference forms
- fix the wrong team group success toast, team email meta title, misnamed page
  exports, untranslated Manage label and Create Team casing mismatch
- update manage-organisation e2e assertions for the corrected copy
2026-07-07 10:28:19 +00:00
62 changed files with 1553 additions and 3081 deletions
@@ -76,8 +76,6 @@ The Enterprise Edition is required when you:
4. Restart your Documenso instance
5. Verify the license is active in the **Admin Panel** under the **Stats** section
See [Apply Your License Key](/docs/self-hosting/configuration/license) for the full walkthrough, including how to enable individual features once licensed.
</Accordion>
</Accordions>
@@ -199,7 +197,7 @@ See [Support](/docs/policies/support) for complete support options.
1. Sign the Enterprise license agreement
2. Receive license key and access credentials
3. Deploy using [self-hosting guides](/docs/self-hosting) or access Documenso Cloud
4. Apply the key — see [Apply Your License Key](/docs/self-hosting/configuration/license) — and configure Enterprise features with support assistance
4. Configure Enterprise features with support assistance
</Step>
<Step>
@@ -240,7 +238,6 @@ See [Support](/docs/policies/support) for complete support options.
## Related
- [Apply Your License Key](/docs/self-hosting/configuration/license) - Step-by-step license activation
- [Community Edition](/docs/policies/community-edition) - AGPL-3.0 open-source license
- [Licenses](/docs/policies/licenses) - Complete licensing overview and FAQ
- [Support](/docs/policies/support) - Support channels and response times
@@ -443,11 +443,11 @@ Telemetry collects only: app version, installation ID, and node ID. No personal
## Enterprise Features
These variables require an active [Enterprise Edition](/docs/policies/enterprise-edition) license. Obtain a license key from [license.documenso.com](https://license.documenso.com) and set it below to unlock enterprise features such as SSO, embed editor, and 21 CFR Part 11 compliance. See [Apply Your License Key](/docs/self-hosting/configuration/license) for step-by-step setup.
These variables require an active [Enterprise Edition](/docs/policies/enterprise-edition) license. Obtain a license key from [license.documenso.com](https://license.documenso.com) and set it below to unlock enterprise features such as SSO, embed editor, and 21 CFR Part 11 compliance.
| Variable | Description |
| ------------------------------------ | ------------------------------------------------ |
| `NEXT_PRIVATE_DOCUMENSO_LICENSE_KEY` | License key for enterprise features — see [Apply Your License Key](/docs/self-hosting/configuration/license) for how to apply it |
| `NEXT_PRIVATE_DOCUMENSO_LICENSE_KEY` | License key for enterprise features |
| `NEXT_PRIVATE_STRIPE_API_KEY` | Stripe API key for billing |
| `NEXT_PRIVATE_STRIPE_WEBHOOK_SECRET` | Stripe webhook secret |
| `NEXT_PRIVATE_SES_ACCESS_KEY_ID` | AWS SES access key for email domain verification |
@@ -1,107 +0,0 @@
---
title: Apply Your License Key
description: Activate your Enterprise license key to unlock enterprise features on your self-hosted instance.
---
import { Accordion, Accordions } from 'fumadocs-ui/components/accordion';
import { Callout } from 'fumadocs-ui/components/callout';
import { Tab, Tabs } from 'fumadocs-ui/components/tabs';
A license key activates the Enterprise features available to your self-hosted instance, such as CSC signing, SSO, embed white-labelling, and 21 CFR Part 11 compliance.
<Callout type="info">
The license key applies to your **whole instance**, not an individual user account. There's one
key per deployment.
</Callout>
## Prerequisites
- An active Enterprise license key — contact [sales](https://documen.so/enterprise) to set up an
Enterprise subscription, then copy your key from [license.documenso.com](https://license.documenso.com).
See [Enterprise Edition](/docs/policies/enterprise-edition) for details.
- A running self-hosted Documenso instance that you're able to restart
## Step 1: Set the environment variable
Set `NEXT_PRIVATE_DOCUMENSO_LICENSE_KEY` to your license key.
<Tabs items={['Docker Compose', 'docker run', '.env']}>
<Tab value="Docker Compose">
Add the variable to your `.env` file (or directly under `environment:` in `compose.yml`):
```bash
NEXT_PRIVATE_DOCUMENSO_LICENSE_KEY=your-license-key-here
```
Then apply it:
```bash
docker compose up -d
```
</Tab>
<Tab value="docker run">
```bash
docker run -d \
--name documenso \
-e NEXT_PRIVATE_DOCUMENSO_LICENSE_KEY=your-license-key-here \
documenso/documenso:latest
```
</Tab>
<Tab value=".env">
If you're running Documenso directly (not in a container), add the variable to your `.env` file:
```bash
NEXT_PRIVATE_DOCUMENSO_LICENSE_KEY=your-license-key-here
```
</Tab>
</Tabs>
## Step 2: Restart the instance
The license key is only read once, at process startup. Setting the variable in a running container or shell has no effect until the process restarts.
```bash
# Docker Compose
docker compose restart documenso
# Docker
docker restart documenso
```
On startup, Documenso validates the key against the Documenso license server and caches the result locally for future startups, so a brief license-server outage won't lock you out.
## What the license enables
A valid license doesn't turn every enterprise feature on everywhere — activation depends on the feature:
- **CSC signing** activates instance-wide automatically once the license is active and CSC transport is configured. See [CSC / QES Signing](/docs/self-hosting/configuration/signing-certificate/csc-qes) for the full setup.
- **SSO, embed white-labelling, 21 CFR Part 11, and similar** are provisioned per organisation. Follow each feature's own guide to configure it once the license is active.
## Troubleshooting
<Accordions type="multiple">
<Accordion title="Enterprise features are still unavailable after applying the key">
- Confirm the key is present in the environment the running process actually reads — `docker
exec` into the container and check `env | grep LICENSE` if unsure.
- Confirm the instance was fully restarted after the variable was set, not just reloaded.
- Re-copy the key to rule out truncation or accidental whitespace.
</Accordion>
<Accordion title="A specific feature still isn't working">
Instance-wide features (like CSC signing) also need their own configuration — an active license
alone isn't enough. Check that feature's guide to confirm the required settings are in place.
Per-organisation features additionally need to be provisioned for the organisation that's using
them.
</Accordion>
</Accordions>
## See Also
- [Environment Variables](/docs/self-hosting/configuration/environment) - Complete configuration reference
- [Enterprise Edition](/docs/policies/enterprise-edition) - What's included and how to purchase a license
- [CSC / QES Signing](/docs/self-hosting/configuration/signing-certificate/csc-qes) - Enable CSC-based signing
@@ -2,7 +2,6 @@
"title": "Configuration",
"pages": [
"environment",
"license",
"database",
"email",
"storage",
@@ -49,7 +49,7 @@ The callback URL is fixed — Documenso derives it from `NEXT_PUBLIC_WEBAPP_URL`
### Enterprise Edition license
CSC mode is gated by the `instanceCscSigning` license flag. Without a valid Enterprise license, the transport refuses to start (`CSC_UNLICENSED`). See [Apply Your License Key](/docs/self-hosting/configuration/license) to activate one.
CSC mode is gated by the `instanceCscSigning` license flag. Without a valid Enterprise license, the transport refuses to start (`CSC_UNLICENSED`).
</Step>
<Step>
@@ -141,7 +141,7 @@ See the [Quick Start guide](/docs/self-hosting/getting-started/quick-start) for
Self-hosted Documenso includes full core functionality under the AGPL-3.0 license. If you need enterprise features such as SSO, embed editor white label, or 21 CFR Part 11 compliance, you can activate them with a license key.
See [Enterprise Edition](/docs/policies/enterprise-edition) for details and [Licenses](/docs/policies/licenses) for a comparison. Already have a key? See [Apply Your License Key](/docs/self-hosting/configuration/license).
See [Enterprise Edition](/docs/policies/enterprise-edition) for details and [Licenses](/docs/policies/licenses) for a comparison.
---
@@ -155,7 +155,7 @@ export const TeamCreateDialog = ({ trigger, onCreated, ...props }: TeamCreateDia
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild={true}>
{trigger ?? (
<Button className="flex-shrink-0" variant="secondary">
<Trans>Create team</Trans>
<Trans>Create Team</Trans>
</Button>
)}
</DialogTrigger>
@@ -163,7 +163,7 @@ export const TeamCreateDialog = ({ trigger, onCreated, ...props }: TeamCreateDia
<DialogContent position="center">
<DialogHeader>
<DialogTitle>
<Trans>Create team</Trans>
<Trans>Create Team</Trans>
</DialogTitle>
<DialogDescription>
@@ -79,7 +79,7 @@ export const TeamGroupCreateDialog = ({ ...props }: TeamGroupCreateDialogProps)
toast({
title: t`Success`,
description: t`Team members have been added.`,
description: t`Team groups have been added.`,
duration: 5000,
});
@@ -87,7 +87,7 @@ export const TeamGroupCreateDialog = ({ ...props }: TeamGroupCreateDialogProps)
} catch {
toast({
title: t`An unknown error occurred`,
description: t`We encountered an unknown error while attempting to add team members. Please try again later.`,
description: t`We encountered an unknown error while attempting to add the groups. Please try again later.`,
variant: 'destructive',
});
}
@@ -134,7 +134,7 @@ export const TeamGroupCreateDialog = ({ ...props }: TeamGroupCreateDialogProps)
</DialogTitle>
<DialogDescription>
<Trans>Configure the team roles for each group</Trans>
<Trans>Configure the team roles for each group.</Trans>
</DialogDescription>
</DialogHeader>
))
@@ -260,7 +260,7 @@ export const TeamGroupCreateDialog = ({ ...props }: TeamGroupCreateDialogProps)
</Button>
<Button type="submit" loading={form.formState.isSubmitting}>
<Trans>Create Groups</Trans>
<Trans>Add Groups</Trans>
</Button>
</DialogFooter>
</>
@@ -106,7 +106,7 @@ export const WebhookCreateDialog = ({ trigger, ...props }: WebhookCreateDialogPr
<Trans>Create webhook</Trans>
</DialogTitle>
<DialogDescription>
<Trans>On this page, you can create a new webhook.</Trans>
<Trans>Receive real-time notifications when document events occur.</Trans>
</DialogDescription>
</DialogHeader>
@@ -162,7 +162,7 @@ export const DocumentPreferencesForm = ({
return (
<Form {...form}>
<form onSubmit={handleFormSubmit}>
<fieldset className="flex h-full max-w-2xl flex-col gap-y-6" disabled={form.formState.isSubmitting}>
<fieldset className="flex h-full flex-col gap-y-6" disabled={form.formState.isSubmitting}>
{!isPersonalLayoutMode && (
<FormField
control={form.control}
@@ -75,7 +75,7 @@ export const EmailPreferencesForm = ({ settings, onFormSubmit, canInherit }: Ema
return (
<Form {...form}>
<form onSubmit={handleFormSubmit}>
<fieldset className="flex h-full max-w-2xl flex-col gap-y-6" disabled={form.formState.isSubmitting}>
<fieldset className="flex h-full flex-col gap-y-6" disabled={form.formState.isSubmitting}>
{organisation.organisationClaim.flags.emailDomains && (
<FormField
control={form.control}
+1 -1
View File
@@ -96,7 +96,7 @@ export const SignUpForm = ({
password: '',
signature: '',
},
mode: 'onChange',
mode: 'onBlur',
resolver: zodResolver(ZSignUpFormSchema),
});
@@ -1,23 +0,0 @@
import { Trans } from '@lingui/react/macro';
import { AlertTriangleIcon } from 'lucide-react';
export const DirectTemplateInvalidPageView = () => {
return (
<div className="mx-auto flex h-[70vh] w-full max-w-md flex-col items-center justify-center">
<div>
<AlertTriangleIcon className="h-10 w-10 text-destructive" />
<h1 className="mt-4 font-semibold text-3xl">
<Trans>Invalid direct link template</Trans>
</h1>
<p className="mt-2 text-muted-foreground text-sm">
<Trans>
This direct link template cannot be used because one or more signers do not have a signature field assigned.
Please contact the sender to update the template.
</Trans>
</p>
</div>
</div>
);
};
@@ -54,7 +54,6 @@ import { useCurrentTeam } from '~/providers/team';
import { EnvelopeEditorFieldDragDrop } from './envelope-editor-fields-drag-drop';
import { EnvelopeEditorFieldsPageRenderer } from './envelope-editor-fields-page-renderer';
import { EnvelopeEditorInvalidDirectTemplateAlert } from './envelope-editor-invalid-direct-template-alert';
import { EnvelopeRendererFileSelector } from './envelope-file-selector';
import { EnvelopeRecipientSelector } from './envelope-recipient-selector';
@@ -239,8 +238,6 @@ export const EnvelopeEditorFieldsPage = () => {
}
/>
<EnvelopeEditorInvalidDirectTemplateAlert />
{/* Document View */}
<div className="mt-4 flex h-full flex-col items-center justify-center">
{envelope.recipients.length === 0 && (
@@ -1,55 +0,0 @@
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
import { getRecipientsWithMissingFields } from '@documenso/lib/utils/recipients';
import { cn } from '@documenso/ui/lib/utils';
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
import { Trans } from '@lingui/react/macro';
import { useMemo } from 'react';
export type EnvelopeEditorInvalidDirectTemplateAlertProps = {
className?: string;
};
/**
* Warns that a direct link template cannot be used because one or more signers
* are missing a signature field.
*/
export const EnvelopeEditorInvalidDirectTemplateAlert = ({
className,
}: EnvelopeEditorInvalidDirectTemplateAlertProps) => {
const { envelope, isTemplate } = useCurrentEnvelopeEditor();
const signersMissingSignatureFields = useMemo(() => {
if (!isTemplate || !envelope.directLink?.enabled) {
return [];
}
return getRecipientsWithMissingFields(envelope.recipients, envelope.fields);
}, [isTemplate, envelope.directLink, envelope.recipients, envelope.fields]);
if (signersMissingSignatureFields.length === 0) {
return null;
}
return (
<Alert
variant="destructive"
className={cn('mx-auto w-full max-w-[800px] flex-row items-start gap-3 rounded-sm', className)}
>
<AlertTitle>
<Trans>Invalid direct link template</Trans>
</AlertTitle>
<AlertDescription>
<Trans>
Recipients cannot use this direct link template because the following signers are missing a signature field
</Trans>
<ul className="list-disc pl-5">
{signersMissingSignatureFields.map((recipient, i) => (
<li key={recipient.id}>{recipient.email || recipient.name || `Recipient ${i + 1}`}</li>
))}
</ul>
</AlertDescription>
</Alert>
);
};
@@ -22,7 +22,6 @@ import { match } from 'ts-pattern';
import { EnvelopeGenericPageRenderer } from '~/components/general/envelope-editor/envelope-generic-page-renderer';
import { EnvelopePdfViewer } from '~/components/general/pdf-viewer/envelope-pdf-viewer';
import { EnvelopeEditorInvalidDirectTemplateAlert } from './envelope-editor-invalid-direct-template-alert';
import { EnvelopeRendererFileSelector } from './envelope-file-selector';
export const EnvelopeEditorPreviewPage = () => {
@@ -229,8 +228,6 @@ export const EnvelopeEditorPreviewPage = () => {
{/* Horizontal envelope item selector */}
<EnvelopeRendererFileSelector className="px-0" fields={editorFields.localFields} />
<EnvelopeEditorInvalidDirectTemplateAlert className="mb-4" />
<Alert variant="warning" className="mx-auto max-w-[800px]">
<AlertTitle>
<Trans>Preview Mode</Trans>
@@ -26,7 +26,6 @@ import { ErrorCode as DropzoneErrorCode, type FileRejection, useDropzone } from
import { EnvelopeItemDeleteDialog } from '~/components/dialogs/envelope-item-delete-dialog';
import { EnvelopeEditorInvalidDirectTemplateAlert } from './envelope-editor-invalid-direct-template-alert';
import { EnvelopeEditorRecipientForm } from './envelope-editor-recipient-form';
import { EnvelopeItemTitleInput } from './envelope-editor-title-input';
@@ -450,9 +449,6 @@ export const EnvelopeEditorUploadPage = () => {
return (
<div className="mx-auto max-w-4xl space-y-6 p-8">
<input {...getReplaceInputProps()} />
<EnvelopeEditorInvalidDirectTemplateAlert className="max-w-none" />
<Card backdropBlur={false} className="border">
<CardHeader className="pb-3">
<CardTitle>
@@ -1,137 +0,0 @@
import { useSession } from '@documenso/lib/client-only/providers/session';
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
import { canExecuteOrganisationAction, isPersonalLayout } from '@documenso/lib/utils/organisations';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import { Trans } from '@lingui/react/macro';
import { BracesIcon, CreditCardIcon, Globe2Icon, Lock, Settings2Icon, User, Users, WebhookIcon } from 'lucide-react';
import type { HTMLAttributes } from 'react';
import { Link, useLocation } from 'react-router';
export type SettingsDesktopNavProps = HTMLAttributes<HTMLDivElement>;
export const SettingsDesktopNav = ({ className, ...props }: SettingsDesktopNavProps) => {
const { pathname } = useLocation();
const { organisations } = useSession();
const isPersonalLayoutMode = isPersonalLayout(organisations);
const hasManageableBillingOrgs = organisations.some((org) =>
canExecuteOrganisationAction('MANAGE_BILLING', org.currentOrganisationRole),
);
return (
<div className={cn('flex flex-col gap-y-2', className)} {...props}>
<Link to="/settings/profile">
<Button
variant="ghost"
className={cn('w-full justify-start', pathname?.startsWith('/settings/profile') && 'bg-secondary')}
>
<User className="mr-2 h-5 w-5" />
<Trans>Profile</Trans>
</Button>
</Link>
{isPersonalLayoutMode && (
<>
<Link to="/settings/document">
<Button variant="ghost" className={cn('w-full justify-start')}>
<Settings2Icon className="mr-2 h-5 w-5" />
<Trans>Preferences</Trans>
</Button>
</Link>
<Link className="w-full pl-8" to="/settings/document">
<Button
variant="ghost"
className={cn('w-full justify-start', pathname?.startsWith('/settings/document') && 'bg-secondary')}
>
<Trans>Document</Trans>
</Button>
</Link>
<Link className="w-full pl-8" to="/settings/branding">
<Button
variant="ghost"
className={cn('w-full justify-start', pathname?.startsWith('/settings/branding') && 'bg-secondary')}
>
<Trans>Branding</Trans>
</Button>
</Link>
<Link className="w-full pl-8" to="/settings/email">
<Button
variant="ghost"
className={cn('w-full justify-start', pathname?.startsWith('/settings/email') && 'bg-secondary')}
>
<Trans>Email</Trans>
</Button>
</Link>
<Link to="/settings/public-profile">
<Button
variant="ghost"
className={cn('w-full justify-start', pathname?.startsWith('/settings/public-profile') && 'bg-secondary')}
>
<Globe2Icon className="mr-2 h-5 w-5" />
<Trans>Public Profile</Trans>
</Button>
</Link>
<Link to="/settings/tokens">
<Button
variant="ghost"
className={cn('w-full justify-start', pathname?.startsWith('/settings/tokens') && 'bg-secondary')}
>
<BracesIcon className="mr-2 h-5 w-5" />
<Trans>API Tokens</Trans>
</Button>
</Link>
<Link to="/settings/webhooks">
<Button
variant="ghost"
className={cn('w-full justify-start', pathname?.startsWith('/settings/webhooks') && 'bg-secondary')}
>
<WebhookIcon className="mr-2 h-5 w-5" />
<Trans>Webhooks</Trans>
</Button>
</Link>
</>
)}
<Link to="/settings/organisations">
<Button
variant="ghost"
className={cn('w-full justify-start', pathname?.startsWith('/settings/organisations') && 'bg-secondary')}
>
<Users className="mr-2 h-5 w-5" />
<Trans>Organisations</Trans>
</Button>
</Link>
{IS_BILLING_ENABLED() && hasManageableBillingOrgs && (
<Link to={isPersonalLayoutMode ? '/settings/billing-personal' : `/settings/billing`}>
<Button
variant="ghost"
className={cn('w-full justify-start', pathname?.startsWith('/settings/billing') && 'bg-secondary')}
>
<CreditCardIcon className="mr-2 h-5 w-5" />
<Trans>Billing</Trans>
</Button>
</Link>
)}
<Link to="/settings/security">
<Button
variant="ghost"
className={cn('w-full justify-start', pathname?.startsWith('/settings/security') && 'bg-secondary')}
>
<Lock className="mr-2 h-5 w-5" />
<Trans>Security</Trans>
</Button>
</Link>
</div>
);
};
@@ -1,144 +0,0 @@
import { useSession } from '@documenso/lib/client-only/providers/session';
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
import { canExecuteOrganisationAction, isPersonalLayout } from '@documenso/lib/utils/organisations';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import { Trans } from '@lingui/react/macro';
import {
BracesIcon,
CreditCardIcon,
Globe2Icon,
Lock,
MailIcon,
PaletteIcon,
Settings2Icon,
User,
Users,
WebhookIcon,
} from 'lucide-react';
import type { HTMLAttributes } from 'react';
import { Link, useLocation } from 'react-router';
export type SettingsMobileNavProps = HTMLAttributes<HTMLDivElement>;
export const SettingsMobileNav = ({ className, ...props }: SettingsMobileNavProps) => {
const { pathname } = useLocation();
const { organisations } = useSession();
const isPersonalLayoutMode = isPersonalLayout(organisations);
const hasManageableBillingOrgs = organisations.some((org) =>
canExecuteOrganisationAction('MANAGE_BILLING', org.currentOrganisationRole),
);
return (
<div className={cn('flex flex-wrap items-center justify-start gap-x-2 gap-y-4', className)} {...props}>
<Link to="/settings/profile">
<Button
variant="ghost"
className={cn('w-full justify-start', pathname?.startsWith('/settings/profile') && 'bg-secondary')}
>
<User className="mr-2 h-5 w-5" />
<Trans>Profile</Trans>
</Button>
</Link>
{isPersonalLayoutMode && (
<>
<Link to="/settings/document">
<Button
variant="ghost"
className={cn('w-full justify-start', pathname?.startsWith('/settings/document') && 'bg-secondary')}
>
<Settings2Icon className="mr-2 h-5 w-5" />
<Trans>Document Preferences</Trans>
</Button>
</Link>
<Link to="/settings/branding">
<Button
variant="ghost"
className={cn('w-full justify-start', pathname?.startsWith('/settings/branding') && 'bg-secondary')}
>
<PaletteIcon className="mr-2 h-5 w-5" />
<Trans>Branding Preferences</Trans>
</Button>
</Link>
<Link to="/settings/email">
<Button
variant="ghost"
className={cn('w-full justify-start', pathname?.startsWith('/settings/email') && 'bg-secondary')}
>
<MailIcon className="mr-2 h-5 w-5" />
<Trans>Email Preferences</Trans>
</Button>
</Link>
<Link to="/settings/public-profile">
<Button
variant="ghost"
className={cn('w-full justify-start', pathname?.startsWith('/settings/public-profile') && 'bg-secondary')}
>
<Globe2Icon className="mr-2 h-5 w-5" />
<Trans>Public Profile</Trans>
</Button>
</Link>
<Link to="/settings/tokens">
<Button
variant="ghost"
className={cn('w-full justify-start', pathname?.startsWith('/settings/tokens') && 'bg-secondary')}
>
<BracesIcon className="mr-2 h-5 w-5" />
<Trans>API Tokens</Trans>
</Button>
</Link>
<Link to="/settings/webhooks">
<Button
variant="ghost"
className={cn('w-full justify-start', pathname?.startsWith('/settings/webhooks') && 'bg-secondary')}
>
<WebhookIcon className="mr-2 h-5 w-5" />
<Trans>Webhooks</Trans>
</Button>
</Link>
</>
)}
<Link to="/settings/organisations">
<Button
variant="ghost"
className={cn('w-full justify-start', pathname?.startsWith('/settings/organisations') && 'bg-secondary')}
>
<Users className="mr-2 h-5 w-5" />
<Trans>Organisations</Trans>
</Button>
</Link>
{IS_BILLING_ENABLED() && hasManageableBillingOrgs && (
<Link to={isPersonalLayoutMode ? '/settings/billing-personal' : `/settings/billing`}>
<Button
variant="ghost"
className={cn('w-full justify-start', pathname?.startsWith('/settings/billing') && 'bg-secondary')}
>
<CreditCardIcon className="mr-2 h-5 w-5" />
<Trans>Billing</Trans>
</Button>
</Link>
)}
<Link to="/settings/security">
<Button
variant="ghost"
className={cn('w-full justify-start', pathname?.startsWith('/settings/security') && 'bg-secondary')}
>
<Lock className="mr-2 h-5 w-5" />
<Trans>Security</Trans>
</Button>
</Link>
</div>
);
};
@@ -0,0 +1,62 @@
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import type { LucideIcon } from 'lucide-react';
import { NavLink } from 'react-router';
export type SettingsNavRoute = {
path: string;
label: string;
icon?: LucideIcon;
/**
* Renders the route as a non-interactive group label, only visible on desktop.
*/
isSectionLabel?: boolean;
/**
* Indents the route under the preceding section label on desktop.
*/
isSubNav?: boolean;
/**
* Only mark the route as active on an exact path match.
*/
end?: boolean;
};
export type SettingsNavProps = {
routes: SettingsNavRoute[];
className?: string;
};
export const SettingsNav = ({ routes, className }: SettingsNavProps) => {
return (
<nav
className={cn(
'flex flex-wrap items-center justify-start gap-x-2 gap-y-4 md:w-full md:flex-col md:items-start md:gap-y-2',
className,
)}
>
{routes.map((route) =>
route.isSectionLabel ? (
<div
key={`${route.path}-${route.label}`}
className="flex h-10 w-full items-center px-4 font-medium text-sm max-md:hidden"
>
{route.icon && <route.icon className="mr-2 h-5 w-5" />}
{route.label}
</div>
) : (
<NavLink
key={`${route.path}-${route.label}`}
to={route.path}
end={route.end}
className={cn('group justify-start md:w-full', route.isSubNav && 'md:pl-8')}
>
<Button variant="ghost" className="w-full justify-start group-aria-[current]:bg-secondary">
{route.icon && <route.icon className="mr-2 h-5 w-5" />}
{route.label}
</Button>
</NavLink>
),
)}
</nav>
);
};
@@ -102,7 +102,9 @@ export const OrganisationEmailDomainsDataTable = () => {
cell: ({ row }) => (
<div className="flex justify-end space-x-2">
<Button asChild variant="outline">
<Link to={`/o/${organisation.url}/settings/email-domains/${row.original.id}`}>Manage</Link>
<Link to={`/o/${organisation.url}/settings/email-domains/${row.original.id}`}>
<Trans>Manage</Trans>
</Link>
</Button>
<OrganisationEmailDomainDeleteDialog
@@ -1,7 +1,6 @@
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
import { canExecuteOrganisationAction } from '@documenso/lib/utils/organisations';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import { msg } from '@lingui/core/macro';
import { Trans, useLingui } from '@lingui/react/macro';
@@ -13,11 +12,12 @@ import {
Settings2Icon,
ShieldCheckIcon,
Users2Icon,
UsersIcon,
} from 'lucide-react';
import { FaUsers } from 'react-icons/fa6';
import { Link, NavLink, Outlet } from 'react-router';
import { Link, Outlet } from 'react-router';
import { GenericErrorLayout } from '~/components/general/generic-error-layout';
import { SettingsNav, type SettingsNavRoute } from '~/components/general/settings-nav';
import { appMetaTags } from '~/utils/meta';
export function meta() {
@@ -30,7 +30,7 @@ export default function SettingsLayout() {
const isBillingEnabled = IS_BILLING_ENABLED();
const organisation = useCurrentOrganisation();
const organisationSettingRoutes = [
const organisationSettingRoutes: SettingsNavRoute[] = [
{
path: `/o/${organisation.url}/settings/general`,
label: t`General`,
@@ -40,7 +40,7 @@ export default function SettingsLayout() {
path: `/o/${organisation.url}/settings/document`,
label: t`Preferences`,
icon: Settings2Icon,
hideHighlight: true,
isSectionLabel: true,
},
{
path: `/o/${organisation.url}/settings/document`,
@@ -65,7 +65,7 @@ export default function SettingsLayout() {
{
path: `/o/${organisation.url}/settings/teams`,
label: t`Teams`,
icon: FaUsers,
icon: UsersIcon,
},
{
path: `/o/${organisation.url}/settings/members`,
@@ -139,32 +139,9 @@ export default function SettingsLayout() {
</h1>
<div className="mt-4 grid grid-cols-12 gap-x-8 md:mt-8">
{/* Navigation */}
<div
className={cn(
'col-span-12 mb-8 flex flex-wrap items-center justify-start gap-x-2 gap-y-4 md:col-span-3 md:w-full md:flex-col md:items-start md:gap-y-2',
)}
>
{organisationSettingRoutes.map((route) => (
<NavLink
to={route.path}
className={cn('group w-full justify-start', route.isSubNav && 'pl-8')}
key={route.path}
>
<Button
variant="ghost"
className={cn('w-full justify-start', {
'group-aria-[current]:bg-secondary': !route.hideHighlight,
})}
>
{route.icon && <route.icon className="mr-2 h-5 w-5" />}
<Trans>{route.label}</Trans>
</Button>
</NavLink>
))}
</div>
<SettingsNav routes={organisationSettingRoutes} className="col-span-12 mb-8 md:col-span-3 md:mb-0" />
<div className="col-span-12 md:col-span-9">
<div className="col-span-12 max-w-3xl md:col-span-9">
<Outlet />
</div>
</div>
@@ -89,9 +89,9 @@ export default function TeamsSettingBillingPage() {
return (
<div>
<div className="flex flex-row items-end justify-between">
<div className="flex flex-row items-center justify-between">
<div>
<h3 className="font-semibold text-2xl">
<h3 className="font-medium text-lg">
<Trans>Billing</Trans>
</h3>
@@ -123,13 +123,13 @@ export default function OrganisationSettingsBrandingPage() {
const settingsHeaderText = t`Branding Preferences`;
const settingsHeaderSubtitle = isPersonalLayoutMode
? t`Here you can set your general branding preferences.`
? t`Set the default branding for documents you send.`
: team
? t`Here you can set branding preferences for your team.`
: t`Here you can set branding preferences for your organisation. Teams will inherit these settings by default.`;
? t`Set the default branding for documents sent by your team.`
: t`Set the default branding for your organisation. Teams inherit these settings by default.`;
return (
<div className="max-w-2xl">
<div>
<SettingsHeader title={settingsHeaderText} subtitle={settingsHeaderSubtitle} />
{organisationWithSettings.organisationClaim.flags.allowCustomBranding || !IS_BILLING_ENABLED() ? (
@@ -120,11 +120,11 @@ export default function OrganisationSettingsDocumentPage() {
const settingsHeaderText = t`Document Preferences`;
const settingsHeaderSubtitle = isPersonalLayoutMode
? t`Here you can set your general document preferences.`
: t`Here you can set document preferences for your organisation. Teams will inherit these settings by default.`;
? t`Set your default document settings.`
: t`Set the default document settings for your organisation. Teams inherit these settings by default.`;
return (
<div className="max-w-2xl">
<div>
<SettingsHeader title={settingsHeaderText} subtitle={settingsHeaderSubtitle} />
<section>
@@ -132,7 +132,7 @@ export default function OrganisationEmailDomainSettingsPage({ params }: Route.Co
return (
<div>
<SettingsHeader title={t`Email Domain Settings`} subtitle={t`Manage your email domain settings.`}>
<SettingsHeader title={t`Email Domain Settings`} subtitle={t`Manage the email addresses for this domain.`}>
<OrganisationEmailCreateDialog emailDomain={emailDomain} />
</SettingsHeader>
@@ -33,7 +33,7 @@ export default function OrganisationSettingsEmailDomains() {
return (
<div>
<SettingsHeader title={t`Email Domains`} subtitle={t`Here you can add email domains to your organisation.`}>
<SettingsHeader title={t`Email Domains`} subtitle={t`Add and verify email domains for your organisation.`}>
{isEmailDomainsEnabled && <OrganisationEmailDomainCreateDialog />}
</SettingsHeader>
@@ -1,4 +1,6 @@
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { isPersonalLayout } from '@documenso/lib/utils/organisations';
import { trpc } from '@documenso/trpc/react';
import { SpinnerBox } from '@documenso/ui/primitives/spinner';
import { useToast } from '@documenso/ui/primitives/use-toast';
@@ -13,12 +15,16 @@ export function meta() {
return appMetaTags(msg`Email Preferences`);
}
export default function OrganisationSettingsGeneral() {
export default function OrganisationSettingsEmailPage() {
const { t } = useLingui();
const { toast } = useToast();
const organisation = useCurrentOrganisation();
const { organisations } = useSession();
const isPersonalLayoutMode = isPersonalLayout(organisations);
const { data: organisationWithSettings, isLoading: isLoadingOrganisation } = trpc.organisation.get.useQuery({
organisationReference: organisation.url,
});
@@ -59,8 +65,15 @@ export default function OrganisationSettingsGeneral() {
}
return (
<div className="max-w-2xl">
<SettingsHeader title={t`Email Preferences`} subtitle={t`You can manage your email preferences here.`} />
<div>
<SettingsHeader
title={t`Email Preferences`}
subtitle={
isPersonalLayoutMode
? t`Manage your default email settings.`
: t`Manage the default email settings for your organisation. Teams inherit these settings by default.`
}
/>
<section>
<EmailPreferencesForm
@@ -21,8 +21,8 @@ export default function OrganisationSettingsGeneral() {
const organisation = useCurrentOrganisation();
return (
<div className="max-w-2xl">
<SettingsHeader title={_(msg`General`)} subtitle={_(msg`Here you can edit your organisation details.`)} />
<div>
<SettingsHeader title={_(msg`General`)} subtitle={_(msg`Update your organisation's details.`)} />
<div className="space-y-8">
<AvatarImageForm organisation={organisation} />
@@ -94,7 +94,7 @@ export default function OrganisationGroupSettingsPage({ params }: Route.Componen
return (
<div>
<SettingsHeader title={t`Organisation Group Settings`} subtitle={t`Manage your organisation group settings.`}>
<SettingsHeader title={t`Group Settings`} subtitle={t`Update the group's name, role and members.`}>
<OrganisationGroupDeleteDialog
organisationGroupId={groupId}
organisationGroupName={group.name || ''}
@@ -10,7 +10,7 @@ export default function TeamsSettingsMembersPage() {
return (
<div>
<SettingsHeader
title={t`Custom Organisation Groups`}
title={t`Organisation Groups`}
subtitle={t`Manage the custom groups of members for your organisation.`}
>
<OrganisationGroupCreateDialog />
@@ -46,7 +46,10 @@ export default function TeamsSettingsMembersPage() {
return (
<div>
<SettingsHeader title={_(msg`Organisation Members`)} subtitle={_(msg`Manage the members or invite new members.`)}>
<SettingsHeader
title={_(msg`Organisation Members`)}
subtitle={_(msg`Manage the members and invitations for your organisation.`)}
>
<OrganisationMemberInviteDialog />
</SettingsHeader>
@@ -73,7 +73,7 @@ export default function OrganisationSettingSSOLoginPage() {
}
return (
<div className="max-w-2xl">
<div>
<SettingsHeader
title={t`Organisation SSO Portal`}
subtitle={t`Manage a custom SSO login portal for your organisation.`}
@@ -1,9 +1,21 @@
import { useSession } from '@documenso/lib/client-only/providers/session';
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
import { canExecuteOrganisationAction, isPersonalLayout } from '@documenso/lib/utils/organisations';
import { msg } from '@lingui/core/macro';
import { Trans } from '@lingui/react/macro';
import { Trans, useLingui } from '@lingui/react/macro';
import {
BracesIcon,
CreditCardIcon,
Globe2Icon,
LockIcon,
Settings2Icon,
UserIcon,
UsersIcon,
WebhookIcon,
} from 'lucide-react';
import { Outlet } from 'react-router';
import { SettingsDesktopNav } from '~/components/general/settings-nav-desktop';
import { SettingsMobileNav } from '~/components/general/settings-nav-mobile';
import { SettingsNav, type SettingsNavRoute } from '~/components/general/settings-nav';
import { appMetaTags } from '~/utils/meta';
export function meta() {
@@ -11,6 +23,83 @@ export function meta() {
}
export default function SettingsLayout() {
const { t } = useLingui();
const { organisations } = useSession();
const isPersonalLayoutMode = isPersonalLayout(organisations);
const hasManageableBillingOrgs = organisations.some((org) =>
canExecuteOrganisationAction('MANAGE_BILLING', org.currentOrganisationRole),
);
const settingRoutes: SettingsNavRoute[] = [
{
path: '/settings/profile',
label: t`Profile`,
icon: UserIcon,
},
...(isPersonalLayoutMode
? [
{
path: '/settings/document',
label: t`Preferences`,
icon: Settings2Icon,
isSectionLabel: true,
},
{
path: '/settings/document',
label: t`Document`,
isSubNav: true,
},
{
path: '/settings/branding',
label: t`Branding`,
isSubNav: true,
},
{
path: '/settings/email',
label: t`Email`,
isSubNav: true,
},
{
path: '/settings/public-profile',
label: t`Public Profile`,
icon: Globe2Icon,
},
{
path: '/settings/tokens',
label: t`API Tokens`,
icon: BracesIcon,
},
{
path: '/settings/webhooks',
label: t`Webhooks`,
icon: WebhookIcon,
},
]
: []),
{
path: '/settings/organisations',
label: t`Organisations`,
icon: UsersIcon,
},
...(IS_BILLING_ENABLED() && hasManageableBillingOrgs
? [
{
path: isPersonalLayoutMode ? '/settings/billing-personal' : '/settings/billing',
label: t`Billing`,
icon: CreditCardIcon,
},
]
: []),
{
path: '/settings/security',
label: t`Security`,
icon: LockIcon,
},
];
return (
<div className="mx-auto w-full max-w-screen-xl px-4 md:px-8">
<h1 className="font-semibold text-4xl">
@@ -18,10 +107,9 @@ export default function SettingsLayout() {
</h1>
<div className="mt-4 grid grid-cols-12 gap-x-8 md:mt-8">
<SettingsDesktopNav className="hidden md:col-span-3 md:flex" />
<SettingsMobileNav className="col-span-12 mb-8 md:hidden" />
<SettingsNav routes={settingRoutes} className="col-span-12 mb-8 md:col-span-3 md:mb-0" />
<div className="col-span-12 md:col-span-9">
<div className="col-span-12 max-w-3xl md:col-span-9">
<Outlet />
</div>
</div>
@@ -11,10 +11,7 @@ export default function TeamsSettingsPage() {
return (
<div>
<SettingsHeader
title={_(msg`Organisations`)}
subtitle={_(msg`Manage all organisations you are currently associated with.`)}
>
<SettingsHeader title={_(msg`Organisations`)} subtitle={_(msg`Manage the organisations you belong to.`)}>
<OrganisationCreateDialog />
</SettingsHeader>
@@ -27,14 +27,14 @@ export default function SettingsProfile() {
return (
<div>
<SettingsHeader title={_(msg`Profile`)} subtitle={_(msg`Here you can edit your personal details.`)} />
<SettingsHeader title={_(msg`Profile`)} subtitle={_(msg`Update your personal details.`)} />
<AvatarImageForm className="mb-8 max-w-xl" />
<ProfileForm className="mb-8 max-w-xl" />
<AvatarImageForm className="mb-8" />
<ProfileForm className="mb-8" />
<hr className="my-4 max-w-xl" />
<hr className="my-4" />
<div className="max-w-xl space-y-8">
<div className="space-y-8">
<AnimatePresence>
{(!isPersonalLayoutMode || user.email !== teamEmail?.email) && teamEmail && (
<AnimateGenericFadeInOut>
@@ -62,10 +62,7 @@ export default function SettingsSecurity({ loaderData }: Route.ComponentProps) {
return (
<div>
<SettingsHeader
title={_(msg`Security`)}
subtitle={_(msg`Here you can manage your password and security settings.`)}
/>
<SettingsHeader title={_(msg`Security`)} subtitle={_(msg`Manage your password and security settings.`)} />
{hasEmailPasswordAccount && (
<>
<PasswordForm user={user} />
@@ -15,7 +15,7 @@ export default function SettingsSecurityActivity() {
return (
<div>
<SettingsHeader
title={_(msg`Security activity`)}
title={_(msg`Security Activity`)}
subtitle={_(msg`View all security activity related to your account.`)}
hideDivider={true}
/>
@@ -94,7 +94,7 @@ export default function SettingsSecuritySessions() {
return (
<div>
<SettingsHeader title={t`Active sessions`} subtitle={t`View and manage all active sessions for your account.`}>
<SettingsHeader title={t`Active Sessions`} subtitle={t`View and manage all active sessions for your account.`}>
<SessionLogoutAllDialog onSuccess={refetch} disabled={results.length === 1 || isLoading} />
</SettingsHeader>
@@ -5,7 +5,7 @@ import { extractInitials } from '@documenso/lib/utils/recipient-formatter';
import { canExecuteTeamAction } from '@documenso/lib/utils/teams';
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
import { AvatarWithText } from '@documenso/ui/primitives/avatar';
import { Trans } from '@lingui/react/macro';
import { Trans, useLingui } from '@lingui/react/macro';
import { CheckCircle2, Clock } from 'lucide-react';
import { match, P } from 'ts-pattern';
@@ -33,13 +33,15 @@ export async function loader({ request, params }: Route.LoaderArgs) {
}
export default function TeamsSettingsPage({ loaderData }: Route.ComponentProps) {
const { t } = useLingui();
const { team } = loaderData;
const currentTeam = useCurrentTeam();
return (
<div className="max-w-2xl">
<SettingsHeader title="General settings" subtitle="Here you can edit your team's details." />
<div>
<SettingsHeader title={t`General`} subtitle={t`Update your team's details.`} />
<AvatarImageForm team={currentTeam} className="mb-8" />
@@ -1,14 +1,14 @@
import { getSession } from '@documenso/auth/server/lib/utils/get-session';
import { getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
import { canExecuteTeamAction } from '@documenso/lib/utils/teams';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import { msg } from '@lingui/core/macro';
import { Trans, useLingui } from '@lingui/react/macro';
import { BracesIcon, Globe2Icon, GroupIcon, Settings2Icon, SettingsIcon, Users2Icon, WebhookIcon } from 'lucide-react';
import { Link, NavLink, Outlet, redirect } from 'react-router';
import { Link, Outlet, redirect } from 'react-router';
import { GenericErrorLayout } from '~/components/general/generic-error-layout';
import { SettingsNav, type SettingsNavRoute } from '~/components/general/settings-nav';
import { useCurrentTeam } from '~/providers/team';
import { appMetaTags } from '~/utils/meta';
@@ -40,17 +40,18 @@ export default function TeamsSettingsLayout() {
const team = useCurrentTeam();
const teamSettingRoutes = [
const teamSettingRoutes: SettingsNavRoute[] = [
{
path: `/t/${team.url}/settings`,
label: t`General`,
icon: SettingsIcon,
end: true,
},
{
path: `/t/${team.url}/settings/document`,
label: t`Preferences`,
icon: Settings2Icon,
isSubNavParent: true,
isSectionLabel: true,
},
{
path: `/t/${team.url}/settings/document`,
@@ -124,31 +125,9 @@ export default function TeamsSettingsLayout() {
</h1>
<div className="mt-4 grid grid-cols-12 gap-x-8 md:mt-8">
<div
className={cn(
'col-span-12 mb-8 flex flex-wrap items-center justify-start gap-x-2 gap-y-4 md:col-span-3 md:w-full md:flex-col md:items-start md:gap-y-2',
)}
>
{teamSettingRoutes.map((route) => (
<NavLink
to={route.path}
className={cn('group w-full justify-start', route.isSubNav && 'pl-8')}
key={route.path}
>
<Button
variant="ghost"
className={cn('w-full justify-start', {
'group-aria-[current]:bg-secondary': !route.isSubNavParent,
})}
>
{route.icon && <route.icon className="mr-2 h-5 w-5" />}
<Trans>{route.label}</Trans>
</Button>
</NavLink>
))}
</div>
<SettingsNav routes={teamSettingRoutes} className="col-span-12 mb-8 md:col-span-3 md:mb-0" />
<div className="col-span-12 md:col-span-9">
<div className="col-span-12 max-w-3xl md:col-span-9">
<Outlet />
</div>
</div>
@@ -115,10 +115,10 @@ export default function TeamsSettingsPage() {
}
return (
<div className="max-w-2xl">
<div>
<SettingsHeader
title={t`Branding Preferences`}
subtitle={t`Here you can set preferences and defaults for branding.`}
subtitle={t`Set the default branding for documents sent by your team.`}
/>
{canConfigureBranding ? (
@@ -110,11 +110,8 @@ export default function TeamsSettingsPage() {
}
return (
<div className="max-w-2xl">
<SettingsHeader
title={t`Document Preferences`}
subtitle={t`Here you can set preferences and defaults for your team.`}
/>
<div>
<SettingsHeader title={t`Document Preferences`} subtitle={t`Set the default document settings for your team.`} />
<section>
<DocumentPreferencesForm
@@ -10,10 +10,10 @@ import { useCurrentTeam } from '~/providers/team';
import { appMetaTags } from '~/utils/meta';
export function meta() {
return appMetaTags(msg`Settings`);
return appMetaTags(msg`Email Preferences`);
}
export default function TeamEmailSettingsGeneral() {
export default function TeamEmailSettingsPage() {
const { t } = useLingui();
const { toast } = useToast();
@@ -59,8 +59,8 @@ export default function TeamEmailSettingsGeneral() {
}
return (
<div className="max-w-2xl">
<SettingsHeader title={t`Email Preferences`} subtitle={t`You can manage your email preferences here.`} />
<div>
<SettingsHeader title={t`Email Preferences`} subtitle={t`Manage the default email settings for your team.`} />
<section>
<EmailPreferencesForm
@@ -128,11 +128,8 @@ export default function PublicProfilePage({ loaderData }: Route.ComponentProps)
}, [profile.enabled]);
return (
<div className="max-w-2xl">
<SettingsHeader
title={t`Public Profile`}
subtitle={t`You can choose to enable or disable the profile for public view.`}
>
<div>
<SettingsHeader title={t`Public Profile`} subtitle={t`Manage your public profile and the templates shown on it.`}>
<Tooltip open={isTooltipOpen} onOpenChange={setIsTooltipOpen}>
<TooltipTrigger asChild>
<div
@@ -190,7 +187,7 @@ export default function PublicProfilePage({ loaderData }: Route.ComponentProps)
<div className="mt-4">
<SettingsHeader
title={t`Templates`}
subtitle={t`Show templates in your public profile for your audience to sign and get started quickly`}
subtitle={t`Show templates in your public profile for your audience to sign and get started quickly.`}
hideDivider={true}
className="mt-8 [&>*>h3]:text-base"
>
@@ -30,7 +30,7 @@ export default function ApiTokensPage() {
title={<Trans>API Tokens</Trans>}
subtitle={
<Trans>
On this page, you can create and manage API tokens. See our{' '}
Create and manage API tokens. See our{' '}
<a
className="text-primary underline"
href={'https://docs.documenso.com/developers/public-api'}
@@ -57,7 +57,7 @@ export default function ApiTokensPage() {
</Alert>
) : (
<>
<ApiTokenForm className="max-w-xl" tokens={tokens} />
<ApiTokenForm tokens={tokens} />
<hr className="mt-8 mb-4" />
@@ -74,7 +74,7 @@ export default function ApiTokensPage() {
)}
{tokens && tokens.length > 0 && (
<div className="mt-4 flex max-w-xl flex-col gap-y-4">
<div className="mt-4 flex flex-col gap-y-4">
{tokens.map((token) => (
<div key={token.id} className="rounded-lg border border-border p-4">
<div className="flex items-center justify-between gap-x-4">
@@ -88,10 +88,7 @@ export default function WebhookPage() {
return (
<div>
<SettingsHeader
title={t`Webhooks`}
subtitle={t`On this page, you can create new Webhooks and manage the existing ones.`}
>
<SettingsHeader title={t`Webhooks`} subtitle={t`Create and manage webhooks for document events.`}>
<WebhookCreateDialog />
</SettingsHeader>
{isLoading && (
@@ -7,7 +7,6 @@ import { getEnvelopeForDirectTemplateSigning } from '@documenso/lib/server-only/
import { getTemplateByDirectLinkToken } from '@documenso/lib/server-only/template/get-template-by-direct-link-token';
import { DocumentAccessAuth } from '@documenso/lib/types/document-auth';
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
import { getRecipientsWithMissingFields } from '@documenso/lib/utils/recipients';
import { prisma } from '@documenso/prisma';
import { Plural } from '@lingui/react/macro';
import { UsersIcon } from 'lucide-react';
@@ -15,7 +14,6 @@ import { redirect } from 'react-router';
import { match } from 'ts-pattern';
import { Header as AuthenticatedHeader } from '~/components/general/app-header';
import { DirectTemplateInvalidPageView } from '~/components/general/direct-template/direct-template-invalid-page';
import { DirectTemplatePageView } from '~/components/general/direct-template/direct-template-page';
import { DirectTemplateAuthPageView } from '~/components/general/direct-template/direct-template-signing-auth-page';
import { DocumentSigningAuthPageView } from '~/components/general/document-signing/document-signing-auth-page';
@@ -72,18 +70,8 @@ const handleV1Loader = async ({ params, request }: Route.LoaderArgs) => {
};
}
const recipientsWithMissingFields = getRecipientsWithMissingFields(template.recipients, template.fields);
if (recipientsWithMissingFields.length > 0) {
return {
isAccessAuthValid: true,
isTemplateMissingSignatures: true,
} as const;
}
return {
isAccessAuthValid: true,
isTemplateMissingSignatures: false,
template: {
...template,
folder: null,
@@ -108,7 +96,6 @@ const handleV2Loader = async ({ params, request }: Route.LoaderArgs) => {
.then((envelopeForSigning) => {
return {
isDocumentAccessValid: true,
isTemplateMissingSignatures: false,
envelopeForSigning,
} as const;
})
@@ -121,13 +108,6 @@ const handleV2Loader = async ({ params, request }: Route.LoaderArgs) => {
} as const;
}
if (error.code === AppErrorCode.MISSING_SIGNATURE_FIELD) {
return {
isDocumentAccessValid: true,
isTemplateMissingSignatures: true,
} as const;
}
throw new Response('Not Found', { status: 404 });
});
};
@@ -201,10 +181,6 @@ const DirectSigningPageV1 = ({ data }: { data: Awaited<ReturnType<typeof handleV
return <DirectTemplateAuthPageView />;
}
if (data.isTemplateMissingSignatures) {
return <DirectTemplateInvalidPageView />;
}
const { template, directTemplateRecipient } = data;
return (
@@ -259,10 +235,6 @@ const DirectSigningPageV2 = ({ data }: { data: Awaited<ReturnType<typeof handleV
return <DocumentSigningAuthPageView email={''} emailHasAccount={true} />;
}
if (data.isTemplateMissingSignatures) {
return <DirectTemplateInvalidPageView />;
}
const { envelope, recipient } = data.envelopeForSigning;
const { derivedRecipientAccessAuth } = extractDocumentAuthMethods({
@@ -32,10 +32,6 @@ export const getDirectTemplateErrorMessage = (code: string): ToastMessageDescrip
return match(code)
.with('RECIPIENT_LIMIT_EXCEEDED', () => RECIPIENT_LIMIT_EXCEEDED_ERROR_MESSAGE)
.with(AppErrorCode.TOO_MANY_REQUESTS, () => FAIR_USE_LIMIT_EXCEEDED_ERROR_MESSAGE)
.with(AppErrorCode.MISSING_SIGNATURE_FIELD, () => ({
title: msg`Missing signature fields`,
description: msg`This direct link template cannot be used because one or more signers do not have a signature field assigned.`,
}))
.otherwise(() => ({
title: msg`Something went wrong`,
description: msg`We were unable to submit this document at this time. Please try again later.`,
@@ -81,10 +77,6 @@ export const getTemplateUseErrorMessage = (code: string): ToastMessageDescriptor
title: msg`Error`,
description: msg`The document was created but could not be sent to recipients.`,
}))
.with(AppErrorCode.MISSING_SIGNATURE_FIELD, () => ({
title: msg`Missing signature fields`,
description: msg`The document could not be sent because some signers do not have a signature field. Please edit the template and add a signature field for each signer.`,
}))
.with(AppErrorCode.INVALID_BODY, AppErrorCode.INVALID_REQUEST, () => ({
title: msg`Error`,
description: msg`The document could not be created because of missing or invalid information. Please review the template's recipients and fields.`,
+4 -4
View File
@@ -36,8 +36,8 @@
"@lingui/react": "^5.6.0",
"@oslojs/crypto": "^1.0.1",
"@oslojs/encoding": "^1.1.0",
"@react-router/node": "^7.18.1",
"@react-router/serve": "^7.18.1",
"@react-router/node": "^7.12.0",
"@react-router/serve": "^7.12.0",
"@simplewebauthn/browser": "^13.2.2",
"@simplewebauthn/server": "^13.2.2",
"@tanstack/react-query": "5.90.10",
@@ -81,8 +81,8 @@
"@babel/preset-typescript": "^7.28.5",
"@lingui/babel-plugin-lingui-macro": "^5.6.0",
"@lingui/vite-plugin": "^5.6.0",
"@react-router/dev": "^7.18.1",
"@react-router/remix-routes-option-adapter": "^7.18.1",
"@react-router/dev": "^7.12.0",
"@react-router/remix-routes-option-adapter": "^7.12.0",
"@rollup/plugin-babel": "^6.1.0",
"@rollup/plugin-commonjs": "^28.0.9",
"@rollup/plugin-json": "^6.1.0",
+1264 -2048
View File
File diff suppressed because it is too large Load Diff
@@ -1,76 +0,0 @@
import { seedDirectTemplate } from '@documenso/prisma/seed/templates';
import { seedUser } from '@documenso/prisma/seed/users';
import { expect, type Page, test } from '@playwright/test';
import { apiSignin } from '../fixtures/authentication';
import { clickEnvelopeEditorStep } from '../fixtures/envelope-editor';
const INVALID_DIRECT_TEMPLATE_ALERT_TITLE = 'Invalid direct link template';
/**
* Place a field on the PDF canvas in the envelope editor.
*/
const placeFieldOnPdf = async (root: Page, fieldName: 'Signature' | 'Text', position: { x: number; y: number }) => {
await root.getByRole('button', { name: fieldName, exact: true }).click();
const canvas = root.locator('.konva-container canvas').first();
await expect(canvas).toBeVisible();
await canvas.click({ position });
};
/**
* Seed a V2 direct template and open it in the native template editor.
*
* Only the native template editor is covered here: direct links only exist
* for templates and are not part of the embedded editor surfaces.
*/
const openDirectTemplateEditor = async (page: Page, options: { createDirectRecipientSignatureField: boolean }) => {
const { user, team } = await seedUser();
const template = await seedDirectTemplate({
title: `E2E Direct Template Validation ${Date.now()}`,
userId: user.id,
teamId: team.id,
internalVersion: 2,
createDirectRecipientSignatureField: options.createDirectRecipientSignatureField,
});
await apiSignin({
page,
email: user.email,
redirectPath: `/t/${team.url}/templates/${template.id}/edit`,
});
return { user, team, template };
};
test.describe('template editor', () => {
test('shows invalid direct template warning when a signer has no signature field', async ({ page }) => {
await openDirectTemplateEditor(page, { createDirectRecipientSignatureField: false });
await expect(page.getByText(INVALID_DIRECT_TEMPLATE_ALERT_TITLE)).toBeVisible();
await expect(page.getByText('are missing a signature field')).toBeVisible();
});
test('does not show the warning when all signers have signature fields', async ({ page }) => {
await openDirectTemplateEditor(page, { createDirectRecipientSignatureField: true });
// Wait for the editor to render before asserting the banner is absent.
await expect(page.getByTestId('envelope-editor-step-upload')).toBeVisible();
await expect(page.getByText(INVALID_DIRECT_TEMPLATE_ALERT_TITLE)).not.toBeVisible();
});
test('warning disappears after placing a signature field', async ({ page }) => {
await openDirectTemplateEditor(page, { createDirectRecipientSignatureField: false });
await expect(page.getByText(INVALID_DIRECT_TEMPLATE_ALERT_TITLE)).toBeVisible();
// Place a signature field for the direct recipient (auto-selected single recipient).
await clickEnvelopeEditorStep(page, 'addFields');
await expect(page.locator('.konva-container canvas').first()).toBeVisible();
await placeFieldOnPdf(page, 'Signature', { x: 120, y: 140 });
// The banner clears once the field is autosaved and the envelope state updates.
await expect(page.getByText(INVALID_DIRECT_TEMPLATE_ALERT_TITLE)).not.toBeVisible({ timeout: 15_000 });
});
});
@@ -355,8 +355,8 @@ test('[ORGANISATIONS]: manage groups and members', async ({ page }) => {
await page.getByRole('button', { name: 'Next' }).click();
await page.getByRole('combobox').click();
await page.getByRole('option', { name: 'Manager' }).click();
await page.getByRole('button', { name: 'Create Groups' }).click();
await expect(page.getByText('Team members have been added').first()).toBeVisible();
await page.getByRole('button', { name: 'Add Groups' }).click();
await expect(page.getByText('Team groups have been added').first()).toBeVisible();
// Assign CUSTOM_GROUP_B to TeamA
await page.goto(`/t/${teamA}/settings/groups`);
@@ -368,8 +368,8 @@ test('[ORGANISATIONS]: manage groups and members', async ({ page }) => {
await page.getByRole('button', { name: 'Next' }).click();
await page.getByRole('combobox').click();
await page.getByRole('option', { name: 'Manager' }).click();
await page.getByRole('button', { name: 'Create Groups' }).click();
await expect(page.getByText('Team members have been added').first()).toBeVisible();
await page.getByRole('button', { name: 'Add Groups' }).click();
await expect(page.getByText('Team groups have been added').first()).toBeVisible();
// Update CUSTOM_GROUP_B
const updateBtn = page.getByRole('row', { name: 'CUSTOM_GROUP_B' }).getByRole('button');
@@ -6,7 +6,6 @@ import { expect, test } from '@playwright/test';
import { apiSignin } from '../fixtures/authentication';
import { expectToastTextToBeVisible } from '../fixtures/generic';
import { signSignaturePad } from '../fixtures/signature';
test('[PUBLIC_PROFILE]: create team profile', async ({ page }) => {
const { user, team } = await seedUser();
@@ -74,19 +73,8 @@ test('[PUBLIC_PROFILE]: create team profile', async ({ page }) => {
await expect(page.locator('body')).toContainText('public-direct-template-title');
await expect(page.locator('body')).toContainText('public-direct-template-description');
const directSignatureField = directTemplate.fields[0];
if (!directSignatureField) {
throw new Error('Expected seeded direct template signature field to exist');
}
await page.getByRole('link', { name: 'Sign' }).click();
await page.getByRole('button', { name: 'Continue' }).click();
await signSignaturePad(page);
await page.locator(`#field-${directSignatureField.id}`).getByRole('button').click();
await expect(page.locator(`#field-${directSignatureField.id}`)).toHaveAttribute('data-inserted', 'true');
await page.getByRole('button', { name: 'Complete' }).click();
await page.getByRole('button', { name: 'Sign' }).click();
@@ -197,18 +197,7 @@ test('[DIRECT_TEMPLATES]: V1 direct template link auth access', async ({ page })
await expect(page.getByRole('heading', { name: 'General' })).toBeVisible();
await expect(page.getByLabel('Email')).toBeDisabled();
const directSignatureField = directTemplateWithAuth.fields[0];
if (!directSignatureField) {
throw new Error('Expected seeded direct template signature field to exist');
}
await page.getByRole('button', { name: 'Continue' }).click();
await signSignaturePad(page);
await page.locator(`#field-${directSignatureField.id}`).getByRole('button').click();
await expect(page.locator(`#field-${directSignatureField.id}`)).toHaveAttribute('data-inserted', 'true');
await page.getByRole('button', { name: 'Complete' }).click();
await page.getByRole('button', { name: 'Sign' }).click();
@@ -246,37 +235,6 @@ test('[DIRECT_TEMPLATES]: V2 direct template link auth access', async ({ page })
await page.goto(directTemplatePath);
await expect(page.getByRole('heading', { name: 'Personal direct template link' })).toBeVisible();
const directSignatureField = directTemplateWithAuth.fields[0];
if (!directSignatureField) {
throw new Error('Expected seeded direct template signature field to exist');
}
// Wait for the PDF and the Konva canvas overlay to be ready.
await expect(page.locator('img[data-page-number]').first()).toBeVisible({ timeout: 30_000 });
const canvas = page.locator('.konva-container canvas').first();
await expect(canvas).toBeVisible({ timeout: 30_000 });
// Sign the direct template recipient's signature field via the canvas-based V2 UI.
await signSignaturePad(page);
const canvasBox = await canvas.boundingBox();
if (!canvasBox) {
throw new Error('Canvas bounding box not found');
}
const x =
(Number(directSignatureField.positionX) / 100) * canvasBox.width +
((Number(directSignatureField.width) / 100) * canvasBox.width) / 2;
const y =
(Number(directSignatureField.positionY) / 100) * canvasBox.height +
((Number(directSignatureField.height) / 100) * canvasBox.height) / 2;
await canvas.click({ position: { x, y } });
await expect(page.getByText('0 Fields Remaining').first()).toBeVisible({ timeout: 10_000 });
await page.getByRole('button', { name: 'Complete' }).click();
await expect(page.getByLabel('Your Email')).not.toBeVisible();
@@ -308,16 +266,6 @@ test('[DIRECT_TEMPLATES]: use direct template link with 1 recipient', async ({ p
await expect(page.getByText('Next Recipient Name')).not.toBeVisible();
const directSignatureField = template.fields[0];
if (!directSignatureField) {
throw new Error('Expected seeded direct template signature field to exist');
}
await signSignaturePad(page);
await page.locator(`#field-${directSignatureField.id}`).getByRole('button').click();
await expect(page.locator(`#field-${directSignatureField.id}`)).toHaveAttribute('data-inserted', 'true');
await page.getByRole('button', { name: 'Complete' }).click();
await page.getByRole('button', { name: 'Sign' }).click();
await page.waitForURL(/\/sign/);
@@ -351,13 +299,19 @@ test('[DIRECT_TEMPLATES]: V1 use direct template link with 2 recipients with nex
},
});
// The seeded direct template already includes a signature field for the direct recipient.
const directSignatureField = template.fields[0];
const directTemplateRecipient = template.recipients[0];
if (!directSignatureField) {
throw new Error('Expected seeded direct template signature field to exist');
if (!directTemplateRecipient) {
throw new Error('Expected direct template recipient to exist');
}
// All SIGNER recipients need a signature field for sendDocument to dispatch emails.
const directSignatureField = await seedSignatureFieldForRecipient({
envelopeId: template.id,
recipientId: directTemplateRecipient.id,
positionY: 10,
});
const originalName = 'Signer 2';
const originalSecondSignerEmail = seedTestEmail();
@@ -459,13 +413,19 @@ test('[DIRECT_TEMPLATES]: V2 use direct template link with 2 recipients with nex
},
});
// The seeded direct template already includes a signature field for the direct recipient.
const directSignatureField = template.fields[0];
const directTemplateRecipient = template.recipients[0];
if (!directSignatureField) {
throw new Error('Expected seeded direct template signature field to exist');
if (!directTemplateRecipient) {
throw new Error('Expected direct template recipient to exist');
}
// All SIGNER recipients need a signature field for sendDocument to dispatch emails.
const directSignatureField = await seedSignatureFieldForRecipient({
envelopeId: template.id,
recipientId: directTemplateRecipient.id,
positionY: 10,
});
const originalName = 'Signer 2';
const originalSecondSignerEmail = seedTestEmail();
@@ -561,48 +521,3 @@ test('[DIRECT_TEMPLATES]: V2 use direct template link with 2 recipients with nex
expect(updatedSecondRecipient.email).toBe(newSecondSignerEmail);
await expectSigningRequestJobForRecipient(updatedSecondRecipient.id);
});
test('[DIRECT_TEMPLATES]: V1 direct template without signature fields shows invalid template page', async ({
page,
}) => {
const { user, team } = await seedUser();
const template = await seedDirectTemplate({
title: 'V1 invalid direct template',
userId: user.id,
teamId: team.id,
createDirectRecipientSignatureField: false,
});
await page.goto(formatDirectTemplatePath(template.directLink?.token || ''));
await expect(page.getByRole('heading', { name: 'Invalid direct link template' })).toBeVisible();
await expect(page.getByText('This direct link template cannot be used because one or more signers')).toBeVisible();
// The signing flow must not render.
await expect(page.getByRole('heading', { name: 'General' })).not.toBeVisible();
await expect(page.getByRole('button', { name: 'Continue' })).not.toBeVisible();
});
test('[DIRECT_TEMPLATES]: V2 direct template without signature fields shows invalid template page', async ({
page,
}) => {
const { user, team } = await seedUser();
const template = await seedDirectTemplate({
title: 'V2 invalid direct template',
userId: user.id,
teamId: team.id,
internalVersion: 2,
createDirectRecipientSignatureField: false,
});
await page.goto(formatDirectTemplatePath(template.directLink?.token || ''));
await expect(page.getByRole('heading', { name: 'Invalid direct link template' })).toBeVisible();
await expect(page.getByText('This direct link template cannot be used because one or more signers')).toBeVisible();
// The signing flow (PDF canvas) must not render.
await expect(page.locator('.konva-container canvas')).toHaveCount(0);
await expect(page.getByRole('button', { name: 'Complete' })).not.toBeVisible();
});
@@ -1,101 +0,0 @@
import { FIELD_SIGNATURE_META_DEFAULT_VALUES } from '@documenso/lib/types/field-meta';
import { prisma } from '@documenso/prisma';
import { seedTemplate } from '@documenso/prisma/seed/templates';
import { seedUser } from '@documenso/prisma/seed/users';
import { expect, test } from '@playwright/test';
import { DocumentStatus, FieldType } from '@prisma/client';
import { apiSignin } from '../fixtures/authentication';
import { expectToastTextToBeVisible } from '../fixtures/generic';
const seedSignatureFieldForRecipient = async (options: { envelopeId: string; recipientId: number }) => {
const envelopeItem = await prisma.envelopeItem.findFirstOrThrow({
where: { envelopeId: options.envelopeId },
});
return await prisma.field.create({
data: {
envelopeId: options.envelopeId,
envelopeItemId: envelopeItem.id,
recipientId: options.recipientId,
type: FieldType.SIGNATURE,
page: 1,
positionX: 5,
positionY: 10,
width: 20,
height: 5,
customText: '',
inserted: false,
fieldMeta: FIELD_SIGNATURE_META_DEFAULT_VALUES,
},
});
};
test('[TEMPLATE_USE]: shows missing signature fields error when sending a template without signature fields', async ({
page,
}) => {
const { user, team } = await seedUser();
// seedTemplate creates one SIGNER recipient and no fields.
await seedTemplate({
title: 'Template missing signature fields',
userId: user.id,
teamId: team.id,
});
await apiSignin({
page,
email: user.email,
redirectPath: `/t/${team.url}/templates`,
});
await page.getByRole('button', { name: 'Use Template' }).click();
await expect(page.getByRole('heading', { name: 'Create document from template' })).toBeVisible();
// Enable distribution so the document is sent on creation.
await page.locator('#distributeDocument').click();
await page.getByRole('button', { name: 'Create and send' }).click();
await expectToastTextToBeVisible(page, 'Missing signature fields');
await expectToastTextToBeVisible(
page,
'The document could not be sent because some signers do not have a signature field',
);
});
test('[TEMPLATE_USE]: creates and sends a document when signers have signature fields', async ({ page }) => {
const { user, team } = await seedUser();
const template = await seedTemplate({
title: 'Template with signature fields',
userId: user.id,
teamId: team.id,
});
await seedSignatureFieldForRecipient({
envelopeId: template.id,
recipientId: template.recipients[0].id,
});
await apiSignin({
page,
email: user.email,
redirectPath: `/t/${team.url}/templates`,
});
await page.getByRole('button', { name: 'Use Template' }).click();
await expect(page.getByRole('heading', { name: 'Create document from template' })).toBeVisible();
await page.locator('#distributeDocument').click();
await page.getByRole('button', { name: 'Create and send' }).click();
await page.waitForURL(new RegExp(`/t/${team.url}/documents/envelope_.*`));
const envelopeId = page.url().split('/').pop()?.split('?')[0];
const envelope = await prisma.envelope.findFirstOrThrow({
where: { id: envelopeId },
});
expect(envelope.status).toBe(DocumentStatus.PENDING);
});
-9
View File
@@ -36,13 +36,6 @@ export enum AppErrorCode {
*/
ENVELOPE_TSP_LOCKED = 'ENVELOPE_TSP_LOCKED',
/**
* A signer recipient does not have a signature field assigned. Thrown when
* distributing an envelope or using a direct template where at least one
* signer has no signature field.
*/
MISSING_SIGNATURE_FIELD = 'MISSING_SIGNATURE_FIELD',
/**
* CSC (Cloud Signature Consortium) error codes. See the CSC QES V1 spec
* for the recovery taxonomy.
@@ -91,7 +84,6 @@ export const genericErrorCodeToTrpcErrorCodeMap: Record<string, { code: string;
[AppErrorCode.ENVELOPE_CANCELLED]: { code: 'BAD_REQUEST', status: 400 },
[AppErrorCode.ENVELOPE_LEGACY]: { code: 'BAD_REQUEST', status: 400 },
[AppErrorCode.ENVELOPE_TSP_LOCKED]: { code: 'BAD_REQUEST', status: 400 },
[AppErrorCode.MISSING_SIGNATURE_FIELD]: { code: 'BAD_REQUEST', status: 400 },
[AppErrorCode.CSC_INSTANCE_MODE_MISMATCH]: { code: 'BAD_REQUEST', status: 400 },
[AppErrorCode.CSC_UNLICENSED]: { code: 'FORBIDDEN', status: 403 },
[AppErrorCode.CSC_PROVIDER_INFO_FAILED]: { code: 'INTERNAL_SERVER_ERROR', status: 500 },
@@ -299,7 +291,6 @@ export class AppError extends Error {
AppErrorCode.ENVELOPE_CANCELLED,
AppErrorCode.ENVELOPE_LEGACY,
AppErrorCode.ENVELOPE_TSP_LOCKED,
AppErrorCode.MISSING_SIGNATURE_FIELD,
AppErrorCode.CSC_INSTANCE_MODE_MISMATCH,
AppErrorCode.CSC_CREDENTIAL_LIST_EMPTY,
AppErrorCode.CSC_CERT_INVALID,
@@ -194,7 +194,7 @@ export const sendDocument = async ({ id, userId, teamId, sendEmail, requestMetad
.map((r) => (r.name ? `${r.name} (${r.email}, id: ${r.id})` : `${r.email} (id: ${r.id})`))
.join(', ');
throw new AppError(AppErrorCode.MISSING_SIGNATURE_FIELD, {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: `The following recipients are missing required fields: ${missingRecipientDescriptions}. Signers must have at least one signature field.`,
});
}
@@ -5,7 +5,6 @@ import { match } from 'ts-pattern';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { DocumentAccessAuth, type TDocumentAuthMethods } from '../../types/document-auth';
import { extractDocumentAuthMethods } from '../../utils/document-auth';
import { getRecipientsWithMissingFields } from '../../utils/recipients';
import { extractFieldAutoInsertValues } from '../document/send-document';
import { getTeamSettings } from '../team/get-team-settings';
import type { EnvelopeForSigningResponse } from './get-envelope-for-recipient-signing';
@@ -126,17 +125,6 @@ export const getEnvelopeForDirectTemplateSigning = async ({
});
}
const recipientsWithMissingFields = getRecipientsWithMissingFields(
envelope.recipients,
envelope.recipients.flatMap((envelopeRecipient) => envelopeRecipient.fields),
);
if (recipientsWithMissingFields.length > 0) {
throw new AppError(AppErrorCode.MISSING_SIGNATURE_FIELD, {
message: 'One or more signers on this direct template are missing a signature field',
});
}
const settings = await getTeamSettings({ teamId: envelope.teamId });
const sender = settings.includeSenderDetails
@@ -40,7 +40,6 @@ import {
extractDocumentAuthMethods,
} from '../../utils/document-auth';
import { mapSecondaryIdToTemplateId } from '../../utils/envelope';
import { getRecipientsWithMissingFields } from '../../utils/recipients';
import { sendDocument } from '../document/send-document';
import { validateFieldAuth } from '../document/validate-field-auth';
import { incrementDocumentId } from '../envelope/increment-id';
@@ -173,17 +172,6 @@ export const createDocumentFromDirectTemplate = async ({
});
}
const recipientsWithMissingFields = getRecipientsWithMissingFields(
recipients,
recipients.flatMap((recipient) => recipient.fields),
);
if (recipientsWithMissingFields.length > 0) {
throw new AppError(AppErrorCode.MISSING_SIGNATURE_FIELD, {
message: 'One or more signers on this direct template are missing a signature field',
});
}
if (directTemplateEnvelope.updatedAt.getTime() !== templateUpdatedAt.getTime()) {
throw new AppError(AppErrorCode.INVALID_REQUEST, { message: 'Template no longer matches' });
}
+3 -3
View File
@@ -361,9 +361,9 @@ export const seedAlignmentTestDocument = async ({
const { id, recipients, envelopeItems } = createdEnvelope;
if (isDirectTemplate) {
const directTemplateRecipient = recipients.find((recipient) => recipient.email === DIRECT_TEMPLATE_RECIPIENT_EMAIL);
const directTemplateRecpient = recipients.find((recipient) => recipient.email === DIRECT_TEMPLATE_RECIPIENT_EMAIL);
if (!directTemplateRecipient) {
if (!directTemplateRecpient) {
throw new Error('Need to create a direct template recipient');
}
@@ -372,7 +372,7 @@ export const seedAlignmentTestDocument = async ({
envelopeId: id,
enabled: true,
token: directTemplateToken ?? Math.random().toString(),
directTemplateRecipientId: directTemplateRecipient.id,
directTemplateRecipientId: directTemplateRecpient.id,
},
});
}
+3 -35
View File
@@ -6,7 +6,6 @@ import {
DIRECT_TEMPLATE_RECIPIENT_NAME,
} from '@documenso/lib/constants/direct-templates';
import { incrementTemplateId } from '@documenso/lib/server-only/envelope/increment-id';
import { FIELD_SIGNATURE_META_DEFAULT_VALUES } from '@documenso/lib/types/field-meta';
import { SignatureLevel } from '@documenso/lib/types/signature-level';
import { prefixedId } from '@documenso/lib/universal/id';
@@ -16,7 +15,6 @@ import {
DocumentDataType,
DocumentSource,
EnvelopeType,
FieldType,
ReadStatus,
RecipientRole,
SendStatus,
@@ -31,11 +29,6 @@ type SeedTemplateOptions = {
teamId: number;
internalVersion?: 1 | 2;
createTemplateOptions?: Partial<Prisma.EnvelopeUncheckedCreateInput>;
/**
* Only used by seedDirectTemplate. Creates a signature field for the direct
* recipient so the seeded direct template is valid. Defaults to true.
*/
createDirectRecipientSignatureField?: boolean;
};
type CreateTemplateOptions = {
@@ -205,11 +198,11 @@ export const seedDirectTemplate = async (options: SeedTemplateOptions) => {
},
});
const directTemplateRecipient = template.recipients.find(
const directTemplateRecpient = template.recipients.find(
(recipient) => recipient.email === DIRECT_TEMPLATE_RECIPIENT_EMAIL,
);
if (!directTemplateRecipient) {
if (!directTemplateRecpient) {
throw new Error('Need to create a direct template recipient');
}
@@ -218,35 +211,10 @@ export const seedDirectTemplate = async (options: SeedTemplateOptions) => {
envelopeId: template.id,
enabled: true,
token: Math.random().toString(),
directTemplateRecipientId: directTemplateRecipient.id,
directTemplateRecipientId: directTemplateRecpient.id,
},
});
const { createDirectRecipientSignatureField = true } = options;
if (createDirectRecipientSignatureField) {
const envelopeItem = await prisma.envelopeItem.findFirstOrThrow({
where: { envelopeId: template.id },
});
await prisma.field.create({
data: {
envelopeId: template.id,
envelopeItemId: envelopeItem.id,
recipientId: directTemplateRecipient.id,
type: FieldType.SIGNATURE,
page: 1,
positionX: 5,
positionY: 10,
width: 20,
height: 5,
customText: '',
inserted: false,
fieldMeta: FIELD_SIGNATURE_META_DEFAULT_VALUES,
},
});
}
return await prisma.envelope.findFirstOrThrow({
where: {
id: template.id,