diff --git a/.npmrc b/.npmrc index cbc6b6537..75baad7f0 100644 --- a/.npmrc +++ b/.npmrc @@ -1,3 +1,3 @@ legacy-peer-deps = true prefer-dedupe = true -min-release-age = 7 +# min-release-age = 7 diff --git a/apps/docs/content/docs/developers/api/rate-limits.mdx b/apps/docs/content/docs/developers/api/rate-limits.mdx index ecd50558e..d2e31b1d4 100644 --- a/apps/docs/content/docs/developers/api/rate-limits.mdx +++ b/apps/docs/content/docs/developers/api/rate-limits.mdx @@ -65,3 +65,4 @@ When you exceed a resource limit: - [Authentication](/docs/developers/getting-started/authentication) - API authentication guide - [API Versioning](/docs/developers/api/versioning) - API version management - [First API Call](/docs/developers/getting-started/first-api-call) - Getting started with the API +- [Organisation Limits](/docs/self-hosting/configuration/organisation-limits) - Admins: set per-organisation resource quotas and rate limits (the HTTP rate limit above is separate and not admin-settable) diff --git a/apps/docs/content/docs/self-hosting/configuration/environment.mdx b/apps/docs/content/docs/self-hosting/configuration/environment.mdx index e90e1b099..bc4acd1a6 100644 --- a/apps/docs/content/docs/self-hosting/configuration/environment.mdx +++ b/apps/docs/content/docs/self-hosting/configuration/environment.mdx @@ -510,4 +510,5 @@ NEXT_PRIVATE_SIGNING_PASSPHRASE="your-certificate-password" - [Email Configuration](/docs/self-hosting/configuration/email) - Configure email delivery - [Storage Configuration](/docs/self-hosting/configuration/storage) - Set up S3 storage - [Signing Certificate](/docs/self-hosting/configuration/signing-certificate) - Configure document signing +- [Organisation Limits](/docs/self-hosting/configuration/organisation-limits) - Set per-organisation document, email, and API limits from the admin panel - [Troubleshooting](/docs/self-hosting/maintenance/troubleshooting) - Common configuration issues diff --git a/apps/docs/content/docs/self-hosting/configuration/index.mdx b/apps/docs/content/docs/self-hosting/configuration/index.mdx index 0e80e0efc..de5830a4d 100644 --- a/apps/docs/content/docs/self-hosting/configuration/index.mdx +++ b/apps/docs/content/docs/self-hosting/configuration/index.mdx @@ -29,6 +29,11 @@ description: Configure your self-hosted Documenso instance with environment vari description="Digital signature certificate setup." href="/docs/self-hosting/configuration/signing-certificate" /> + ## Required Configuration diff --git a/apps/docs/content/docs/self-hosting/configuration/meta.json b/apps/docs/content/docs/self-hosting/configuration/meta.json index f38183703..6cc250f64 100644 --- a/apps/docs/content/docs/self-hosting/configuration/meta.json +++ b/apps/docs/content/docs/self-hosting/configuration/meta.json @@ -9,6 +9,7 @@ "background-jobs", "signing-certificate", "telemetry", + "organisation-limits", "advanced" ] } diff --git a/apps/docs/content/docs/self-hosting/configuration/organisation-limits.mdx b/apps/docs/content/docs/self-hosting/configuration/organisation-limits.mdx new file mode 100644 index 000000000..2459812ce --- /dev/null +++ b/apps/docs/content/docs/self-hosting/configuration/organisation-limits.mdx @@ -0,0 +1,111 @@ +--- +title: Organisation Limits +description: View and set per-organisation document, email, and API limits on a self-hosted Documenso instance using the admin panel's subscription claims. +--- + +import { Callout } from 'fumadocs-ui/components/callout'; + +Per-organisation limits — document, email, and API usage, plus feature toggles and team/member caps — are controlled by **subscription claims**. You configure them in the admin panel, not through environment variables. + +There are three distinct kinds of limit: + +| Limit | Caps | Admin-settable | +| ---------------------- | ------------------------------------------------- | ----------------------- | +| Resource quota | Documents, emails, and API requests **per month** | Yes — per claim and org | +| Resource rate limit | The same resources over a short window (e.g. `1h`) | Yes — per claim and org | +| Global HTTP rate limit | API requests per IP (100/min, hardcoded) | No — see [Limitations](#limitations) | + +## Prerequisites + +- A running self-hosted Documenso instance. +- An account with the **`ADMIN`** role — an account-level role, separate from organisation and team roles. New accounts are created with the `USER` role only. Grant the first admin by adding `ADMIN` to that user's `roles` directly in the database; after that, an existing admin can grant the role to others under **Admin Panel > Users > _(user)_ > Roles > Update user**. + +Open the admin panel at `/admin`. The sidebar sections used below are **Claims**, **Organisations**, and **Organisation Stats**. + +## Viewing usage + +**One organisation:** open **Admin Panel > Organisations** and select it. The **Organisation usage** section shows the current period's document, email, and API usage against its quotas. + +**All organisations:** open **Admin Panel > Organisation Stats** to sort and filter monthly usage. Filter by **claim** and by **period** (a UTC calendar month, shown as `YYYY-MM`), and switch between **Show usage**, **Show usage with quotas**, and **Show daily averages**. + + + Usage counts **attempts**, not only successful actions. A request that exceeds a quota is still counted before it is rejected, so displayed usage can read higher than the number of actions that succeeded. + + +## Subscription claims + +A subscription claim is a named bundle of limits and feature flags (for example `Free`, `Individual`, `Teams`, `Platform`, or `Enterprise`). Claims are **templates**: when an organisation is created it receives a private copy of its claim and reads from that copy afterwards. Editing a claim template therefore affects organisations created later, not existing ones — to change an existing organisation, [edit it directly](#change-limits-for-one-organisation). + +### Claim fields + +Under **Admin Panel > Claims** (`/admin/claims`), each claim has: + +| Field | Controls | +| ----------------------- | --------------------------------------------------------------------------------- | +| **Name** | The claim's display name. | +| **Team Count** | Teams allowed. `0` = unlimited. | +| **Member Count** | Members allowed. `0` = unlimited. | +| **Envelope Item Count** | Uploaded files allowed per envelope. Minimum `1`. | +| **Recipient Count** | Recipients allowed per document. `0` = unlimited. | +| **Feature Flags** | Feature toggles (see [Feature flags](#feature-flags)). | +| **Limits** | Monthly quota and rate-limit windows for Documents, Emails, and API. | +| **Email transport** | Transport the claim uses. *Default (system mailer)* uses the instance default. | + +### Quotas and rate limits + +The **Limits** section has a column for **Documents**, **Emails**, and **API**, each with two controls: + +- **Monthly quota** — how many of that resource are allowed per calendar month. An **empty** field is unlimited; **`0`** blocks the resource entirely. +- **Rate limit windows** — optional short-window caps, each a duration and a maximum. A window is a number and a unit (`s`, `m`, `h`, `d`), such as `5m`, `1h`, or `24h`, and must be unique within the resource. + + + Quotas and counts use opposite conventions for "unlimited": an **empty** quota is unlimited (and `0` blocks the resource), whereas `0` in the **Team**, **Member**, and **Recipient Count** fields means unlimited. + + +### Feature flags + +The **Feature Flags** section toggles capabilities such as Unlimited documents, Branding, Hide Documenso branding, Email domains, Embed authoring, Embed signing, White label for embed authoring/signing, 21 CFR, HIPAA, Authentication portal, Allow Legacy Envelopes, Signing reminders, QES signing, and Disable emails. + +Some flags are Enterprise features. If your license does not include one, it is marked and cannot be enabled (you can still turn it off). See [Enterprise Edition](/docs/policies/enterprise-edition). + +### Create or edit a claim template + +1. Go to **Admin Panel > Claims**. +2. Select **New claim**, or select an existing claim to edit it. +3. Set the counts, feature flags, and the **Limits** section. +4. Save. Changes apply to organisations created afterwards, not existing ones. + +### Change limits for one organisation + +To change limits for an existing organisation, edit it directly rather than its claim template. + +1. Go to **Admin Panel > Organisations** and open the organisation. +2. Adjust its quota, rate-limit, feature-flag, or email-transport fields. +3. Save. Changes take effect immediately. + +The organisation also shows the **Inherited subscription claim** it was created from. + +## Usage reset + +Monthly quota usage is keyed to the **UTC calendar month**. There is no scheduled reset job — when the month rolls over, the new period's counter starts at `0`. + +## Limitations + +The **global HTTP rate limit is not configurable.** Documenso enforces a hardcoded **100 requests per minute per IP address** on its API endpoint groups (`/api/v1`, `/api/v2`, and the tRPC API are limited separately), returning `429 Too Many Requests`. It is a per-IP safeguard applied at the HTTP layer — not per-organisation, not stored on any claim, and not adjustable from the admin panel. See [Rate Limits](/docs/developers/api/rate-limits). + +## Troubleshooting + +| Symptom | Cause and fix | +| ------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------- | +| An organisation hit its limit unexpectedly | Usage counts rejected over-quota attempts. Compare usage against the quota under **Organisation Stats > Show usage with quotas**. | +| A resource is blocked entirely, not just capped | The **Monthly quota** is `0`, which blocks the resource. Leave it empty for unlimited. | +| Emails are not sending for an organisation | Check whether the **Disable emails** flag is enabled on the organisation's claim — it blocks all emails regardless of quota. | +| A claim template edit had no effect | Template edits are not retroactive. Edit the organisation directly under **Admin Panel > Organisations**. | + +--- + +## See Also + +- [Environment Variables](/docs/self-hosting/configuration/environment) - All configuration options +- [Rate Limits](/docs/developers/api/rate-limits) - The global HTTP API rate limit (separate from claims) +- [Enterprise Edition](/docs/policies/enterprise-edition) - Features unlocked by license flags diff --git a/apps/docs/package.json b/apps/docs/package.json index 76aecb716..da9966679 100644 --- a/apps/docs/package.json +++ b/apps/docs/package.json @@ -3,7 +3,7 @@ "version": "0.0.0", "private": true, "scripts": { - "build": "next build", + "build": "NEXT_IGNORE_INCORRECT_LOCKFILE=true next build", "dev": "next dev", "start": "next start", "types:check": "fumadocs-mdx && next typegen && tsc --noEmit", @@ -29,7 +29,7 @@ "@types/node": "^25.1.0", "@types/react": "^19.2.10", "@types/react-dom": "^19.2.3", - "postcss": "^8.5.14", + "postcss": "^8.5.19", "tailwindcss": "^4.1.18", "typescript": "^5.9.3" } diff --git a/apps/docs/src/app/global.css b/apps/docs/src/app/global.css index 7d9c89316..ce71134c8 100644 --- a/apps/docs/src/app/global.css +++ b/apps/docs/src/app/global.css @@ -83,7 +83,7 @@ --accent: hsl(0 0% 27.8431%); --accent-foreground: hsl(95.0847 71.0843% 67.451%); --destructive: hsl(0 86.5979% 61.9608%); - --destructive-foreground: hsl(0 87.6289% 19.0196%); + --destructive-foreground: hsl(0 0% 98.0392%); --border: hsl(0 0% 27.8431%); --input: hsl(0 0% 27.8431%); --ring: hsl(95.0847 71.0843% 67.451%); diff --git a/apps/remix/app/components/dialogs/folder-delete-dialog.tsx b/apps/remix/app/components/dialogs/folder-delete-dialog.tsx index e05f943b3..aca0e7e31 100644 --- a/apps/remix/app/components/dialogs/folder-delete-dialog.tsx +++ b/apps/remix/app/components/dialogs/folder-delete-dialog.tsx @@ -122,7 +122,7 @@ export const FolderDeleteDialog = ({ folder, isOpen, onOpenChange }: FolderDelet Confirm by typing:{' '} - {deleteMessage} + {deleteMessage} diff --git a/apps/remix/app/components/dialogs/organisation-create-dialog.tsx b/apps/remix/app/components/dialogs/organisation-create-dialog.tsx index 183dfbeec..c76bc00a2 100644 --- a/apps/remix/app/components/dialogs/organisation-create-dialog.tsx +++ b/apps/remix/app/components/dialogs/organisation-create-dialog.tsx @@ -336,7 +336,7 @@ const BillingPlanForm = ({ value, onChange, plans, canCreateFreeOrganisation }: >
-

