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/policies/enterprise-edition.mdx b/apps/docs/content/docs/policies/enterprise-edition.mdx index a611db5cd..f821e73ec 100644 --- a/apps/docs/content/docs/policies/enterprise-edition.mdx +++ b/apps/docs/content/docs/policies/enterprise-edition.mdx @@ -76,6 +76,8 @@ 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. + @@ -197,7 +199,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. Configure Enterprise features with support assistance + 4. Apply the key — see [Apply Your License Key](/docs/self-hosting/configuration/license) — and configure Enterprise features with support assistance @@ -238,6 +240,7 @@ 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 diff --git a/apps/docs/content/docs/self-hosting/configuration/environment.mdx b/apps/docs/content/docs/self-hosting/configuration/environment.mdx index 4b910b43b..bc4acd1a6 100644 --- a/apps/docs/content/docs/self-hosting/configuration/environment.mdx +++ b/apps/docs/content/docs/self-hosting/configuration/environment.mdx @@ -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. +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. | Variable | Description | | ------------------------------------ | ------------------------------------------------ | -| `NEXT_PRIVATE_DOCUMENSO_LICENSE_KEY` | License key for enterprise features | +| `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_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 | @@ -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/license.mdx b/apps/docs/content/docs/self-hosting/configuration/license.mdx new file mode 100644 index 000000000..eea50c154 --- /dev/null +++ b/apps/docs/content/docs/self-hosting/configuration/license.mdx @@ -0,0 +1,107 @@ +--- +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. + + + The license key applies to your **whole instance**, not an individual user account. There's one + key per deployment. + + +## 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. + + + + +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 +``` + + + + +```bash +docker run -d \ + --name documenso \ + -e NEXT_PRIVATE_DOCUMENSO_LICENSE_KEY=your-license-key-here \ + documenso/documenso:latest +``` + + + + +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 +``` + + + + +## 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 + + + + - 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. + + + 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. + + + +## 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 diff --git a/apps/docs/content/docs/self-hosting/configuration/meta.json b/apps/docs/content/docs/self-hosting/configuration/meta.json index 32b92f853..6cc250f64 100644 --- a/apps/docs/content/docs/self-hosting/configuration/meta.json +++ b/apps/docs/content/docs/self-hosting/configuration/meta.json @@ -2,12 +2,14 @@ "title": "Configuration", "pages": [ "environment", + "license", "database", "email", "storage", "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/content/docs/self-hosting/configuration/signing-certificate/csc-qes.mdx b/apps/docs/content/docs/self-hosting/configuration/signing-certificate/csc-qes.mdx index 0495430d7..4998baaf7 100644 --- a/apps/docs/content/docs/self-hosting/configuration/signing-certificate/csc-qes.mdx +++ b/apps/docs/content/docs/self-hosting/configuration/signing-certificate/csc-qes.mdx @@ -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`). +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. diff --git a/apps/docs/content/docs/self-hosting/index.mdx b/apps/docs/content/docs/self-hosting/index.mdx index 3f8f8d3c2..99e4ecb01 100644 --- a/apps/docs/content/docs/self-hosting/index.mdx +++ b/apps/docs/content/docs/self-hosting/index.mdx @@ -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. +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). --- diff --git a/apps/remix/app/components/dialogs/folder-create-dialog.tsx b/apps/remix/app/components/dialogs/folder-create-dialog.tsx index c2cf21eaa..395feaf37 100644 --- a/apps/remix/app/components/dialogs/folder-create-dialog.tsx +++ b/apps/remix/app/components/dialogs/folder-create-dialog.tsx @@ -1,3 +1,4 @@ +import { ZNameSchema } from '@documenso/lib/types/name'; import { trpc } from '@documenso/trpc/react'; import { Button } from '@documenso/ui/primitives/button'; import { @@ -23,7 +24,7 @@ import { useParams } from 'react-router'; import { z } from 'zod'; const ZCreateFolderFormSchema = z.object({ - name: z.string().min(1, { message: 'Folder name is required' }), + name: ZNameSchema, }); type TCreateFolderFormSchema = z.infer; @@ -65,7 +66,7 @@ export const FolderCreateDialog = ({ type, trigger, parentFolderId, ...props }: toast({ description: t`Folder created successfully`, }); - } catch (err) { + } catch (_err) { toast({ title: t`Failed to create folder`, description: t`An unknown error occurred while creating the folder.`, 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/folder-update-dialog.tsx b/apps/remix/app/components/dialogs/folder-update-dialog.tsx index 1c57bc27d..db859431a 100644 --- a/apps/remix/app/components/dialogs/folder-update-dialog.tsx +++ b/apps/remix/app/components/dialogs/folder-update-dialog.tsx @@ -1,5 +1,6 @@ import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; import { DocumentVisibility } from '@documenso/lib/types/document-visibility'; +import { ZNameSchema } from '@documenso/lib/types/name'; import { trpc } from '@documenso/trpc/react'; import type { TFolderWithSubfolders } from '@documenso/trpc/server/folder-router/schema'; import { Button } from '@documenso/ui/primitives/button'; @@ -23,8 +24,6 @@ import { useEffect } from 'react'; import { useForm } from 'react-hook-form'; import { z } from 'zod'; -import { useOptionalCurrentTeam } from '~/providers/team'; - export type FolderUpdateDialogProps = { folder: TFolderWithSubfolders | null; isOpen: boolean; @@ -32,7 +31,7 @@ export type FolderUpdateDialogProps = { } & Omit; export const ZUpdateFolderFormSchema = z.object({ - name: z.string().min(1), + name: ZNameSchema, visibility: z.nativeEnum(DocumentVisibility).optional(), }); @@ -40,7 +39,6 @@ export type TUpdateFolderFormSchema = z.infer; export const FolderUpdateDialog = ({ folder, isOpen, onOpenChange }: FolderUpdateDialogProps) => { const { t } = useLingui(); - const team = useOptionalCurrentTeam(); const { toast } = useToast(); const { mutateAsync: updateFolder } = trpc.folder.updateFolder.useMutation(); 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 -
+ diff --git a/apps/remix/app/components/forms/document-preferences-form.tsx b/apps/remix/app/components/forms/document-preferences-form.tsx index ee552d043..f1a0b6d8a 100644 --- a/apps/remix/app/components/forms/document-preferences-form.tsx +++ b/apps/remix/app/components/forms/document-preferences-form.tsx @@ -21,7 +21,6 @@ import { ReminderSettingsPicker } from '@documenso/ui/components/document/remind import { RecipientRoleSelect } from '@documenso/ui/components/recipient/recipient-role-select'; import { Alert } from '@documenso/ui/primitives/alert'; import { AvatarWithText } from '@documenso/ui/primitives/avatar'; -import { Button } from '@documenso/ui/primitives/button'; import { Combobox } from '@documenso/ui/primitives/combobox'; import { Form, @@ -46,6 +45,7 @@ import { z } from 'zod'; import { useOptionalCurrentTeam } from '~/providers/team'; import { DefaultRecipientsMultiSelectCombobox } from '../general/default-recipients-multiselect-combobox'; +import { FormStickySaveBar } from './form-sticky-save-bar'; /** * Can't infer this from the schema since we need to keep the schema inside the component to allow @@ -147,9 +147,21 @@ export const DocumentPreferencesForm = ({ resolver: zodResolver(ZDocumentPreferencesFormSchema), }); + const handleFormSubmit = form.handleSubmit(async (data) => { + try { + await onFormSubmit(data); + } catch { + // The page handler surfaces its own error toast. Keep the form dirty so + // the save bar stays visible and the user can retry. + return; + } + + form.reset(data); + }); + return (
- +
{!isPersonalLayoutMode && ( )} -
- -
+ form.reset()} + />
diff --git a/apps/remix/app/components/forms/email-preferences-form.tsx b/apps/remix/app/components/forms/email-preferences-form.tsx index 4ab981d93..818868005 100644 --- a/apps/remix/app/components/forms/email-preferences-form.tsx +++ b/apps/remix/app/components/forms/email-preferences-form.tsx @@ -4,7 +4,6 @@ import { DEFAULT_DOCUMENT_EMAIL_SETTINGS, ZDocumentEmailSettingsSchema } from '@ import { zEmail } from '@documenso/lib/utils/zod'; import { trpc } from '@documenso/trpc/react'; import { DocumentEmailCheckboxes } from '@documenso/ui/components/document/document-email-checkboxes'; -import { Button } from '@documenso/ui/primitives/button'; import { Form, FormControl, @@ -22,6 +21,8 @@ import type { TeamGlobalSettings } from '@prisma/client'; import { useForm } from 'react-hook-form'; import { z } from 'zod'; +import { FormStickySaveBar } from './form-sticky-save-bar'; + const ZEmailPreferencesFormSchema = z.object({ emailId: z.string().nullable(), emailReplyTo: zEmail().nullable(), @@ -59,9 +60,21 @@ export const EmailPreferencesForm = ({ settings, onFormSubmit, canInherit }: Ema const emails = emailData?.data || []; + const handleFormSubmit = form.handleSubmit(async (data) => { + try { + await onFormSubmit(data); + } catch { + // The page handler surfaces its own error toast. Keep the form dirty so + // the save bar stays visible and the user can retry. + return; + } + + form.reset(data); + }); + return (
- +
{organisation.organisationClaim.flags.emailDomains && ( -
- -
+ form.reset()} + />
diff --git a/apps/remix/app/components/forms/email-transport-form.tsx b/apps/remix/app/components/forms/email-transport-form.tsx index 2e20fdb59..c8af2f478 100644 --- a/apps/remix/app/components/forms/email-transport-form.tsx +++ b/apps/remix/app/components/forms/email-transport-form.tsx @@ -1,3 +1,4 @@ +import { ZNameSchema } from '@documenso/lib/types/name'; import { Form, FormControl, @@ -15,8 +16,8 @@ import { useForm } from 'react-hook-form'; import { z } from 'zod'; const ZEmailTransportFormSchema = z.object({ - name: z.string().min(1), - fromName: z.string().min(1), + name: ZNameSchema, + fromName: ZNameSchema, fromAddress: z.string().email(), type: z.enum(['SMTP_AUTH', 'SMTP_API', 'RESEND', 'MAILCHANNELS']), host: z.string().optional(), diff --git a/apps/remix/app/components/forms/form-sticky-save-bar.tsx b/apps/remix/app/components/forms/form-sticky-save-bar.tsx new file mode 100644 index 000000000..1d37f9482 --- /dev/null +++ b/apps/remix/app/components/forms/form-sticky-save-bar.tsx @@ -0,0 +1,119 @@ +import { cn } from '@documenso/ui/lib/utils'; +import { Button } from '@documenso/ui/primitives/button'; +import { Trans, useLingui } from '@lingui/react/macro'; +import { AnimatePresence, motion } from 'framer-motion'; +import { AlertTriangleIcon } from 'lucide-react'; +import { useEffect, useRef, useState } from 'react'; + +export type FormStickySaveBarProps = { + isDirty: boolean; + isSubmitting: boolean; + onReset: () => void; +}; + +/** + * A single `position: sticky` bar rendered at the bottom of the form. + * + * - When the form's end is on screen it settles into place as a plain footer (just the + * Reset / Save buttons). + * - When the form's end is scrolled off, it sticks to the bottom of the viewport and + * shows the "unsaved changes" pill chrome. + * + * Because it's the same element in the form's flow, it auto-aligns to the form and the + * float <-> dock hand-off is a native, scroll-linked transition (no measurement, no + * shared-layout morph). A 1px sentinel below it detects the stuck state so we can toggle + * the pill chrome. + */ +export const FormStickySaveBar = ({ isDirty, isSubmitting, onReset }: FormStickySaveBarProps) => { + const { t } = useLingui(); + + const sentinelRef = useRef(null); + const [isStuck, setIsStuck] = useState(false); + + useEffect(() => { + const sentinel = sentinelRef.current; + + if (!sentinel) { + return; + } + + // The sentinel sits at the bar's resting position (the end of the form). While the + // bar is stuck to the bottom of the viewport the sentinel is scrolled past (out of + // view); once you reach the form's end it comes into view and the bar settles. + const observer = new IntersectionObserver( + ([entry]) => { + setIsStuck(!entry.isIntersecting); + }, + { + root: null, + rootMargin: '0px 0px -24px 0px', + threshold: 0, + }, + ); + + observer.observe(sentinel); + + return () => { + observer.disconnect(); + }; + }, []); + + // Show the floating pill chrome only when there are unsaved changes AND the form's + // end is off screen. + const isFloating = isDirty && isStuck; + + return ( + <> +
+ + {isFloating && ( + + + + You have unsaved changes + + + )} + + +
+ {isDirty && ( + + )} + + +
+
+ + {/* Sentinel: detects when the sticky bar is floating (stuck) vs settled (docked). */} +
+ + ); +}; diff --git a/apps/remix/app/components/forms/organisation-update-form.tsx b/apps/remix/app/components/forms/organisation-update-form.tsx index 7ac15b1c7..5d4c2c88f 100644 --- a/apps/remix/app/components/forms/organisation-update-form.tsx +++ b/apps/remix/app/components/forms/organisation-update-form.tsx @@ -4,7 +4,6 @@ import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app'; import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; import { trpc } from '@documenso/trpc/react'; import { ZUpdateOrganisationRequestSchema } from '@documenso/trpc/server/organisation-router/update-organisation.types'; -import { Button } from '@documenso/ui/primitives/button'; import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@documenso/ui/primitives/form/form'; import { Input } from '@documenso/ui/primitives/input'; import { useToast } from '@documenso/ui/primitives/use-toast'; @@ -12,11 +11,12 @@ import { zodResolver } from '@hookform/resolvers/zod'; import { msg } from '@lingui/core/macro'; import { useLingui } from '@lingui/react'; import { Trans } from '@lingui/react/macro'; -import { AnimatePresence, motion } from 'framer-motion'; import { useForm } from 'react-hook-form'; import { useNavigate } from 'react-router'; import type { z } from 'zod'; +import { FormStickySaveBar } from './form-sticky-save-bar'; + const ZOrganisationUpdateFormSchema = ZUpdateOrganisationRequestSchema.shape.data.pick({ name: true, url: true, @@ -137,36 +137,11 @@ export const OrganisationUpdateForm = () => { )} /> -
- - {form.formState.isDirty && ( - - - - )} - - - -
+ form.reset()} + /> diff --git a/apps/remix/app/components/forms/profile.tsx b/apps/remix/app/components/forms/profile.tsx index a9b81bc02..3449a747f 100644 --- a/apps/remix/app/components/forms/profile.tsx +++ b/apps/remix/app/components/forms/profile.tsx @@ -1,5 +1,5 @@ import { useSession } from '@documenso/lib/client-only/providers/session'; -import { ZNameSchema } from '@documenso/lib/constants/auth'; +import { ZNameSchema } from '@documenso/lib/types/name'; import { trpc } from '@documenso/trpc/react'; import { cn } from '@documenso/ui/lib/utils'; import { Button } from '@documenso/ui/primitives/button'; diff --git a/apps/remix/app/components/forms/signup.tsx b/apps/remix/app/components/forms/signup.tsx index a53131f08..ea22b2789 100644 --- a/apps/remix/app/components/forms/signup.tsx +++ b/apps/remix/app/components/forms/signup.tsx @@ -1,8 +1,8 @@ import communityCardsImage from '@documenso/assets/images/community-cards.png'; import { authClient } from '@documenso/auth/client'; import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics'; -import { ZNameSchema } from '@documenso/lib/constants/auth'; import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; +import { ZNameSchema } from '@documenso/lib/types/name'; import { env } from '@documenso/lib/utils/env'; import { zEmail } from '@documenso/lib/utils/zod'; import { ZPasswordSchema } from '@documenso/trpc/server/auth-router/schema'; @@ -96,7 +96,7 @@ export const SignUpForm = ({ password: '', signature: '', }, - mode: 'onBlur', + mode: 'onChange', resolver: zodResolver(ZSignUpFormSchema), }); diff --git a/apps/remix/app/components/forms/team-update-form.tsx b/apps/remix/app/components/forms/team-update-form.tsx index fdf569f95..8dddaee2b 100644 --- a/apps/remix/app/components/forms/team-update-form.tsx +++ b/apps/remix/app/components/forms/team-update-form.tsx @@ -2,7 +2,6 @@ import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app'; import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; import { trpc } from '@documenso/trpc/react'; import { ZUpdateTeamRequestSchema } from '@documenso/trpc/server/team-router/update-team.types'; -import { Button } from '@documenso/ui/primitives/button'; import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@documenso/ui/primitives/form/form'; import { Input } from '@documenso/ui/primitives/input'; import { useToast } from '@documenso/ui/primitives/use-toast'; @@ -10,11 +9,12 @@ import { zodResolver } from '@hookform/resolvers/zod'; import { msg } from '@lingui/core/macro'; import { useLingui } from '@lingui/react'; import { Trans } from '@lingui/react/macro'; -import { AnimatePresence, motion } from 'framer-motion'; import { useForm } from 'react-hook-form'; import { useNavigate } from 'react-router'; import type { z } from 'zod'; +import { FormStickySaveBar } from './form-sticky-save-bar'; + export type UpdateTeamDialogProps = { teamId: number; teamName: string; @@ -135,36 +135,11 @@ export const TeamUpdateForm = ({ teamId, teamName, teamUrl }: UpdateTeamDialogPr )} /> -
- - {form.formState.isDirty && ( - - - - )} - - - -
+ form.reset()} + /> diff --git a/apps/remix/app/components/general/admin-global-settings-section.tsx b/apps/remix/app/components/general/admin-global-settings-section.tsx index 5f21e7900..760684077 100644 --- a/apps/remix/app/components/general/admin-global-settings-section.tsx +++ b/apps/remix/app/components/general/admin-global-settings-section.tsx @@ -5,6 +5,7 @@ import { msg } from '@lingui/core/macro'; import { useLingui } from '@lingui/react'; import { Trans } from '@lingui/react/macro'; import type { OrganisationGlobalSettings, TeamGlobalSettings } from '@prisma/client'; +import type { ReactNode } from 'react'; import { DetailsCard, DetailsValue } from '~/components/general/admin-details'; @@ -25,38 +26,72 @@ const emailSettingsKeys = Object.keys(EMAIL_SETTINGS_LABELS) as (keyof TDocument type AdminGlobalSettingsSectionProps = { settings: TeamGlobalSettings | OrganisationGlobalSettings | null; isTeam?: boolean; + /** When viewing a team, the parent organisation settings the team inherits from. */ + inheritedSettings?: OrganisationGlobalSettings | null; }; -export const AdminGlobalSettingsSection = ({ settings, isTeam = false }: AdminGlobalSettingsSectionProps) => { +export const AdminGlobalSettingsSection = ({ + settings, + isTeam = false, + inheritedSettings, +}: AdminGlobalSettingsSectionProps) => { const { _ } = useLingui(); - const notSetLabel = isTeam ? Inherited : Not set; if (!settings) { return null; } - const textValue = (value: string | null | undefined) => { - if (value === null || value === undefined) { - return notSetLabel; + const notSet = Not set; + + const inheritedValue = (value: ReactNode) => { + if (!isTeam || value === null) { + return notSet; } - return value; + return ( + + + Inherited: + + {value} + + ); }; - const brandingTextValue = (value: string | null | undefined) => { - if (value === null || value === undefined || value.trim() === '') { - return notSetLabel; + const textValue = (value: string | null | undefined, inherited?: string | null) => { + if (value && value.trim() !== '') { + return value; } - return value; + if (inherited && inherited.trim() !== '') { + return inheritedValue(inherited); + } + + return notSet; }; - const booleanValue = (value: boolean | null | undefined) => { - if (value === null || value === undefined) { - return notSetLabel; + const booleanLabel = (value: boolean) => (value ? Enabled : Disabled); + + const booleanValue = (value: boolean | null | undefined, inherited?: boolean | null) => { + if (value !== null && value !== undefined) { + return booleanLabel(value); } - return value ? Enabled : Disabled; + return inherited !== null && inherited !== undefined ? inheritedValue(booleanLabel(inherited)) : notSet; + }; + + const visibilityLabel = (value: string | null | undefined) => { + return value && DOCUMENT_VISIBILITY[value] ? _(DOCUMENT_VISIBILITY[value].value) : null; + }; + + const visibilityValue = (value: string | null | undefined, inherited?: string | null) => { + const label = visibilityLabel(value); + + if (label !== null) { + return label; + } + + return inheritedValue(visibilityLabel(inherited)); }; const parsedEmailSettings = ZDocumentEmailSettingsSchema.safeParse(settings.emailDocumentSettings); @@ -65,70 +100,82 @@ export const AdminGlobalSettingsSection = ({ settings, isTeam = false }: AdminGl
Document visibility}> - {settings.documentVisibility != null - ? _(DOCUMENT_VISIBILITY[settings.documentVisibility].value) - : notSetLabel} + {visibilityValue(settings.documentVisibility, inheritedSettings?.documentVisibility)} Document language}> - {textValue(settings.documentLanguage)} + {textValue(settings.documentLanguage, inheritedSettings?.documentLanguage)} Document timezone}> - {textValue(settings.documentTimezone)} + {textValue(settings.documentTimezone, inheritedSettings?.documentTimezone)} Date format}> - {textValue(settings.documentDateFormat)} + {textValue(settings.documentDateFormat, inheritedSettings?.documentDateFormat)} Include sender details}> - {booleanValue(settings.includeSenderDetails)} + + {booleanValue(settings.includeSenderDetails, inheritedSettings?.includeSenderDetails)} + Include signing certificate}> - {booleanValue(settings.includeSigningCertificate)} + + {booleanValue(settings.includeSigningCertificate, inheritedSettings?.includeSigningCertificate)} + Include audit log}> - {booleanValue(settings.includeAuditLog)} + {booleanValue(settings.includeAuditLog, inheritedSettings?.includeAuditLog)} Delegate document ownership}> - {booleanValue(settings.delegateDocumentOwnership)} + + {booleanValue(settings.delegateDocumentOwnership, inheritedSettings?.delegateDocumentOwnership)} + Typed signature}> - {booleanValue(settings.typedSignatureEnabled)} + + {booleanValue(settings.typedSignatureEnabled, inheritedSettings?.typedSignatureEnabled)} + Upload signature}> - {booleanValue(settings.uploadSignatureEnabled)} + + {booleanValue(settings.uploadSignatureEnabled, inheritedSettings?.uploadSignatureEnabled)} + Draw signature}> - {booleanValue(settings.drawSignatureEnabled)} + + {booleanValue(settings.drawSignatureEnabled, inheritedSettings?.drawSignatureEnabled)} + Branding}> - {booleanValue(settings.brandingEnabled)} + {booleanValue(settings.brandingEnabled, inheritedSettings?.brandingEnabled)} Branding logo}> - {brandingTextValue(settings.brandingLogo)} + {textValue(settings.brandingLogo, inheritedSettings?.brandingLogo)} Branding URL}> - {brandingTextValue(settings.brandingUrl)} + {textValue(settings.brandingUrl, inheritedSettings?.brandingUrl)} Branding company details}> - {brandingTextValue(settings.brandingCompanyDetails)} + + {textValue(settings.brandingCompanyDetails, inheritedSettings?.brandingCompanyDetails)} + Email reply-to}> - {textValue(settings.emailReplyTo)} + {textValue(settings.emailReplyTo, inheritedSettings?.emailReplyTo)} {isTeam && parsedEmailSettings.success && ( @@ -145,7 +192,7 @@ export const AdminGlobalSettingsSection = ({ settings, isTeam = false }: AdminGl )} AI features}> - {booleanValue(settings.aiFeaturesEnabled)} + {booleanValue(settings.aiFeaturesEnabled, inheritedSettings?.aiFeaturesEnabled)}
); 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/claim-account.tsx b/apps/remix/app/components/general/claim-account.tsx index 110549bc7..4301391e6 100644 --- a/apps/remix/app/components/general/claim-account.tsx +++ b/apps/remix/app/components/general/claim-account.tsx @@ -1,6 +1,7 @@ import { authClient } from '@documenso/auth/client'; import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics'; import { AppError } from '@documenso/lib/errors/app-error'; +import { ZNameSchema } from '@documenso/lib/types/name'; import { env } from '@documenso/lib/utils/env'; import { zEmail } from '@documenso/lib/utils/zod'; import { ZPasswordSchema } from '@documenso/trpc/server/auth-router/schema'; @@ -19,7 +20,6 @@ import { useRef } from 'react'; import { useForm } from 'react-hook-form'; import { useNavigate } from 'react-router'; import { z } from 'zod'; - import { SIGNUP_ERROR_MESSAGES } from '~/components/forms/signup'; export type ClaimAccountProps = { @@ -30,7 +30,7 @@ export type ClaimAccountProps = { export const ZClaimAccountFormSchema = z .object({ - name: z.string().trim().min(1, { message: msg`Please enter a valid name.`.id }), + name: ZNameSchema, email: zEmail().min(1), password: ZPasswordSchema, }) diff --git a/apps/remix/app/components/general/claim-limit-fields.tsx b/apps/remix/app/components/general/claim-limit-fields.tsx index ed29c8fc3..c90a92430 100644 --- a/apps/remix/app/components/general/claim-limit-fields.tsx +++ b/apps/remix/app/components/general/claim-limit-fields.tsx @@ -1,11 +1,4 @@ -import { - FormControl, - FormDescription, - FormField, - FormItem, - FormLabel, - FormMessage, -} from '@documenso/ui/primitives/form/form'; +import { FormControl, FormField, FormItem, FormLabel, FormMessage } from '@documenso/ui/primitives/form/form'; import { Input } from '@documenso/ui/primitives/input'; import { Trans, useLingui } from '@lingui/react/macro'; import type { ReactNode } from 'react'; @@ -13,6 +6,13 @@ import type { Control, FieldValues, Path } from 'react-hook-form'; import { RateLimitArrayInput } from './rate-limit-array-input'; +/** + * The rate-limit editor renders its own per-row inline errors, but a submit + * attempt can still surface array-level Zod issues (e.g. a committed duplicate + * window). Rendering the field's message here guarantees the form never fails + * silently when those errors are not tied to a row the editor is showing. + */ + type ClaimLimitFieldsProps = { control: Control; /** e.g. '' for the claim form, 'claims.' for the org admin form. */ @@ -20,6 +20,12 @@ type ClaimLimitFieldsProps = { disabled?: boolean; }; +type LimitGroup = { + title: ReactNode; + quotaKey: string; + rateLimitKey: string; +}; + export const ClaimLimitFields = ({ control, prefix = '', @@ -30,13 +36,33 @@ export const ClaimLimitFields = ({ // eslint-disable-next-line @typescript-eslint/consistent-type-assertions const name = (key: string) => `${prefix}${key}` as Path; - const renderQuotaField = (key: string, label: ReactNode, description: ReactNode) => ( + const limitGroups: LimitGroup[] = [ + { + title: Documents, + quotaKey: 'documentQuota', + rateLimitKey: 'documentRateLimits', + }, + { + title: Emails, + quotaKey: 'emailQuota', + rateLimitKey: 'emailRateLimits', + }, + { + title: API, + quotaKey: 'apiQuota', + rateLimitKey: 'apiRateLimits', + }, + ]; + + const renderQuotaField = (group: LimitGroup) => ( ( - {label} + + Monthly quota + ({ onChange={(e) => field.onChange(e.target.value === '' ? null : parseInt(e.target.value, 10))} /> - {description} )} /> ); - const renderRateLimitField = (key: string, label: ReactNode) => ( + const renderRateLimitField = (group: LimitGroup) => ( ( - {label} @@ -71,27 +95,30 @@ export const ClaimLimitFields = ({ ); return ( -
- - Limits - +
+
+

+ Limits +

+

+ + Empty quota means unlimited, 0 blocks the resource. Rate limit windows accept values like 5m, 1h or 24h. + +

+
- {renderQuotaField( - 'documentQuota', - Monthly document quota, - Empty = Unlimited, 0 = Blocked, - )} - {renderRateLimitField('documentRateLimits', Document rate limits)} +
+
+ {limitGroups.map((group) => ( +
+

{group.title}

- {renderQuotaField( - 'emailQuota', - Monthly email quota, - Empty = Unlimited, 0 = Blocked, - )} - {renderRateLimitField('emailRateLimits', Email rate limits)} - - {renderQuotaField('apiQuota', Monthly API quota, Empty = Unlimited, 0 = Blocked)} - {renderRateLimitField('apiRateLimits', API rate limits)} + {renderQuotaField(group)} + {renderRateLimitField(group)} +
+ ))} +
+
); }; 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/components/general/organisation-usage-panel.tsx b/apps/remix/app/components/general/organisation-usage-panel.tsx index c9ed06265..99a69a661 100644 --- a/apps/remix/app/components/general/organisation-usage-panel.tsx +++ b/apps/remix/app/components/general/organisation-usage-panel.tsx @@ -1,13 +1,38 @@ import { currentMonthlyPeriod } from '@documenso/lib/universal/monthly-period'; +import { + getQuotaUsagePercent, + isQuotaExceeded, + isQuotaNearing, + normalizeCapacityLimit, +} from '@documenso/lib/universal/quota-usage'; +import { cn } from '@documenso/ui/lib/utils'; +import type { BadgeProps } from '@documenso/ui/primitives/badge'; +import { Badge } from '@documenso/ui/primitives/badge'; import { Progress } from '@documenso/ui/primitives/progress'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@documenso/ui/primitives/select'; import { Trans } from '@lingui/react/macro'; import type { OrganisationClaim, OrganisationMonthlyStat } from '@prisma/client'; -import { useState } from 'react'; -import { match } from 'ts-pattern'; +import type { LucideIcon } from 'lucide-react'; +import { FileIcon, MailIcon, MailOpenIcon, PlugIcon, UsersIcon, UsersRoundIcon } from 'lucide-react'; +import type { ReactNode } from 'react'; +import { useId, useState } from 'react'; + import { OrganisationUsageResetButton } from './organisation-usage-reset-button'; +type CapacityUsage = { + members: number; + teams: number; +}; + +type UsageRow = { + counter: 'document' | 'email' | 'api'; + label: ReactNode; + icon: LucideIcon; + used: number; + effectiveLimit: number | null; +}; + type OrganisationUsagePanelProps = { organisationId: string; monthlyStats: Pick< @@ -15,13 +40,151 @@ type OrganisationUsagePanelProps = { 'period' | 'documentCount' | 'emailCount' | 'apiCount' | 'emailReports' >[]; organisationClaim: OrganisationClaim; + capacityUsage?: CapacityUsage; +}; + +type UsageCardState = { + status: { + label: ReactNode; + variant: NonNullable; + }; + percent: number; + hasFiniteLimit: boolean; + progressClassName: string; + subtext: ReactNode; +}; + +type UsageCardStateOptions = { + used: number; + limit: number | null | undefined; + footnote?: ReactNode; +}; + +const getUsageCardState = ({ used, limit, footnote }: UsageCardStateOptions): UsageCardState => { + const percent = getQuotaUsagePercent(used, limit ?? null); + const hasFiniteLimit = Boolean(limit && limit > 0); + + if (limit === null || limit === undefined) { + return { + status: { label: Unlimited, variant: 'neutral' }, + percent, + hasFiniteLimit, + progressClassName: '', + subtext: footnote ?? null, + }; + } + + if (limit === 0) { + return { + status: { label: Blocked, variant: 'destructive' }, + percent, + hasFiniteLimit, + progressClassName: '', + subtext: footnote ?? Resource blocked, + }; + } + + if (used > limit) { + return { + status: { label: Exceeded, variant: 'destructive' }, + percent, + hasFiniteLimit, + progressClassName: '[&>div]:bg-destructive', + subtext: footnote ?? null, + }; + } + + if (isQuotaExceeded(limit, used)) { + return { + status: { label: Limit reached, variant: 'orange' }, + percent, + hasFiniteLimit, + progressClassName: '[&>div]:bg-orange-500 dark:[&>div]:bg-orange-400', + subtext: footnote ?? null, + }; + } + + if (isQuotaNearing(limit, used)) { + return { + status: { label: Near limit, variant: 'warning' }, + percent, + hasFiniteLimit, + progressClassName: '[&>div]:bg-yellow-500 dark:[&>div]:bg-yellow-400', + subtext: footnote ?? null, + }; + } + + return { + status: { label: Within limit, variant: 'default' }, + percent, + hasFiniteLimit, + progressClassName: '', + subtext: footnote ?? null, + }; +}; + +type UsageStatCardProps = { + label: ReactNode; + icon: LucideIcon; + used: number; + limit: number | null | undefined; + /** When true the card is a plain counter with no limit, status or progress. */ + countOnly?: boolean; + footnote?: ReactNode; + action?: ReactNode; +}; + +const UsageStatCard = ({ label, icon: Icon, used, limit, countOnly = false, footnote, action }: UsageStatCardProps) => { + const { status, percent, hasFiniteLimit, progressClassName, subtext } = getUsageCardState({ used, limit, footnote }); + + return ( +
+
+
+ + {label} +
+ + {!countOnly && ( + + {status.label} + + )} +
+ +
+
+
+ + {used.toLocaleString()} + + {hasFiniteLimit ? ( + / {limit?.toLocaleString()} + ) : null} +
+ + {hasFiniteLimit ? ( + {percent}% + ) : null} +
+ + {hasFiniteLimit ? : null} + + {subtext ?

{subtext}

: null} +
+ + {action ?
{action}
: null} +
+ ); }; export const OrganisationUsagePanel = ({ organisationId, monthlyStats, organisationClaim, + capacityUsage, }: OrganisationUsagePanelProps) => { + const monthlyUsagePeriodId = useId(); const [selectedPeriod, setSelectedPeriod] = useState(() => monthlyStats[0]?.period); const selectedStat = monthlyStats.find((stat) => stat.period === selectedPeriod) ?? monthlyStats[0]; @@ -30,86 +193,105 @@ export const OrganisationUsagePanel = ({ // current period), so only offer the reset action when viewing the current month. const isCurrentPeriod = selectedStat?.period === currentMonthlyPeriod(); - const rows = [ + const capacityRows = capacityUsage + ? [ + { + key: 'members', + label: Members, + icon: UsersIcon, + used: capacityUsage.members, + limit: normalizeCapacityLimit(organisationClaim.memberCount), + }, + { + key: 'teams', + label: Teams, + icon: UsersRoundIcon, + used: capacityUsage.teams, + limit: normalizeCapacityLimit(organisationClaim.teamCount), + }, + ] + : []; + + const monthlyRows: UsageRow[] = [ { - counter: 'document' as const, + counter: 'document', label: Documents, + icon: FileIcon, used: selectedStat?.documentCount ?? 0, effectiveLimit: organisationClaim.documentQuota, }, { - counter: 'email' as const, + counter: 'email', label: Emails, + icon: MailIcon, used: selectedStat?.emailCount ?? 0, effectiveLimit: organisationClaim.emailQuota, }, { - counter: 'api' as const, + counter: 'api', label: API requests, + icon: PlugIcon, used: selectedStat?.apiCount ?? 0, effectiveLimit: organisationClaim.apiQuota, }, ]; return ( -
-
-

- Usage for period: {selectedStat?.period || 'N/A'} -

+
+ {capacityRows.length > 0 ? ( +
+ {capacityRows.map((row) => ( + + ))} +
+ ) : null} - {monthlyStats.length > 0 && ( - - )} -
+
+
+

+ Monthly usage +

- {rows.map((row) => { - const percent = - row.effectiveLimit && row.effectiveLimit > 0 - ? Math.min(100, Math.round((row.used / row.effectiveLimit) * 100)) - : 0; + {monthlyStats.length > 0 ? ( + + ) : null} +
- return ( -
-
- {row.label} - - {row.used} /{' '} - {match(row.effectiveLimit) - .with(null, () => Unlimited) - .with(0, () => Blocked) - .otherwise(String)} - -
+
+ {monthlyRows.map((row) => ( + + ) : undefined + } + /> + ))} - {row.effectiveLimit && row.effectiveLimit > 0 ? : null} - - {selectedStat && isCurrentPeriod && ( -
- -
- )} -
- ); - })} - -
-
- - Reports - - {selectedStat?.emailReports ?? 0} + Reports} + icon={MailOpenIcon} + used={selectedStat?.emailReports ?? 0} + limit={null} + countOnly + footnote={Sent this period} + />
diff --git a/apps/remix/app/components/general/organisation-usage-reset-button.tsx b/apps/remix/app/components/general/organisation-usage-reset-button.tsx index 7451f6e1c..bec8b8dd6 100644 --- a/apps/remix/app/components/general/organisation-usage-reset-button.tsx +++ b/apps/remix/app/components/general/organisation-usage-reset-button.tsx @@ -2,6 +2,7 @@ import { trpc } from '@documenso/trpc/react'; import { Button } from '@documenso/ui/primitives/button'; import { useToast } from '@documenso/ui/primitives/use-toast'; import { Trans, useLingui } from '@lingui/react/macro'; +import { RotateCcwIcon } from 'lucide-react'; import { useRevalidator } from 'react-router'; type OrganisationUsageResetButtonProps = { @@ -32,6 +33,7 @@ export const OrganisationUsageResetButton = ({ organisationId, counter }: Organi loading={isPending} onClick={() => reset({ organisationId, counter })} > + Reset ); diff --git a/apps/remix/app/components/general/rate-limit-array-input.tsx b/apps/remix/app/components/general/rate-limit-array-input.tsx index a2adaad38..a0197764f 100644 --- a/apps/remix/app/components/general/rate-limit-array-input.tsx +++ b/apps/remix/app/components/general/rate-limit-array-input.tsx @@ -1,7 +1,9 @@ +import { RATE_LIMIT_WINDOW_REGEX } from '@documenso/lib/types/subscription'; import { Button } from '@documenso/ui/primitives/button'; import { Input } from '@documenso/ui/primitives/input'; -import { Trans } from '@lingui/react/macro'; +import { Trans, useLingui } from '@lingui/react/macro'; import { PlusIcon, Trash2Icon } from 'lucide-react'; +import { useState } from 'react'; type RateLimitEntryValue = { window: string; max: number }; @@ -11,50 +13,153 @@ type RateLimitArrayInputProps = { disabled?: boolean; }; +const EMPTY_ENTRY: RateLimitEntryValue = { window: '', max: 0 }; + +/** A row counts as "started" once either field has input; fully-empty rows are dropped on commit. */ +const hasEntryInput = (entry: RateLimitEntryValue) => entry.window.trim() !== '' || entry.max > 0; + +/** Keep in-progress rows; drop rows that are completely empty. */ +const persistEntries = (entries: RateLimitEntryValue[]) => { + return entries.map((entry) => ({ ...entry, window: entry.window.trim() })).filter(hasEntryInput); +}; + export const RateLimitArrayInput = ({ value, onChange, disabled }: RateLimitArrayInputProps) => { - const entries = value ?? []; + const { t } = useLingui(); + const [draftEntry, setDraftEntry] = useState(null); + + const entries = draftEntry ? [...value, draftEntry] : value.length ? value : [EMPTY_ENTRY]; + + const getWindowError = (entry: RateLimitEntryValue, index: number) => { + const window = entry.window.trim(); + + if (!hasEntryInput(entry)) { + return null; + } + + if (window === '') { + return t`Enter a window, e.g. 5m`; + } + + if (!RATE_LIMIT_WINDOW_REGEX.test(window)) { + return t`Use a duration with a unit, e.g. 5m, 1h, or 24h`; + } + + const isDuplicateWindow = entries.some((otherEntry, otherIndex) => { + return otherIndex !== index && otherEntry.window.trim() === window; + }); + + return isDuplicateWindow ? t`Use a unique window for each rate limit` : null; + }; + + const getMaxError = (entry: RateLimitEntryValue) => { + if (!hasEntryInput(entry)) { + return null; + } + + return entry.max > 0 ? null : t`Enter a max request count greater than 0`; + }; const updateEntry = (index: number, patch: Partial) => { - const next = entries.map((entry, i) => (i === index ? { ...entry, ...patch } : entry)); - onChange(next); + if (index >= value.length) { + const nextDraftEntry = { ...(draftEntry ?? EMPTY_ENTRY), ...patch }; + + if (hasEntryInput(nextDraftEntry)) { + onChange(persistEntries([...value, nextDraftEntry])); + setDraftEntry(null); + return; + } + + setDraftEntry(nextDraftEntry); + return; + } + + const next = value.map((entry, i) => (i === index ? { ...entry, ...patch } : entry)); + onChange(persistEntries(next)); }; const removeEntry = (index: number) => { - onChange(entries.filter((_, i) => i !== index)); + if (index >= value.length) { + setDraftEntry(null); + return; + } + + const next = value.filter((_, i) => i !== index); + onChange(persistEntries(next)); }; const addEntry = () => { - onChange([...entries, { window: '5m', max: 100 }]); + setDraftEntry(EMPTY_ENTRY); }; + const hasErrors = entries.some((entry, index) => getWindowError(entry, index) || getMaxError(entry)); + const isAddDisabled = disabled || value.length === 0 || Boolean(draftEntry) || hasErrors; + return (
- {entries.map((entry, index) => ( -
- updateEntry(index, { window: e.target.value })} - /> - updateEntry(index, { max: parseInt(e.target.value, 10) || 0 })} - /> - -
- ))} +
+ + Window + + + Max requests + +
- +
+ + {windowError ?

{windowError}

: null} + {maxError ?

{maxError}

: null} +
+ ); + })} + +
); diff --git a/apps/remix/app/components/tables/settings-security-passkey-table-actions.tsx b/apps/remix/app/components/tables/settings-security-passkey-table-actions.tsx index 7feb0e191..180dc3e44 100644 --- a/apps/remix/app/components/tables/settings-security-passkey-table-actions.tsx +++ b/apps/remix/app/components/tables/settings-security-passkey-table-actions.tsx @@ -1,3 +1,4 @@ +import { ZNameSchema } from '@documenso/lib/types/name'; import { trpc } from '@documenso/trpc/react'; import { cn } from '@documenso/ui/lib/utils'; import { Button } from '@documenso/ui/primitives/button'; @@ -29,7 +30,7 @@ export type SettingsSecurityPasskeyTableActionsProps = { }; const ZUpdatePasskeySchema = z.object({ - name: z.string(), + name: ZNameSchema, }); type TUpdatePasskeySchema = z.infer; diff --git a/apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx b/apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx index ac668ee3f..d895b338d 100644 --- a/apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx +++ b/apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx @@ -8,6 +8,7 @@ import { getHighestOrganisationRoleInGroup } from '@documenso/lib/utils/organisa import { trpc } from '@documenso/trpc/react'; import type { TGetAdminOrganisationResponse } from '@documenso/trpc/server/admin-router/get-admin-organisation.types'; import { ZUpdateAdminOrganisationRequestSchema } from '@documenso/trpc/server/admin-router/update-admin-organisation.types'; +import { cn } from '@documenso/ui/lib/utils'; import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@documenso/ui/primitives/accordion'; import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert'; import { Badge } from '@documenso/ui/primitives/badge'; @@ -30,7 +31,7 @@ import { useToast } from '@documenso/ui/primitives/use-toast'; import { zodResolver } from '@hookform/resolvers/zod'; import { msg } from '@lingui/core/macro'; import { Trans, useLingui } from '@lingui/react/macro'; -import { OrganisationMemberRole } from '@prisma/client'; +import { OrganisationMemberRole, SubscriptionStatus } from '@prisma/client'; import { ExternalLinkIcon, InfoIcon, Loader } from 'lucide-react'; import { useMemo } from 'react'; import { useForm } from 'react-hook-form'; @@ -42,7 +43,6 @@ import { AdminOrganisationDeleteDialog } from '~/components/dialogs/admin-organi import { AdminOrganisationMemberDeleteDialog } from '~/components/dialogs/admin-organisation-member-delete-dialog'; import { AdminOrganisationMemberUpdateDialog } from '~/components/dialogs/admin-organisation-member-update-dialog'; import { AdminOrganisationSyncSubscriptionDialog } from '~/components/dialogs/admin-organisation-sync-subscription-dialog'; -import { DetailsCard, DetailsValue } from '~/components/general/admin-details'; import { AdminGlobalSettingsSection } from '~/components/general/admin-global-settings-section'; import { ClaimLimitFields } from '~/components/general/claim-limit-fields'; import { GenericErrorLayout } from '~/components/general/generic-error-layout'; @@ -268,54 +268,32 @@ export default function OrganisationGroupSettingsPage({ params, loaderData }: Ro -
-
-
-

- Organisation usage -

-

- Current usage against organisation limits. -

-
-
+ -
- Members}> - - {organisation.members.length} /{' '} - {organisation.organisationClaim.memberCount === 0 - ? t`Unlimited` - : organisation.organisationClaim.memberCount} - - - - Teams}> - - {organisation.teams.length} /{' '} - {organisation.organisationClaim.teamCount === 0 ? t`Unlimited` : organisation.organisationClaim.teamCount} - - -
- -
- -
-
+
-

+

Global Settings

-

+

Default settings applied to this organisation.

@@ -335,7 +313,15 @@ export default function OrganisationGroupSettingsPage({ params, loaderData }: Ro className="mt-16" /> - +
Subscription @@ -343,7 +329,12 @@ export default function OrganisationGroupSettingsPage({ params, loaderData }: Ro {organisation.subscription ? ( - {i18n._(SUBSCRIPTION_STATUS_MAP[organisation.subscription.status])} subscription found + + {organisation.subscription.status === SubscriptionStatus.ACTIVE && ( + ) : ( No subscription found @@ -356,6 +347,7 @@ export default function OrganisationGroupSettingsPage({ params, loaderData }: Ro
} /> -