+

Free

diff --git a/apps/remix/app/components/dialogs/organisation-email-domain-delete-dialog.tsx b/apps/remix/app/components/dialogs/organisation-email-domain-delete-dialog.tsx index c903cd079..a0308e530 100644 --- a/apps/remix/app/components/dialogs/organisation-email-domain-delete-dialog.tsx +++ b/apps/remix/app/components/dialogs/organisation-email-domain-delete-dialog.tsx @@ -115,7 +115,7 @@ export const OrganisationEmailDomainDeleteDialog = ({ Confirm by typing{' '} - {deleteMessage} + {deleteMessage} diff --git a/apps/remix/app/components/dialogs/organisation-member-invite-dialog.tsx b/apps/remix/app/components/dialogs/organisation-member-invite-dialog.tsx index a033e7bff..d68c0f5d3 100644 --- a/apps/remix/app/components/dialogs/organisation-member-invite-dialog.tsx +++ b/apps/remix/app/components/dialogs/organisation-member-invite-dialog.tsx @@ -370,7 +370,7 @@ export const OrganisationMemberInviteDialog = ({ trigger, ...props }: Organisati + )} + + + { + // Prevent losing the created token by accidentally clicking outside the dialog. + if (createdToken) { + event.preventDefault(); + } + }} + > + {createdToken ? ( + <> + + + Token created + + + + Copy your token now. For security reasons you will not be able to see it again. + + + +
+ +
+ toast({ title: _(msg`Token copied to clipboard`) })} + /> +
+
+ + + + + + ) : ( + <> + + + Create API token + + + + Use API tokens to authenticate with the Documenso API. + + + +
+ +
+ ( + + + Name + + + + + + + + A name to help you identify this token later. + + + + + )} + /> + + ( + + + Expires in + + + + + + + + + )} + /> + + + + + + +
+
+ + + )} +
+ + ); +}; diff --git a/apps/remix/app/components/dialogs/token-delete-dialog.tsx b/apps/remix/app/components/dialogs/token-delete-dialog.tsx index 63f1465b4..0e3553a4d 100644 --- a/apps/remix/app/components/dialogs/token-delete-dialog.tsx +++ b/apps/remix/app/components/dialogs/token-delete-dialog.tsx @@ -105,7 +105,7 @@ export default function TokenDeleteDialog({ token, onDelete, children }: TokenDe - Are you sure you want to delete this token? + Delete token @@ -126,7 +126,7 @@ export default function TokenDeleteDialog({ token, onDelete, children }: TokenDe Confirm by typing:{' '} - {deleteMessage} + {deleteMessage} @@ -139,21 +139,18 @@ export default function TokenDeleteDialog({ token, onDelete, children }: TokenDe /> -
- + - -
+
diff --git a/apps/remix/app/components/dialogs/webhook-delete-dialog.tsx b/apps/remix/app/components/dialogs/webhook-delete-dialog.tsx index d2f8bc059..3401a2083 100644 --- a/apps/remix/app/components/dialogs/webhook-delete-dialog.tsx +++ b/apps/remix/app/components/dialogs/webhook-delete-dialog.tsx @@ -117,7 +117,7 @@ export const WebhookDeleteDialog = ({ webhook, children }: WebhookDeleteDialogPr Confirm by typing:{' '} - {deleteMessage} + {deleteMessage} diff --git a/apps/remix/app/components/embed/authoring/configure-fields-view.tsx b/apps/remix/app/components/embed/authoring/configure-fields-view.tsx index 4a3a7036f..19a2d4b92 100644 --- a/apps/remix/app/components/embed/authoring/configure-fields-view.tsx +++ b/apps/remix/app/components/embed/authoring/configure-fields-view.tsx @@ -503,7 +503,7 @@ export const ConfigureFieldsView = ({ {selectedField && (
; - -type NewlyCreatedToken = { - id: number; - token: string; -}; - -export type ApiTokenFormProps = { - className?: string; - tokens?: Pick[]; -}; - -export const ApiTokenForm = ({ className, tokens }: ApiTokenFormProps) => { - const [, copy] = useCopyToClipboard(); - - const team = useCurrentTeam(); - - const { _ } = useLingui(); - const { toast } = useToast(); - - const [newlyCreatedToken, setNewlyCreatedToken] = useState(); - const [noExpirationDate, setNoExpirationDate] = useState(false); - - const { mutateAsync: createTokenMutation } = trpc.apiToken.create.useMutation({ - onSuccess(data) { - setNewlyCreatedToken(data); - }, - }); - - const form = useForm({ - resolver: zodResolver(ZCreateTokenFormSchema), - defaultValues: { - tokenName: '', - expirationDate: '', - }, - }); - - const copyToken = async (token: string) => { - try { - const copied = await copy(token); - - if (!copied) { - throw new Error('Unable to copy the token'); - } - - toast({ - title: _(msg`Token copied to clipboard`), - description: _(msg`The token was copied to your clipboard.`), - }); - } catch (error) { - toast({ - title: _(msg`Unable to copy token`), - description: _(msg`We were unable to copy the token to your clipboard. Please try again.`), - variant: 'destructive', - }); - } - }; - - const onSubmit = async ({ tokenName, expirationDate }: TCreateTokenFormSchema) => { - try { - await createTokenMutation({ - teamId: team.id, - tokenName, - expirationDate: noExpirationDate ? null : expirationDate, - }); - - toast({ - title: _(msg`Token created`), - description: _(msg`A new token was created successfully.`), - duration: 5000, - }); - - form.reset(); - } catch (err) { - const error = AppError.parseError(err); - - const errorMessage = match(error.code) - .with(AppErrorCode.UNAUTHORIZED, () => msg`You do not have permission to create a token for this team.`) - .otherwise(() => msg`Something went wrong. Please try again later.`); - - toast({ - title: _(msg`An error occurred`), - description: _(errorMessage), - variant: 'destructive', - duration: 5000, - }); - } - }; - - return ( -
-
- -
- ( - - - Token name - - -
- - - -
- - - Please enter a meaningful name for your token. This will help you identify it later. - - - -
- )} - /> - -
- ( - - - Token expiration date - - -
- - - -
- - -
- )} - /> - -
- - Never expire - -
- -
-
-
- - - -
- -
-
-
- - - - {newlyCreatedToken && tokens && tokens.find((token) => token.id === newlyCreatedToken.id) && ( - - - -

- - Your token was created successfully! Make sure to copy it because you won't be able to see it again! - -

- -

- {newlyCreatedToken.token} -

- - -
-
-
- )} -
-
- ); -}; diff --git a/apps/remix/app/components/general/admin-license-card.tsx b/apps/remix/app/components/general/admin-license-card.tsx index a5ed1fd90..c3a3428df 100644 --- a/apps/remix/app/components/general/admin-license-card.tsx +++ b/apps/remix/app/components/general/admin-license-card.tsx @@ -87,7 +87,7 @@ export const AdminLicenseCard = ({ licenseData }: AdminLicenseCardProps) => {
-

+

Documenso License

diff --git a/apps/remix/app/components/general/envelope-editor/envelope-editor-fields-drag-drop.tsx b/apps/remix/app/components/general/envelope-editor/envelope-editor-fields-drag-drop.tsx index 2c2c4516d..b9af073ed 100644 --- a/apps/remix/app/components/general/envelope-editor/envelope-editor-fields-drag-drop.tsx +++ b/apps/remix/app/components/general/envelope-editor/envelope-editor-fields-drag-drop.tsx @@ -270,7 +270,7 @@ export const EnvelopeEditorFieldDragDrop = ({ {selectedField && (
Upload Document : Upload Template} -

+

Drag and drop your document here

diff --git a/apps/remix/app/entry.client.tsx b/apps/remix/app/entry.client.tsx index 86e949d7a..0ffb3602e 100644 --- a/apps/remix/app/entry.client.tsx +++ b/apps/remix/app/entry.client.tsx @@ -3,27 +3,32 @@ import { dynamicActivate } from '@documenso/lib/utils/i18n'; import { i18n } from '@lingui/core'; import { detect, fromHtmlTag } from '@lingui/detect-locale'; import { I18nProvider } from '@lingui/react'; -import { StrictMode, startTransition, useEffect } from 'react'; +import { StrictMode, startTransition } from 'react'; import { hydrateRoot } from 'react-dom/client'; import { HydratedRouter } from 'react-router/dom'; import './utils/polyfills/promise-with-resolvers'; -function PosthogInit() { +/** + * Initialised imperatively (not as a component inside `hydrateRoot`) because + * rendering extra client-only siblings changes the React tree structure + * relative to the server render in `entry.server.tsx`. That shifts every + * `useId` value (used by Radix for `id`/`htmlFor`/`aria-*`), causing hydration + * mismatches which can abort hydration entirely when the user interacts with + * the page early, leaving dead event handlers (broken dropdowns, native form + * submits). + */ +function initPosthog() { const postHogConfig = extractPostHogConfig(); - useEffect(() => { - if (postHogConfig) { - void import('posthog-js').then(({ default: posthog }) => { - posthog.init(postHogConfig.key, { - api_host: postHogConfig.host, - capture_exceptions: true, - }); + if (postHogConfig) { + void import('posthog-js').then(({ default: posthog }) => { + posthog.init(postHogConfig.key, { + api_host: postHogConfig.host, + capture_exceptions: true, }); - } - }, []); - - return null; + }); + } } async function main() { @@ -38,11 +43,11 @@ async function main() { - - , ); }); + + void initPosthog(); } // eslint-disable-next-line @typescript-eslint/no-floating-promises diff --git a/apps/remix/app/root.tsx b/apps/remix/app/root.tsx index 096b1504e..a7c29b4be 100644 --- a/apps/remix/app/root.tsx +++ b/apps/remix/app/root.tsx @@ -119,7 +119,11 @@ export function LayoutContent({ children }: { children: React.ReactNode }) { const isRecipientRoute = matches.some((m) => m.id?.startsWith('routes/_recipient+')); return ( - + // `suppressHydrationWarning` because `remix-themes` intentionally mutates + // `data-theme`/`class` on before hydration (PreventFlashOnWrongTheme), + // so the server-rendered attributes never match the client render when the + // theme is resolved from the system preference. Attribute-only, one level deep. + @@ -173,7 +177,11 @@ export function LayoutContent({ children }: { children: React.ReactNode }) {