Compare commits

...

24 Commits

Author SHA1 Message Date
Catalin Pit 12d44e1c59 Merge branch 'main' into feature/pdf-placeholder-selection-fields 2026-07-08 10:34:25 +03:00
roshboi 1b1e3d197b Add "Apply Your License Key" self-hosting guide (#3077)
Adds a dedicated self-hosting page documenting how to apply an
Enterprise license key, and cross-links it from the related docs.

New page: self-hosting/configuration/license.mdx — "Apply Your License
Key"
Set NEXT_PRIVATE_DOCUMENSO_LICENSE_KEY (Docker Compose / docker run /
.env tabs)
2026-07-08 16:11:49 +10:00
Lucas Smith a276e18e1f chore: upgrade deps (#3065) 2026-07-08 16:11:00 +10:00
Catalin Pit 9dc66afc7b Merge branch 'main' into feature/pdf-placeholder-selection-fields 2026-07-06 09:44:38 +03:00
Catalin Pit 50f272be87 fix: admin organisation limits and usage UI (#3014) 2026-07-02 16:50:11 +10:00
Catalin Pit a55e6d9484 fix: block invisible & control characters and URLs in names (#2978) 2026-07-02 16:20:56 +10:00
David Nguyen d35d13db23 fix: remove presigned branding upload (#3053) 2026-07-02 15:51:19 +10:00
Catalin Pit 7a13be6bf2 Merge branch 'main' into feature/pdf-placeholder-selection-fields 2026-06-29 10:10:49 +03:00
Catalin Pit 1032028395 Merge branch 'main' into feature/pdf-placeholder-selection-fields 2026-06-25 09:02:29 +03:00
Catalin Pit 2396d0d5d0 refactor: improve logger for wrong pdf placeholders 2026-06-19 13:26:54 +03:00
Catalin Pit dac262edc9 chore: add comment 2026-06-19 10:36:48 +03:00
Catalin Pit 25eb4ffedf refactor: simplify placeholder key-value parsing logic in PDF helpers 2026-06-19 08:43:22 +03:00
Catalin Pit ee0ea82635 chore: add tests 2026-06-18 15:06:15 +03:00
Catalin Pit 5f7f6698fd refactor: improve error handling and validation for PDF field metadata parsing 2026-06-18 14:03:21 +03:00
Catalin Pit 402809c809 refactor: add detailed docs for placeholder string parsing functions 2026-06-18 11:52:27 +03:00
Catalin Pit 733e273c05 Merge branch 'main' into feature/pdf-placeholder-selection-fields 2026-06-18 09:32:18 +03:00
Catalin Pit 7c7933c2d4 Merge branch 'main' into feature/pdf-placeholder-selection-fields 2026-06-17 11:50:50 +03:00
Catalin Pit bb120d65dd Merge branch 'main' into feature/pdf-placeholder-selection-fields 2026-05-27 12:50:34 +03:00
Catalin Pit 9c14aa6297 Merge branch 'main' into feature/pdf-placeholder-selection-fields 2026-05-26 08:54:20 +03:00
Catalin Pit 6387e809a8 chore: merged main 2026-05-26 08:51:26 +03:00
Catalin Pit 5b2b1591a4 feat: enhance placeholder parsing and validation for selection fields in PDF helpers 2026-05-26 08:45:57 +03:00
Catalin Pit e5cb4c6bfd chore: refine dropdown placeholder support in PDF fields documentation and implementation 2026-05-22 11:27:18 +03:00
Catalin Pit 8185f916d3 chore: update plan and documentation 2026-05-22 09:00:46 +03:00
Catalin Pit c308dbf257 feat: support selection field options in PDF placeholders 2026-05-08 12:11:06 +03:00
89 changed files with 4786 additions and 1914 deletions
@@ -0,0 +1,222 @@
---
date: 2026-05-07
title: Pdf Placeholder Selection Fields
---
## Summary
Extend PDF placeholders so radio and dropdown fields can be configured from the existing Documenso placeholder syntax:
```text
{{FIELD_TYPE, RECIPIENT, key=value, key=value}}
```
Do not introduce a new delimiter style. Existing applications may already generate placeholders in this format, so the new selection-field behavior should fit into it.
## Goals
- Keep the current placeholder grammar unchanged.
- Support checkbox placeholders with option lists, checked values, validation, direction, required, read-only, and font size.
- Support radio placeholders with option lists, default/preselected values, direction, required, read-only, and font size.
- Support dropdown placeholders with option lists, default value, required, read-only, and font size.
- Use `options` as the only public list key in PDF placeholders.
- Convert `options` into internal `fieldMeta.values` during parsing.
- Make generated fields usable immediately in the editor, signing UI, preview renderer, and final PDF export.
## Non-Goals
- No semicolon placeholder syntax.
- No `values` alias in PDF placeholder syntax.
- No database migration.
- No behavior change for existing placeholders such as `{{text, r1, required=true}}`.
## Placeholder Syntax
Use the existing comma-separated placeholder format:
```text
{{checkbox, r1, options=Email|SMS|Phone, checked=Email|Phone, validationRule=atLeast, validationLength=1}}
{{radio, r1, options=Card|Bank transfer|Check, defaultValue=Check}}
{{radio, r1, options=Basic|Pro|Enterprise, selected=Pro, direction=horizontal}}
{{dropdown, r1, options=United States|Canada|United Kingdom}}
{{dropdown, r2, options=Sales|Legal|Finance, defaultValue=Legal}}
```
Use `|` inside `options` because `,` is already the top-level placeholder delimiter.
Parsing rules:
- Split top-level placeholder tokens on unescaped commas.
- Split metadata tokens on the first unescaped equals sign.
- Split `options` on unescaped pipes.
- Trim option values and drop empty values.
- Preserve option order.
- Support escaped delimiters: `\,`, `\=`, and `\|`.
- Treat field type values case-insensitively.
## Field Type Mapping
- `checkbox` maps to `FieldType.CHECKBOX`.
- `radio` maps to `FieldType.RADIO`.
- `dropdown` maps to `FieldType.DROPDOWN`.
## Metadata Mapping
### Checkbox
Example:
```text
{{checkbox, r1, options=Email|SMS|Phone, checked=Email|Phone, validationRule=atLeast, validationLength=1}}
```
Normalize to:
```ts
{
type: FieldType.CHECKBOX,
fieldMeta: {
type: 'checkbox',
validationRule: 'Select at least',
validationLength: 1,
values: [
{ id: 1, value: 'Email', checked: true },
{ id: 2, value: 'SMS', checked: false },
{ id: 3, value: 'Phone', checked: true },
],
},
}
```
Accepted keys:
- `options`
- `checked`
- `direction=vertical|horizontal`
- `validationRule=atLeast|exactly|atMost`
- `validationLength=1`
- `required=true|false`
- `readOnly=true|false`
- `fontSize=12`
Map checkbox validation aliases internally: `atLeast` -> `Select at least`, `exactly` -> `Select exactly`, `atMost` -> `Select at most`.
Checkbox placeholders do not support `label` or `placeholder` metadata.
### Radio
Example:
```text
{{radio, r1, options=Card|Bank transfer|Check, selected=Bank transfer}}
```
Normalize to:
```ts
{
type: FieldType.RADIO,
fieldMeta: {
type: 'radio',
values: [
{ id: 1, value: 'Card', checked: false },
{ id: 2, value: 'Bank transfer', checked: true },
{ id: 3, value: 'Check', checked: false },
],
},
}
```
Accepted keys:
- `options`
- `selected`, `default`, or `defaultValue`
- `direction=vertical|horizontal`
- `required=true|false`
- `readOnly=true|false`
- `fontSize=12`
Radio placeholders do not support `label` or `placeholder` metadata.
### Dropdown
Example:
```text
{{dropdown, r1, options=Sales|Legal|Finance, defaultValue=Legal}}
```
Normalize to:
```ts
{
type: FieldType.DROPDOWN,
fieldMeta: {
type: 'dropdown',
values: [{ value: 'Sales' }, { value: 'Legal' }, { value: 'Finance' }],
defaultValue: 'Legal',
},
}
```
Accepted keys:
- `options`
- `selected`, `default`, or `defaultValue`
- `required=true|false`
- `readOnly=true|false`
- `fontSize=12`
`defaultValue` should only be set if it matches one parsed option.
Dropdown placeholders do not support `label` or `placeholder` metadata.
## Code Touchpoints
- `packages/lib/server-only/pdf/helpers.ts`
- Extend `parseFieldMetaFromPlaceholder` so `options` normalizes into checkbox/radio/dropdown `fieldMeta.values`.
- Add delimiter-aware helpers for commas, equals signs, and pipes.
- `packages/lib/server-only/pdf/auto-place-fields.ts`
- Replace plain comma splitting with delimiter-aware splitting.
- Preserve the existing positional structure: field type, recipient, metadata.
- `packages/lib/types/field-meta.ts`
- Keep current internal schemas: checkbox/radio/dropdown still store options as `fieldMeta.values`.
- `packages/ui/primitives/document-flow/field-content.tsx`
- Display a radio fallback when a placeholder-created radio has no options.
- Docs:
- `apps/docs/content/docs/users/documents/advanced/pdf-placeholders.mdx`
- `apps/docs/content/docs/developers/api/fields.mdx`
## Test Plan
Unit tests:
- `options=Yes|No|Maybe` becomes stable radio values.
- `selected=No` marks only the matching radio option checked.
- Checkbox `options`, `checked`, `validationRule`, and `validationLength` normalize correctly.
- Dropdown `options` and `defaultValue` normalize correctly.
- Escaped delimiters parse correctly, for example `options=Sales\|Ops|Legal\, Compliance|A\=B`.
E2E/API tests:
- Add a PDF fixture with checkbox, radio, and dropdown placeholders using the current syntax.
- Verify created fields have schema-compatible metadata and expected options/defaults.
Suggested verification:
```bash
npm run test -w @documenso/lib -- server-only/pdf/helpers.test.ts
npm run test:dev -w @documenso/app-tests -- e2e/auto-placing-fields/auto-place-fields-document.spec.ts
npm run test:dev -w @documenso/app-tests -- e2e/envelope-editor-v2/envelope-fields.spec.ts
npx tsc --noEmit -p packages/lib/tsconfig.json
npx tsc --noEmit -p apps/remix/tsconfig.json
```
Do not use `npm run build` for routine verification unless explicitly requested.
## Decisions
- Keep the existing placeholder format.
- Use only `options` publicly.
- Keep `values` as an internal metadata field only.
- Use `|` as the option delimiter inside `options`.
@@ -76,6 +76,8 @@ The Enterprise Edition is required when you:
4. Restart your Documenso instance 4. Restart your Documenso instance
5. Verify the license is active in the **Admin Panel** under the **Stats** section 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> </Accordion>
</Accordions> </Accordions>
@@ -197,7 +199,7 @@ See [Support](/docs/policies/support) for complete support options.
1. Sign the Enterprise license agreement 1. Sign the Enterprise license agreement
2. Receive license key and access credentials 2. Receive license key and access credentials
3. Deploy using [self-hosting guides](/docs/self-hosting) or access Documenso Cloud 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
</Step> </Step>
<Step> <Step>
@@ -238,6 +240,7 @@ See [Support](/docs/policies/support) for complete support options.
## Related ## 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 - [Community Edition](/docs/policies/community-edition) - AGPL-3.0 open-source license
- [Licenses](/docs/policies/licenses) - Complete licensing overview and FAQ - [Licenses](/docs/policies/licenses) - Complete licensing overview and FAQ
- [Support](/docs/policies/support) - Support channels and response times - [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 ## 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 | | 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_API_KEY` | Stripe API key for billing |
| `NEXT_PRIVATE_STRIPE_WEBHOOK_SECRET` | Stripe webhook secret | | `NEXT_PRIVATE_STRIPE_WEBHOOK_SECRET` | Stripe webhook secret |
| `NEXT_PRIVATE_SES_ACCESS_KEY_ID` | AWS SES access key for email domain verification | | `NEXT_PRIVATE_SES_ACCESS_KEY_ID` | AWS SES access key for email domain verification |
@@ -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.
<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,6 +2,7 @@
"title": "Configuration", "title": "Configuration",
"pages": [ "pages": [
"environment", "environment",
"license",
"database", "database",
"email", "email",
"storage", "storage",
@@ -49,7 +49,7 @@ The callback URL is fixed — Documenso derives it from `NEXT_PUBLIC_WEBAPP_URL`
### Enterprise Edition license ### 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.
</Step> </Step>
<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. 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).
--- ---
@@ -109,6 +109,37 @@ You can customize fields by adding options after the recipient identifier:
| `maxValue` | Number | Maximum allowed value | | `maxValue` | Number | Maximum allowed value |
| `numberFormat` | Format string | Number display format | | `numberFormat` | Format string | Number display format |
### Selection Field Options
Checkbox, radio, and dropdown placeholders can define their selectable choices via the `options` property.
Separate choices with pipe (`|`) characters.
Checkbox, radio, and dropdown placeholders do not support `label` or `placeholder` metadata.
| Option | Applies To | Values | Description |
| ------------------ | ------------------------- | ------------------------ | ---------------------------------------- |
| `options` | Checkbox, Radio, Dropdown | `Option 1|Option 2` | Selectable choices |
| `checked` | Checkbox | `Option 1|Option 2` | Pre-checked choices |
| `selected` | Radio, Dropdown | One option value | Pre-selected/default choice |
| `default` | Radio, Dropdown | One option value | Alias for `selected` |
| `defaultValue` | Radio, Dropdown | One option value | Alias for `selected` |
| `direction` | Checkbox, Radio | `vertical`, `horizontal` | Option layout |
| `validationRule` | Checkbox | `atLeast`, `exactly`, `atMost` | Checkbox selection validation rule |
| `validationLength` | Checkbox | Number (e.g., `1`) | Checkbox validation option count |
| `required` | Checkbox, Radio, Dropdown | `true`, `false` | Whether the field must be completed |
| `readOnly` | Checkbox, Radio, Dropdown | `true`, `false` | Whether the pre-selected value is locked |
| `fontSize` | Checkbox, Radio, Dropdown | Number (e.g., `12`) | Field text size |
For checkbox validation, `validationLength` defines the option count:
- `atLeast` means at least that many options must be selected
- `exactly` means exactly that many options must be selected
- `atMost` means at most that many options must be selected
If an option needs a literal delimiter, escape it with a backslash:
```
{{dropdown, r1, options=Sales\|Ops|Legal\, Compliance|A\=B}}
```
### Examples with Options ### Examples with Options
``` ```
@@ -116,6 +147,10 @@ You can customize fields by adding options after the recipient identifier:
{{number, r1, minValue=0, maxValue=100, value=50}} {{number, r1, minValue=0, maxValue=100, value=50}}
{{name, r1, fontSize=14}} {{name, r1, fontSize=14}}
{{text, r2, readOnly=true, text=Contract #12345}} {{text, r2, readOnly=true, text=Contract #12345}}
{{checkbox, r1, options=Email|SMS|Phone, checked=Email|Phone, validationRule=atLeast, validationLength=1}}
{{radio, r1, options=Card|Bank transfer|Check, selected=Check}}
{{dropdown, r1, options=United States|Canada|United Kingdom}}
{{dropdown, r2, options=Sales|Legal|Finance, defaultValue=Legal}}
``` ```
<Callout type="info"> <Callout type="info">
@@ -1,3 +1,4 @@
import { ZNameSchema } from '@documenso/lib/types/name';
import { trpc } from '@documenso/trpc/react'; import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
import { import {
@@ -23,7 +24,7 @@ import { useParams } from 'react-router';
import { z } from 'zod'; import { z } from 'zod';
const ZCreateFolderFormSchema = z.object({ const ZCreateFolderFormSchema = z.object({
name: z.string().min(1, { message: 'Folder name is required' }), name: ZNameSchema,
}); });
type TCreateFolderFormSchema = z.infer<typeof ZCreateFolderFormSchema>; type TCreateFolderFormSchema = z.infer<typeof ZCreateFolderFormSchema>;
@@ -65,7 +66,7 @@ export const FolderCreateDialog = ({ type, trigger, parentFolderId, ...props }:
toast({ toast({
description: t`Folder created successfully`, description: t`Folder created successfully`,
}); });
} catch (err) { } catch (_err) {
toast({ toast({
title: t`Failed to create folder`, title: t`Failed to create folder`,
description: t`An unknown error occurred while creating the folder.`, description: t`An unknown error occurred while creating the folder.`,
@@ -1,5 +1,6 @@
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { DocumentVisibility } from '@documenso/lib/types/document-visibility'; import { DocumentVisibility } from '@documenso/lib/types/document-visibility';
import { ZNameSchema } from '@documenso/lib/types/name';
import { trpc } from '@documenso/trpc/react'; import { trpc } from '@documenso/trpc/react';
import type { TFolderWithSubfolders } from '@documenso/trpc/server/folder-router/schema'; import type { TFolderWithSubfolders } from '@documenso/trpc/server/folder-router/schema';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
@@ -23,8 +24,6 @@ import { useEffect } from 'react';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { z } from 'zod'; import { z } from 'zod';
import { useOptionalCurrentTeam } from '~/providers/team';
export type FolderUpdateDialogProps = { export type FolderUpdateDialogProps = {
folder: TFolderWithSubfolders | null; folder: TFolderWithSubfolders | null;
isOpen: boolean; isOpen: boolean;
@@ -32,7 +31,7 @@ export type FolderUpdateDialogProps = {
} & Omit<DialogPrimitive.DialogProps, 'children'>; } & Omit<DialogPrimitive.DialogProps, 'children'>;
export const ZUpdateFolderFormSchema = z.object({ export const ZUpdateFolderFormSchema = z.object({
name: z.string().min(1), name: ZNameSchema,
visibility: z.nativeEnum(DocumentVisibility).optional(), visibility: z.nativeEnum(DocumentVisibility).optional(),
}); });
@@ -40,7 +39,6 @@ export type TUpdateFolderFormSchema = z.infer<typeof ZUpdateFolderFormSchema>;
export const FolderUpdateDialog = ({ folder, isOpen, onOpenChange }: FolderUpdateDialogProps) => { export const FolderUpdateDialog = ({ folder, isOpen, onOpenChange }: FolderUpdateDialogProps) => {
const { t } = useLingui(); const { t } = useLingui();
const team = useOptionalCurrentTeam();
const { toast } = useToast(); const { toast } = useToast();
const { mutateAsync: updateFolder } = trpc.folder.updateFolder.useMutation(); const { mutateAsync: updateFolder } = trpc.folder.updateFolder.useMutation();
@@ -1,5 +1,6 @@
import { MAXIMUM_PASSKEYS } from '@documenso/lib/constants/auth'; import { MAXIMUM_PASSKEYS } from '@documenso/lib/constants/auth';
import { AppError } from '@documenso/lib/errors/app-error'; import { AppError } from '@documenso/lib/errors/app-error';
import { ZNameSchema } from '@documenso/lib/types/name';
import { trpc } from '@documenso/trpc/react'; import { trpc } from '@documenso/trpc/react';
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert'; import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
@@ -25,14 +26,13 @@ import { useForm } from 'react-hook-form';
import { match } from 'ts-pattern'; import { match } from 'ts-pattern';
import { UAParser } from 'ua-parser-js'; import { UAParser } from 'ua-parser-js';
import { z } from 'zod'; import { z } from 'zod';
export type PasskeyCreateDialogProps = { export type PasskeyCreateDialogProps = {
trigger?: React.ReactNode; trigger?: React.ReactNode;
onSuccess?: () => void; onSuccess?: () => void;
} & Omit<DialogPrimitive.DialogProps, 'children'>; } & Omit<DialogPrimitive.DialogProps, 'children'>;
const ZCreatePasskeyFormSchema = z.object({ const ZCreatePasskeyFormSchema = z.object({
passkeyName: z.string().min(3), passkeyName: ZNameSchema,
}); });
type TCreatePasskeyFormSchema = z.infer<typeof ZCreatePasskeyFormSchema>; type TCreatePasskeyFormSchema = z.infer<typeof ZCreatePasskeyFormSchema>;
@@ -1,4 +1,5 @@
import { trpc } from '@documenso/trpc/react'; import { trpc } from '@documenso/trpc/react';
import { ZUpdateTeamEmailMutationSchema } from '@documenso/trpc/server/team-router/schema';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
import { import {
Dialog, Dialog,
@@ -19,16 +20,16 @@ import type * as DialogPrimitive from '@radix-ui/react-dialog';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { useRevalidator } from 'react-router'; import { useRevalidator } from 'react-router';
import { z } from 'zod'; import type { z } from 'zod';
export type TeamEmailUpdateDialogProps = { export type TeamEmailUpdateDialogProps = {
teamEmail: TeamEmail; teamEmail: TeamEmail;
trigger?: React.ReactNode; trigger?: React.ReactNode;
} & Omit<DialogPrimitive.DialogProps, 'children'>; } & Omit<DialogPrimitive.DialogProps, 'children'>;
const ZUpdateTeamEmailFormSchema = z.object({ const ZUpdateTeamEmailFormSchema = ZUpdateTeamEmailMutationSchema.pick({
name: z.string().trim().min(1, { message: 'Please enter a valid name.' }), data: true,
}); }).shape.data;
type TUpdateTeamEmailFormSchema = z.infer<typeof ZUpdateTeamEmailFormSchema>; type TUpdateTeamEmailFormSchema = z.infer<typeof ZUpdateTeamEmailFormSchema>;
@@ -44,6 +45,7 @@ export const TeamEmailUpdateDialog = ({ teamEmail, trigger, ...props }: TeamEmai
defaultValues: { defaultValues: {
name: teamEmail.name, name: teamEmail.name,
}, },
mode: 'onSubmit',
}); });
const { mutateAsync: updateTeamEmail } = trpc.team.email.update.useMutation(); const { mutateAsync: updateTeamEmail } = trpc.team.email.update.useMutation();
@@ -1,5 +1,10 @@
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation'; import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app'; import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import {
BRANDING_LOGO_ALLOWED_TYPES,
BRANDING_LOGO_MAX_SIZE_BYTES,
BRANDING_LOGO_MAX_SIZE_MB,
} from '@documenso/lib/constants/branding';
import { DEFAULT_BRAND_COLORS, DEFAULT_BRAND_RADIUS } from '@documenso/lib/constants/theme'; import { DEFAULT_BRAND_COLORS, DEFAULT_BRAND_RADIUS } from '@documenso/lib/constants/theme';
import { ZCssVarsSchema } from '@documenso/lib/types/css-vars'; import { ZCssVarsSchema } from '@documenso/lib/types/css-vars';
import { cn } from '@documenso/ui/lib/utils'; import { cn } from '@documenso/ui/lib/utils';
@@ -23,15 +28,15 @@ import { useCspNonce } from '~/utils/nonce';
import { FormStickySaveBar } from './form-sticky-save-bar'; import { FormStickySaveBar } from './form-sticky-save-bar';
const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB
const ACCEPTED_FILE_TYPES = ['image/jpeg', 'image/png', 'image/webp'];
const ZBrandingPreferencesFormSchema = z.object({ const ZBrandingPreferencesFormSchema = z.object({
brandingEnabled: z.boolean().nullable(), brandingEnabled: z.boolean().nullable(),
brandingLogo: z brandingLogo: z
.instanceof(File) .instanceof(File)
.refine((file) => file.size <= MAX_FILE_SIZE, 'File size must be less than 5MB') .refine(
.refine((file) => ACCEPTED_FILE_TYPES.includes(file.type), 'Only .jpg, .png, and .webp files are accepted') (file) => file.size <= BRANDING_LOGO_MAX_SIZE_BYTES,
`File size must be less than ${BRANDING_LOGO_MAX_SIZE_MB}MB`,
)
.refine((file) => BRANDING_LOGO_ALLOWED_TYPES.includes(file.type), 'Only .jpg, .png, and .webp files are accepted')
.nullish(), .nullish(),
brandingUrl: z.string().url().optional().or(z.literal('')), brandingUrl: z.string().url().optional().or(z.literal('')),
brandingCompanyDetails: z.string().max(500).optional(), brandingCompanyDetails: z.string().max(500).optional(),
@@ -245,7 +250,7 @@ export function BrandingPreferencesForm({
<FormControl className="relative"> <FormControl className="relative">
<Input <Input
type="file" type="file"
accept={ACCEPTED_FILE_TYPES.join(',')} accept={BRANDING_LOGO_ALLOWED_TYPES.join(',')}
disabled={!isBrandingEnabled} disabled={!isBrandingEnabled}
onChange={(e) => { onChange={(e) => {
const file = e.target.files?.[0]; const file = e.target.files?.[0];
@@ -1,3 +1,4 @@
import { ZNameSchema } from '@documenso/lib/types/name';
import { import {
Form, Form,
FormControl, FormControl,
@@ -15,8 +16,8 @@ import { useForm } from 'react-hook-form';
import { z } from 'zod'; import { z } from 'zod';
const ZEmailTransportFormSchema = z.object({ const ZEmailTransportFormSchema = z.object({
name: z.string().min(1), name: ZNameSchema,
fromName: z.string().min(1), fromName: ZNameSchema,
fromAddress: z.string().email(), fromAddress: z.string().email(),
type: z.enum(['SMTP_AUTH', 'SMTP_API', 'RESEND', 'MAILCHANNELS']), type: z.enum(['SMTP_AUTH', 'SMTP_API', 'RESEND', 'MAILCHANNELS']),
host: z.string().optional(), host: z.string().optional(),
+1 -1
View File
@@ -1,5 +1,5 @@
import { useSession } from '@documenso/lib/client-only/providers/session'; 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 { trpc } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils'; import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
+1 -1
View File
@@ -1,8 +1,8 @@
import communityCardsImage from '@documenso/assets/images/community-cards.png'; import communityCardsImage from '@documenso/assets/images/community-cards.png';
import { authClient } from '@documenso/auth/client'; import { authClient } from '@documenso/auth/client';
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics'; 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 { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { ZNameSchema } from '@documenso/lib/types/name';
import { env } from '@documenso/lib/utils/env'; import { env } from '@documenso/lib/utils/env';
import { zEmail } from '@documenso/lib/utils/zod'; import { zEmail } from '@documenso/lib/utils/zod';
import { ZPasswordSchema } from '@documenso/trpc/server/auth-router/schema'; import { ZPasswordSchema } from '@documenso/trpc/server/auth-router/schema';
@@ -5,6 +5,7 @@ import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react'; import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro'; import { Trans } from '@lingui/react/macro';
import type { OrganisationGlobalSettings, TeamGlobalSettings } from '@prisma/client'; import type { OrganisationGlobalSettings, TeamGlobalSettings } from '@prisma/client';
import type { ReactNode } from 'react';
import { DetailsCard, DetailsValue } from '~/components/general/admin-details'; import { DetailsCard, DetailsValue } from '~/components/general/admin-details';
@@ -25,38 +26,72 @@ const emailSettingsKeys = Object.keys(EMAIL_SETTINGS_LABELS) as (keyof TDocument
type AdminGlobalSettingsSectionProps = { type AdminGlobalSettingsSectionProps = {
settings: TeamGlobalSettings | OrganisationGlobalSettings | null; settings: TeamGlobalSettings | OrganisationGlobalSettings | null;
isTeam?: boolean; 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 { _ } = useLingui();
const notSetLabel = isTeam ? <Trans>Inherited</Trans> : <Trans>Not set</Trans>;
if (!settings) { if (!settings) {
return null; return null;
} }
const textValue = (value: string | null | undefined) => { const notSet = <Trans>Not set</Trans>;
if (value === null || value === undefined) {
return notSetLabel; const inheritedValue = (value: ReactNode) => {
if (!isTeam || value === null) {
return notSet;
} }
return value; return (
<span className="flex items-center gap-1.5">
<span className="text-muted-foreground">
<Trans>Inherited</Trans>:
</span>
<span>{value}</span>
</span>
);
}; };
const brandingTextValue = (value: string | null | undefined) => { const textValue = (value: string | null | undefined, inherited?: string | null) => {
if (value === null || value === undefined || value.trim() === '') { if (value && value.trim() !== '') {
return notSetLabel; return value;
} }
return value; if (inherited && inherited.trim() !== '') {
return inheritedValue(inherited);
}
return notSet;
}; };
const booleanValue = (value: boolean | null | undefined) => { const booleanLabel = (value: boolean) => (value ? <Trans>Enabled</Trans> : <Trans>Disabled</Trans>);
if (value === null || value === undefined) {
return notSetLabel; const booleanValue = (value: boolean | null | undefined, inherited?: boolean | null) => {
if (value !== null && value !== undefined) {
return booleanLabel(value);
} }
return value ? <Trans>Enabled</Trans> : <Trans>Disabled</Trans>; 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); const parsedEmailSettings = ZDocumentEmailSettingsSchema.safeParse(settings.emailDocumentSettings);
@@ -65,70 +100,82 @@ export const AdminGlobalSettingsSection = ({ settings, isTeam = false }: AdminGl
<div className="grid grid-cols-1 gap-3 text-sm sm:grid-cols-2 lg:grid-cols-3"> <div className="grid grid-cols-1 gap-3 text-sm sm:grid-cols-2 lg:grid-cols-3">
<DetailsCard label={<Trans>Document visibility</Trans>}> <DetailsCard label={<Trans>Document visibility</Trans>}>
<DetailsValue> <DetailsValue>
{settings.documentVisibility != null {visibilityValue(settings.documentVisibility, inheritedSettings?.documentVisibility)}
? _(DOCUMENT_VISIBILITY[settings.documentVisibility].value)
: notSetLabel}
</DetailsValue> </DetailsValue>
</DetailsCard> </DetailsCard>
<DetailsCard label={<Trans>Document language</Trans>}> <DetailsCard label={<Trans>Document language</Trans>}>
<DetailsValue>{textValue(settings.documentLanguage)}</DetailsValue> <DetailsValue>{textValue(settings.documentLanguage, inheritedSettings?.documentLanguage)}</DetailsValue>
</DetailsCard> </DetailsCard>
<DetailsCard label={<Trans>Document timezone</Trans>}> <DetailsCard label={<Trans>Document timezone</Trans>}>
<DetailsValue>{textValue(settings.documentTimezone)}</DetailsValue> <DetailsValue>{textValue(settings.documentTimezone, inheritedSettings?.documentTimezone)}</DetailsValue>
</DetailsCard> </DetailsCard>
<DetailsCard label={<Trans>Date format</Trans>}> <DetailsCard label={<Trans>Date format</Trans>}>
<DetailsValue>{textValue(settings.documentDateFormat)}</DetailsValue> <DetailsValue>{textValue(settings.documentDateFormat, inheritedSettings?.documentDateFormat)}</DetailsValue>
</DetailsCard> </DetailsCard>
<DetailsCard label={<Trans>Include sender details</Trans>}> <DetailsCard label={<Trans>Include sender details</Trans>}>
<DetailsValue>{booleanValue(settings.includeSenderDetails)}</DetailsValue> <DetailsValue>
{booleanValue(settings.includeSenderDetails, inheritedSettings?.includeSenderDetails)}
</DetailsValue>
</DetailsCard> </DetailsCard>
<DetailsCard label={<Trans>Include signing certificate</Trans>}> <DetailsCard label={<Trans>Include signing certificate</Trans>}>
<DetailsValue>{booleanValue(settings.includeSigningCertificate)}</DetailsValue> <DetailsValue>
{booleanValue(settings.includeSigningCertificate, inheritedSettings?.includeSigningCertificate)}
</DetailsValue>
</DetailsCard> </DetailsCard>
<DetailsCard label={<Trans>Include audit log</Trans>}> <DetailsCard label={<Trans>Include audit log</Trans>}>
<DetailsValue>{booleanValue(settings.includeAuditLog)}</DetailsValue> <DetailsValue>{booleanValue(settings.includeAuditLog, inheritedSettings?.includeAuditLog)}</DetailsValue>
</DetailsCard> </DetailsCard>
<DetailsCard label={<Trans>Delegate document ownership</Trans>}> <DetailsCard label={<Trans>Delegate document ownership</Trans>}>
<DetailsValue>{booleanValue(settings.delegateDocumentOwnership)}</DetailsValue> <DetailsValue>
{booleanValue(settings.delegateDocumentOwnership, inheritedSettings?.delegateDocumentOwnership)}
</DetailsValue>
</DetailsCard> </DetailsCard>
<DetailsCard label={<Trans>Typed signature</Trans>}> <DetailsCard label={<Trans>Typed signature</Trans>}>
<DetailsValue>{booleanValue(settings.typedSignatureEnabled)}</DetailsValue> <DetailsValue>
{booleanValue(settings.typedSignatureEnabled, inheritedSettings?.typedSignatureEnabled)}
</DetailsValue>
</DetailsCard> </DetailsCard>
<DetailsCard label={<Trans>Upload signature</Trans>}> <DetailsCard label={<Trans>Upload signature</Trans>}>
<DetailsValue>{booleanValue(settings.uploadSignatureEnabled)}</DetailsValue> <DetailsValue>
{booleanValue(settings.uploadSignatureEnabled, inheritedSettings?.uploadSignatureEnabled)}
</DetailsValue>
</DetailsCard> </DetailsCard>
<DetailsCard label={<Trans>Draw signature</Trans>}> <DetailsCard label={<Trans>Draw signature</Trans>}>
<DetailsValue>{booleanValue(settings.drawSignatureEnabled)}</DetailsValue> <DetailsValue>
{booleanValue(settings.drawSignatureEnabled, inheritedSettings?.drawSignatureEnabled)}
</DetailsValue>
</DetailsCard> </DetailsCard>
<DetailsCard label={<Trans>Branding</Trans>}> <DetailsCard label={<Trans>Branding</Trans>}>
<DetailsValue>{booleanValue(settings.brandingEnabled)}</DetailsValue> <DetailsValue>{booleanValue(settings.brandingEnabled, inheritedSettings?.brandingEnabled)}</DetailsValue>
</DetailsCard> </DetailsCard>
<DetailsCard label={<Trans>Branding logo</Trans>}> <DetailsCard label={<Trans>Branding logo</Trans>}>
<DetailsValue>{brandingTextValue(settings.brandingLogo)}</DetailsValue> <DetailsValue>{textValue(settings.brandingLogo, inheritedSettings?.brandingLogo)}</DetailsValue>
</DetailsCard> </DetailsCard>
<DetailsCard label={<Trans>Branding URL</Trans>}> <DetailsCard label={<Trans>Branding URL</Trans>}>
<DetailsValue>{brandingTextValue(settings.brandingUrl)}</DetailsValue> <DetailsValue>{textValue(settings.brandingUrl, inheritedSettings?.brandingUrl)}</DetailsValue>
</DetailsCard> </DetailsCard>
<DetailsCard label={<Trans>Branding company details</Trans>}> <DetailsCard label={<Trans>Branding company details</Trans>}>
<DetailsValue>{brandingTextValue(settings.brandingCompanyDetails)}</DetailsValue> <DetailsValue>
{textValue(settings.brandingCompanyDetails, inheritedSettings?.brandingCompanyDetails)}
</DetailsValue>
</DetailsCard> </DetailsCard>
<DetailsCard label={<Trans>Email reply-to</Trans>}> <DetailsCard label={<Trans>Email reply-to</Trans>}>
<DetailsValue>{textValue(settings.emailReplyTo)}</DetailsValue> <DetailsValue>{textValue(settings.emailReplyTo, inheritedSettings?.emailReplyTo)}</DetailsValue>
</DetailsCard> </DetailsCard>
{isTeam && parsedEmailSettings.success && ( {isTeam && parsedEmailSettings.success && (
@@ -145,7 +192,7 @@ export const AdminGlobalSettingsSection = ({ settings, isTeam = false }: AdminGl
)} )}
<DetailsCard label={<Trans>AI features</Trans>}> <DetailsCard label={<Trans>AI features</Trans>}>
<DetailsValue>{booleanValue(settings.aiFeaturesEnabled)}</DetailsValue> <DetailsValue>{booleanValue(settings.aiFeaturesEnabled, inheritedSettings?.aiFeaturesEnabled)}</DetailsValue>
</DetailsCard> </DetailsCard>
</div> </div>
); );
@@ -1,6 +1,7 @@
import { authClient } from '@documenso/auth/client'; import { authClient } from '@documenso/auth/client';
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics'; import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
import { AppError } from '@documenso/lib/errors/app-error'; import { AppError } from '@documenso/lib/errors/app-error';
import { ZNameSchema } from '@documenso/lib/types/name';
import { env } from '@documenso/lib/utils/env'; import { env } from '@documenso/lib/utils/env';
import { zEmail } from '@documenso/lib/utils/zod'; import { zEmail } from '@documenso/lib/utils/zod';
import { ZPasswordSchema } from '@documenso/trpc/server/auth-router/schema'; import { ZPasswordSchema } from '@documenso/trpc/server/auth-router/schema';
@@ -19,7 +20,6 @@ import { useRef } from 'react';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { useNavigate } from 'react-router'; import { useNavigate } from 'react-router';
import { z } from 'zod'; import { z } from 'zod';
import { SIGNUP_ERROR_MESSAGES } from '~/components/forms/signup'; import { SIGNUP_ERROR_MESSAGES } from '~/components/forms/signup';
export type ClaimAccountProps = { export type ClaimAccountProps = {
@@ -30,7 +30,7 @@ export type ClaimAccountProps = {
export const ZClaimAccountFormSchema = z export const ZClaimAccountFormSchema = z
.object({ .object({
name: z.string().trim().min(1, { message: msg`Please enter a valid name.`.id }), name: ZNameSchema,
email: zEmail().min(1), email: zEmail().min(1),
password: ZPasswordSchema, password: ZPasswordSchema,
}) })
@@ -1,11 +1,4 @@
import { import { FormControl, FormField, FormItem, FormLabel, FormMessage } from '@documenso/ui/primitives/form/form';
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input'; import { Input } from '@documenso/ui/primitives/input';
import { Trans, useLingui } from '@lingui/react/macro'; import { Trans, useLingui } from '@lingui/react/macro';
import type { ReactNode } from 'react'; 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'; 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<T extends FieldValues> = { type ClaimLimitFieldsProps<T extends FieldValues> = {
control: Control<T>; control: Control<T>;
/** e.g. '' for the claim form, 'claims.' for the org admin form. */ /** e.g. '' for the claim form, 'claims.' for the org admin form. */
@@ -20,6 +20,12 @@ type ClaimLimitFieldsProps<T extends FieldValues> = {
disabled?: boolean; disabled?: boolean;
}; };
type LimitGroup = {
title: ReactNode;
quotaKey: string;
rateLimitKey: string;
};
export const ClaimLimitFields = <T extends FieldValues>({ export const ClaimLimitFields = <T extends FieldValues>({
control, control,
prefix = '', prefix = '',
@@ -30,13 +36,33 @@ export const ClaimLimitFields = <T extends FieldValues>({
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions // eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const name = (key: string) => `${prefix}${key}` as Path<T>; const name = (key: string) => `${prefix}${key}` as Path<T>;
const renderQuotaField = (key: string, label: ReactNode, description: ReactNode) => ( const limitGroups: LimitGroup[] = [
{
title: <Trans>Documents</Trans>,
quotaKey: 'documentQuota',
rateLimitKey: 'documentRateLimits',
},
{
title: <Trans>Emails</Trans>,
quotaKey: 'emailQuota',
rateLimitKey: 'emailRateLimits',
},
{
title: <Trans>API</Trans>,
quotaKey: 'apiQuota',
rateLimitKey: 'apiRateLimits',
},
];
const renderQuotaField = (group: LimitGroup) => (
<FormField <FormField
control={control} control={control}
name={name(key)} name={name(group.quotaKey)}
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>{label}</FormLabel> <FormLabel className="text-muted-foreground text-xs">
<Trans>Monthly quota</Trans>
</FormLabel>
<FormControl> <FormControl>
<Input <Input
type="number" type="number"
@@ -47,20 +73,18 @@ export const ClaimLimitFields = <T extends FieldValues>({
onChange={(e) => field.onChange(e.target.value === '' ? null : parseInt(e.target.value, 10))} onChange={(e) => field.onChange(e.target.value === '' ? null : parseInt(e.target.value, 10))}
/> />
</FormControl> </FormControl>
<FormDescription>{description}</FormDescription>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
)} )}
/> />
); );
const renderRateLimitField = (key: string, label: ReactNode) => ( const renderRateLimitField = (group: LimitGroup) => (
<FormField <FormField
control={control} control={control}
name={name(key)} name={name(group.rateLimitKey)}
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>{label}</FormLabel>
<FormControl> <FormControl>
<RateLimitArrayInput value={field.value ?? []} onChange={field.onChange} disabled={disabled} /> <RateLimitArrayInput value={field.value ?? []} onChange={field.onChange} disabled={disabled} />
</FormControl> </FormControl>
@@ -71,27 +95,30 @@ export const ClaimLimitFields = <T extends FieldValues>({
); );
return ( return (
<div className="space-y-4 rounded-md border p-4"> <div className="space-y-3">
<FormLabel> <div>
<Trans>Limits</Trans> <h3 className="font-semibold text-base">
</FormLabel> <Trans>Limits</Trans>
</h3>
<p className="mt-1 text-muted-foreground text-sm">
<Trans>
Empty quota means unlimited, 0 blocks the resource. Rate limit windows accept values like 5m, 1h or 24h.
</Trans>
</p>
</div>
{renderQuotaField( <div className="overflow-hidden rounded-lg border">
'documentQuota', <div className="grid grid-cols-1 divide-y divide-border md:grid-cols-3 md:divide-x md:divide-y-0">
<Trans>Monthly document quota</Trans>, {limitGroups.map((group) => (
<Trans>Empty = Unlimited, 0 = Blocked</Trans>, <div key={group.quotaKey} className="space-y-4 p-4">
)} <h4 className="font-semibold text-sm">{group.title}</h4>
{renderRateLimitField('documentRateLimits', <Trans>Document rate limits</Trans>)}
{renderQuotaField( {renderQuotaField(group)}
'emailQuota', {renderRateLimitField(group)}
<Trans>Monthly email quota</Trans>, </div>
<Trans>Empty = Unlimited, 0 = Blocked</Trans>, ))}
)} </div>
{renderRateLimitField('emailRateLimits', <Trans>Email rate limits</Trans>)} </div>
{renderQuotaField('apiQuota', <Trans>Monthly API quota</Trans>, <Trans>Empty = Unlimited, 0 = Blocked</Trans>)}
{renderRateLimitField('apiRateLimits', <Trans>API rate limits</Trans>)}
</div> </div>
); );
}; };
@@ -1,13 +1,38 @@
import { currentMonthlyPeriod } from '@documenso/lib/universal/monthly-period'; 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 { Progress } from '@documenso/ui/primitives/progress';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@documenso/ui/primitives/select'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@documenso/ui/primitives/select';
import { Trans } from '@lingui/react/macro'; import { Trans } from '@lingui/react/macro';
import type { OrganisationClaim, OrganisationMonthlyStat } from '@prisma/client'; import type { OrganisationClaim, OrganisationMonthlyStat } from '@prisma/client';
import { useState } from 'react'; import type { LucideIcon } from 'lucide-react';
import { match } from 'ts-pattern'; 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'; 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 = { type OrganisationUsagePanelProps = {
organisationId: string; organisationId: string;
monthlyStats: Pick< monthlyStats: Pick<
@@ -15,13 +40,151 @@ type OrganisationUsagePanelProps = {
'period' | 'documentCount' | 'emailCount' | 'apiCount' | 'emailReports' 'period' | 'documentCount' | 'emailCount' | 'apiCount' | 'emailReports'
>[]; >[];
organisationClaim: OrganisationClaim; organisationClaim: OrganisationClaim;
capacityUsage?: CapacityUsage;
};
type UsageCardState = {
status: {
label: ReactNode;
variant: NonNullable<BadgeProps['variant']>;
};
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: <Trans>Unlimited</Trans>, variant: 'neutral' },
percent,
hasFiniteLimit,
progressClassName: '',
subtext: footnote ?? null,
};
}
if (limit === 0) {
return {
status: { label: <Trans>Blocked</Trans>, variant: 'destructive' },
percent,
hasFiniteLimit,
progressClassName: '',
subtext: footnote ?? <Trans>Resource blocked</Trans>,
};
}
if (used > limit) {
return {
status: { label: <Trans>Exceeded</Trans>, variant: 'destructive' },
percent,
hasFiniteLimit,
progressClassName: '[&>div]:bg-destructive',
subtext: footnote ?? null,
};
}
if (isQuotaExceeded(limit, used)) {
return {
status: { label: <Trans>Limit reached</Trans>, variant: 'orange' },
percent,
hasFiniteLimit,
progressClassName: '[&>div]:bg-orange-500 dark:[&>div]:bg-orange-400',
subtext: footnote ?? null,
};
}
if (isQuotaNearing(limit, used)) {
return {
status: { label: <Trans>Near limit</Trans>, variant: 'warning' },
percent,
hasFiniteLimit,
progressClassName: '[&>div]:bg-yellow-500 dark:[&>div]:bg-yellow-400',
subtext: footnote ?? null,
};
}
return {
status: { label: <Trans>Within limit</Trans>, 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 (
<div className="flex flex-col rounded-lg border bg-background p-5">
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2 font-medium text-foreground text-sm">
<Icon className="h-4 w-4 text-muted-foreground" />
<span>{label}</span>
</div>
{!countOnly && (
<Badge variant={status.variant} size="small">
{status.label}
</Badge>
)}
</div>
<div className="mt-4 flex flex-1 flex-col">
<div className="flex items-baseline justify-between gap-2">
<div className="flex items-baseline gap-1.5">
<span className="font-semibold text-3xl text-foreground tabular-nums tracking-tight">
{used.toLocaleString()}
</span>
{hasFiniteLimit ? (
<span className="text-base text-muted-foreground tabular-nums">/ {limit?.toLocaleString()}</span>
) : null}
</div>
{hasFiniteLimit ? (
<span className="font-medium text-muted-foreground text-sm tabular-nums">{percent}%</span>
) : null}
</div>
{hasFiniteLimit ? <Progress className={cn('mt-3 h-2', progressClassName)} value={percent} /> : null}
{subtext ? <p className="mt-2 text-muted-foreground text-xs">{subtext}</p> : null}
</div>
{action ? <div className="mt-4 flex justify-end border-t pt-4">{action}</div> : null}
</div>
);
}; };
export const OrganisationUsagePanel = ({ export const OrganisationUsagePanel = ({
organisationId, organisationId,
monthlyStats, monthlyStats,
organisationClaim, organisationClaim,
capacityUsage,
}: OrganisationUsagePanelProps) => { }: OrganisationUsagePanelProps) => {
const monthlyUsagePeriodId = useId();
const [selectedPeriod, setSelectedPeriod] = useState<string | undefined>(() => monthlyStats[0]?.period); const [selectedPeriod, setSelectedPeriod] = useState<string | undefined>(() => monthlyStats[0]?.period);
const selectedStat = monthlyStats.find((stat) => stat.period === selectedPeriod) ?? monthlyStats[0]; 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. // current period), so only offer the reset action when viewing the current month.
const isCurrentPeriod = selectedStat?.period === currentMonthlyPeriod(); const isCurrentPeriod = selectedStat?.period === currentMonthlyPeriod();
const rows = [ const capacityRows = capacityUsage
? [
{
key: 'members',
label: <Trans>Members</Trans>,
icon: UsersIcon,
used: capacityUsage.members,
limit: normalizeCapacityLimit(organisationClaim.memberCount),
},
{
key: 'teams',
label: <Trans>Teams</Trans>,
icon: UsersRoundIcon,
used: capacityUsage.teams,
limit: normalizeCapacityLimit(organisationClaim.teamCount),
},
]
: [];
const monthlyRows: UsageRow[] = [
{ {
counter: 'document' as const, counter: 'document',
label: <Trans>Documents</Trans>, label: <Trans>Documents</Trans>,
icon: FileIcon,
used: selectedStat?.documentCount ?? 0, used: selectedStat?.documentCount ?? 0,
effectiveLimit: organisationClaim.documentQuota, effectiveLimit: organisationClaim.documentQuota,
}, },
{ {
counter: 'email' as const, counter: 'email',
label: <Trans>Emails</Trans>, label: <Trans>Emails</Trans>,
icon: MailIcon,
used: selectedStat?.emailCount ?? 0, used: selectedStat?.emailCount ?? 0,
effectiveLimit: organisationClaim.emailQuota, effectiveLimit: organisationClaim.emailQuota,
}, },
{ {
counter: 'api' as const, counter: 'api',
label: <Trans>API requests</Trans>, label: <Trans>API requests</Trans>,
icon: PlugIcon,
used: selectedStat?.apiCount ?? 0, used: selectedStat?.apiCount ?? 0,
effectiveLimit: organisationClaim.apiQuota, effectiveLimit: organisationClaim.apiQuota,
}, },
]; ];
return ( return (
<div className="space-y-4 rounded-md border p-4"> <div className="mt-4 space-y-6">
<div className="flex items-center justify-between gap-2"> {capacityRows.length > 0 ? (
<h3 className="font-medium text-sm"> <div className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-3">
<Trans>Usage for period: {selectedStat?.period || 'N/A'}</Trans> {capacityRows.map((row) => (
</h3> <UsageStatCard key={row.key} label={row.label} icon={row.icon} used={row.used} limit={row.limit} />
))}
</div>
) : null}
{monthlyStats.length > 0 && ( <div className="space-y-3">
<Select value={selectedStat?.period} onValueChange={setSelectedPeriod}> <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<SelectTrigger className="w-40"> <h3 id={monthlyUsagePeriodId} className="font-semibold text-base">
<SelectValue /> <Trans>Monthly usage</Trans>
</SelectTrigger> </h3>
<SelectContent>
{monthlyStats.map((stat) => (
<SelectItem key={stat.period} value={stat.period}>
{stat.period}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
{rows.map((row) => { {monthlyStats.length > 0 ? (
const percent = <Select value={selectedStat?.period} onValueChange={setSelectedPeriod}>
row.effectiveLimit && row.effectiveLimit > 0 <SelectTrigger className="h-9 w-full sm:w-44" aria-labelledby={monthlyUsagePeriodId}>
? Math.min(100, Math.round((row.used / row.effectiveLimit) * 100)) <SelectValue />
: 0; </SelectTrigger>
<SelectContent>
{monthlyStats.map((stat) => (
<SelectItem key={stat.period} value={stat.period}>
{stat.period}
</SelectItem>
))}
</SelectContent>
</Select>
) : null}
</div>
return ( <div className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-3">
<div key={row.counter} className="space-y-1"> {monthlyRows.map((row) => (
<div className="flex items-center justify-between text-sm"> <UsageStatCard
<span>{row.label}</span> key={row.counter}
<span className="text-muted-foreground"> label={row.label}
{row.used} /{' '} icon={row.icon}
{match(row.effectiveLimit) used={row.used}
.with(null, () => <Trans>Unlimited</Trans>) limit={row.effectiveLimit}
.with(0, () => <Trans>Blocked</Trans>) action={
.otherwise(String)} selectedStat && isCurrentPeriod ? (
</span> <OrganisationUsageResetButton organisationId={organisationId} counter={row.counter} />
</div> ) : undefined
}
/>
))}
{row.effectiveLimit && row.effectiveLimit > 0 ? <Progress className="h-2 w-full" value={percent} /> : null} <UsageStatCard
label={<Trans>Reports</Trans>}
{selectedStat && isCurrentPeriod && ( icon={MailOpenIcon}
<div className="flex w-full justify-end pt-1"> used={selectedStat?.emailReports ?? 0}
<OrganisationUsageResetButton organisationId={organisationId} counter={row.counter} /> limit={null}
</div> countOnly
)} footnote={<Trans>Sent this period</Trans>}
</div> />
);
})}
<div className="space-y-1">
<div className="flex items-center justify-between text-sm">
<span>
<Trans>Reports</Trans>
</span>
<span className="text-muted-foreground">{selectedStat?.emailReports ?? 0}</span>
</div> </div>
</div> </div>
</div> </div>
@@ -2,6 +2,7 @@ import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
import { useToast } from '@documenso/ui/primitives/use-toast'; import { useToast } from '@documenso/ui/primitives/use-toast';
import { Trans, useLingui } from '@lingui/react/macro'; import { Trans, useLingui } from '@lingui/react/macro';
import { RotateCcwIcon } from 'lucide-react';
import { useRevalidator } from 'react-router'; import { useRevalidator } from 'react-router';
type OrganisationUsageResetButtonProps = { type OrganisationUsageResetButtonProps = {
@@ -32,6 +33,7 @@ export const OrganisationUsageResetButton = ({ organisationId, counter }: Organi
loading={isPending} loading={isPending}
onClick={() => reset({ organisationId, counter })} onClick={() => reset({ organisationId, counter })}
> >
<RotateCcwIcon className="mr-2 h-3.5 w-3.5" />
<Trans>Reset</Trans> <Trans>Reset</Trans>
</Button> </Button>
); );
@@ -1,7 +1,9 @@
import { RATE_LIMIT_WINDOW_REGEX } from '@documenso/lib/types/subscription';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
import { Input } from '@documenso/ui/primitives/input'; 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 { PlusIcon, Trash2Icon } from 'lucide-react';
import { useState } from 'react';
type RateLimitEntryValue = { window: string; max: number }; type RateLimitEntryValue = { window: string; max: number };
@@ -11,50 +13,153 @@ type RateLimitArrayInputProps = {
disabled?: boolean; 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) => { export const RateLimitArrayInput = ({ value, onChange, disabled }: RateLimitArrayInputProps) => {
const entries = value ?? []; const { t } = useLingui();
const [draftEntry, setDraftEntry] = useState<RateLimitEntryValue | null>(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<RateLimitEntryValue>) => { const updateEntry = (index: number, patch: Partial<RateLimitEntryValue>) => {
const next = entries.map((entry, i) => (i === index ? { ...entry, ...patch } : entry)); if (index >= value.length) {
onChange(next); 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) => { 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 = () => { 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 ( return (
<div className="space-y-2"> <div className="space-y-2">
{entries.map((entry, index) => ( <div className="flex items-center gap-2 text-muted-foreground text-xs">
<div key={index} className="flex items-center gap-2"> <span className="w-20 shrink-0">
<Input <Trans>Window</Trans>
className="w-24" </span>
placeholder="5m" <span className="flex-1">
value={entry.window} <Trans>Max requests</Trans>
disabled={disabled} </span>
onChange={(e) => updateEntry(index, { window: e.target.value })} <span className="w-9 shrink-0" aria-hidden="true" />
/> </div>
<Input
className="w-32"
type="number"
min={1}
value={entry.max}
disabled={disabled}
onChange={(e) => updateEntry(index, { max: parseInt(e.target.value, 10) || 0 })}
/>
<Button type="button" variant="ghost" size="sm" disabled={disabled} onClick={() => removeEntry(index)}>
<Trash2Icon className="h-4 w-4" />
</Button>
</div>
))}
<Button type="button" variant="secondary" size="sm" disabled={disabled} onClick={addEntry}> {entries.map((entry, index) => {
const windowError = getWindowError(entry, index);
const maxError = getMaxError(entry);
return (
<div key={index} className="space-y-1">
<div className="flex items-center gap-2">
<Input
className="w-20 shrink-0"
placeholder="5m"
value={entry.window}
disabled={disabled}
aria-invalid={Boolean(windowError)}
onChange={(e) => updateEntry(index, { window: e.target.value })}
/>
<Input
className="flex-1"
type="number"
min={1}
placeholder="100"
value={entry.max || ''}
disabled={disabled}
aria-invalid={Boolean(maxError)}
onChange={(e) => updateEntry(index, { max: parseInt(e.target.value, 10) || 0 })}
/>
<Button
type="button"
variant="ghost"
size="sm"
className="h-9 w-9 shrink-0 p-0 text-muted-foreground hover:text-foreground"
disabled={disabled}
aria-label={t`Remove rate limit`}
onClick={() => removeEntry(index)}
>
<Trash2Icon className="h-4 w-4" />
</Button>
</div>
{windowError ? <p className="text-destructive text-xs">{windowError}</p> : null}
{maxError ? <p className="text-destructive text-xs">{maxError}</p> : null}
</div>
);
})}
<Button
type="button"
variant="outline"
size="sm"
className="w-full border-dashed"
disabled={isAddDisabled}
onClick={addEntry}
>
<PlusIcon className="mr-2 h-4 w-4" /> <PlusIcon className="mr-2 h-4 w-4" />
<Trans>Add rate limit</Trans> <Trans>Add rate limit window</Trans>
</Button> </Button>
</div> </div>
); );
@@ -1,3 +1,4 @@
import { ZNameSchema } from '@documenso/lib/types/name';
import { trpc } from '@documenso/trpc/react'; import { trpc } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils'; import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
@@ -29,7 +30,7 @@ export type SettingsSecurityPasskeyTableActionsProps = {
}; };
const ZUpdatePasskeySchema = z.object({ const ZUpdatePasskeySchema = z.object({
name: z.string(), name: ZNameSchema,
}); });
type TUpdatePasskeySchema = z.infer<typeof ZUpdatePasskeySchema>; type TUpdatePasskeySchema = z.infer<typeof ZUpdatePasskeySchema>;
@@ -8,6 +8,7 @@ import { getHighestOrganisationRoleInGroup } from '@documenso/lib/utils/organisa
import { trpc } from '@documenso/trpc/react'; import { trpc } from '@documenso/trpc/react';
import type { TGetAdminOrganisationResponse } from '@documenso/trpc/server/admin-router/get-admin-organisation.types'; 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 { 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 { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@documenso/ui/primitives/accordion';
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert'; import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
import { Badge } from '@documenso/ui/primitives/badge'; 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 { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro'; import { msg } from '@lingui/core/macro';
import { Trans, useLingui } from '@lingui/react/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 { ExternalLinkIcon, InfoIcon, Loader } from 'lucide-react';
import { useMemo } from 'react'; import { useMemo } from 'react';
import { useForm } from 'react-hook-form'; 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 { AdminOrganisationMemberDeleteDialog } from '~/components/dialogs/admin-organisation-member-delete-dialog';
import { AdminOrganisationMemberUpdateDialog } from '~/components/dialogs/admin-organisation-member-update-dialog'; import { AdminOrganisationMemberUpdateDialog } from '~/components/dialogs/admin-organisation-member-update-dialog';
import { AdminOrganisationSyncSubscriptionDialog } from '~/components/dialogs/admin-organisation-sync-subscription-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 { AdminGlobalSettingsSection } from '~/components/general/admin-global-settings-section';
import { ClaimLimitFields } from '~/components/general/claim-limit-fields'; import { ClaimLimitFields } from '~/components/general/claim-limit-fields';
import { GenericErrorLayout } from '~/components/general/generic-error-layout'; import { GenericErrorLayout } from '~/components/general/generic-error-layout';
@@ -268,54 +268,32 @@ export default function OrganisationGroupSettingsPage({ params, loaderData }: Ro
<GenericOrganisationAdminForm organisation={organisation} /> <GenericOrganisationAdminForm organisation={organisation} />
<div className="mt-6 rounded-lg border p-4"> <SettingsHeader
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"> title={t`Organisation usage`}
<div> subtitle={t`Current usage against organisation limits.`}
<p className="font-medium text-sm"> className="mt-6"
<Trans>Organisation usage</Trans> hideDivider
</p> />
<p className="mt-1 text-muted-foreground text-sm">
<Trans>Current usage against organisation limits.</Trans>
</p>
</div>
</div>
<div className="mt-4 grid grid-cols-1 gap-3 text-sm sm:grid-cols-2"> <OrganisationUsagePanel
<DetailsCard label={<Trans>Members</Trans>}> organisationId={organisation.id}
<DetailsValue> monthlyStats={organisation.monthlyStats}
{organisation.members.length} /{' '} organisationClaim={organisation.organisationClaim}
{organisation.organisationClaim.memberCount === 0 capacityUsage={{
? t`Unlimited` members: organisation.members.length,
: organisation.organisationClaim.memberCount} teams: organisation.teams.length,
</DetailsValue> }}
</DetailsCard> />
<DetailsCard label={<Trans>Teams</Trans>}>
<DetailsValue>
{organisation.teams.length} /{' '}
{organisation.organisationClaim.teamCount === 0 ? t`Unlimited` : organisation.organisationClaim.teamCount}
</DetailsValue>
</DetailsCard>
</div>
<div className="mt-4">
<OrganisationUsagePanel
organisationId={organisation.id}
monthlyStats={organisation.monthlyStats}
organisationClaim={organisation.organisationClaim}
/>
</div>
</div>
<div className="mt-6 rounded-lg border p-4"> <div className="mt-6 rounded-lg border p-4">
<Accordion type="single" collapsible> <Accordion type="single" collapsible>
<AccordionItem value="global-settings" className="border-b-0"> <AccordionItem value="global-settings" className="border-b-0">
<AccordionTrigger className="py-0"> <AccordionTrigger className="py-0">
<div className="text-left"> <div className="text-left">
<p className="font-medium text-sm"> <p className="font-semibold text-base">
<Trans>Global Settings</Trans> <Trans>Global Settings</Trans>
</p> </p>
<p className="mt-1 font-normal text-muted-foreground text-sm"> <p className="mt-1 text-muted-foreground text-sm">
<Trans>Default settings applied to this organisation.</Trans> <Trans>Default settings applied to this organisation.</Trans>
</p> </p>
</div> </div>
@@ -335,7 +313,15 @@ export default function OrganisationGroupSettingsPage({ params, loaderData }: Ro
className="mt-16" className="mt-16"
/> />
<Alert className="my-6 flex flex-col justify-between p-6 sm:flex-row sm:items-center" variant="neutral"> <Alert
className={cn(
'my-6 flex flex-col justify-between p-6 sm:flex-row sm:items-center',
organisation.subscription?.status === SubscriptionStatus.ACTIVE &&
'border border-green-600/20 bg-green-50 dark:border-green-500/20 dark:bg-green-500/10',
organisation.subscription?.status === SubscriptionStatus.INACTIVE && 'opacity-60',
)}
variant="neutral"
>
<div className="mb-4 sm:mb-0"> <div className="mb-4 sm:mb-0">
<AlertTitle> <AlertTitle>
<Trans>Subscription</Trans> <Trans>Subscription</Trans>
@@ -343,7 +329,12 @@ export default function OrganisationGroupSettingsPage({ params, loaderData }: Ro
<AlertDescription className="mr-2"> <AlertDescription className="mr-2">
{organisation.subscription ? ( {organisation.subscription ? (
<span>{i18n._(SUBSCRIPTION_STATUS_MAP[organisation.subscription.status])} subscription found</span> <span className="flex items-center gap-2">
{organisation.subscription.status === SubscriptionStatus.ACTIVE && (
<span className="h-2 w-2 shrink-0 rounded-full bg-green-600 dark:bg-green-400" aria-hidden="true" />
)}
<span>{i18n._(SUBSCRIPTION_STATUS_MAP[organisation.subscription.status])} subscription found</span>
</span>
) : ( ) : (
<span> <span>
<Trans>No subscription found</Trans> <Trans>No subscription found</Trans>
@@ -356,6 +347,7 @@ export default function OrganisationGroupSettingsPage({ params, loaderData }: Ro
<div> <div>
<Button <Button
variant="outline" variant="outline"
className="bg-background"
loading={isCreatingStripeCustomer} loading={isCreatingStripeCustomer}
onClick={async () => createStripeCustomer({ organisationId })} onClick={async () => createStripeCustomer({ organisationId })}
> >
@@ -366,7 +358,7 @@ export default function OrganisationGroupSettingsPage({ params, loaderData }: Ro
{organisation.customerId && !organisation.subscription && ( {organisation.customerId && !organisation.subscription && (
<div> <div>
<Button variant="outline" asChild> <Button variant="outline" className="bg-background" asChild>
<Link <Link
target="_blank" target="_blank"
to={`https://dashboard.stripe.com/customers/${organisation.customerId}?create=subscription&subscription_default_customer=${organisation.customerId}`} to={`https://dashboard.stripe.com/customers/${organisation.customerId}?create=subscription&subscription_default_customer=${organisation.customerId}`}
@@ -383,13 +375,13 @@ export default function OrganisationGroupSettingsPage({ params, loaderData }: Ro
<AdminOrganisationSyncSubscriptionDialog <AdminOrganisationSyncSubscriptionDialog
organisationId={organisationId} organisationId={organisationId}
trigger={ trigger={
<Button variant="outline"> <Button variant="outline" className="bg-background">
<Trans>Sync Stripe subscription</Trans> <Trans>Sync Stripe subscription</Trans>
</Button> </Button>
} }
/> />
<Button variant="outline" asChild> <Button variant="outline" className="bg-background" asChild>
<Link <Link
target="_blank" target="_blank"
to={`https://dashboard.stripe.com/subscriptions/${organisation.subscription.planId}`} to={`https://dashboard.stripe.com/subscriptions/${organisation.subscription.planId}`}
@@ -406,21 +398,27 @@ export default function OrganisationGroupSettingsPage({ params, loaderData }: Ro
<div className="mt-16 space-y-10"> <div className="mt-16 space-y-10">
<div> <div>
<label className="font-medium text-sm leading-none"> <h3 className="font-semibold text-base">
<Trans>Organisation Members</Trans> <Trans>Organisation Members</Trans>
</label> </h3>
<p className="mt-1 text-muted-foreground text-sm">
<Trans>People with access to this organisation.</Trans>
</p>
<div className="my-2"> <div className="mt-3">
<DataTable columns={organisationMembersColumns} data={organisation.members} /> <DataTable columns={organisationMembersColumns} data={organisation.members} />
</div> </div>
</div> </div>
<div> <div>
<label className="font-medium text-sm leading-none"> <h3 className="font-semibold text-base">
<Trans>Organisation Teams</Trans> <Trans>Organisation Teams</Trans>
</label> </h3>
<p className="mt-1 text-muted-foreground text-sm">
<Trans>Teams that belong to this organisation.</Trans>
</p>
<div className="my-2"> <div className="mt-3">
<DataTable columns={teamsColumns} data={organisation.teams} /> <DataTable columns={teamsColumns} data={organisation.teams} />
</div> </div>
</div> </div>
@@ -648,7 +646,7 @@ const OrganisationAdminForm = ({ organisation, licenseFlags }: OrganisationAdmin
<FormLabel className="flex items-center"> <FormLabel className="flex items-center">
<Trans>Inherited subscription claim</Trans> <Trans>Inherited subscription claim</Trans>
<Tooltip> <Tooltip>
<TooltipTrigger> <TooltipTrigger type="button">
<InfoIcon className="mx-2 h-4 w-4" /> <InfoIcon className="mx-2 h-4 w-4" />
</TooltipTrigger> </TooltipTrigger>
@@ -681,10 +679,15 @@ const OrganisationAdminForm = ({ organisation, licenseFlags }: OrganisationAdmin
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
</FormLabel> </FormLabel>
<FormControl> <div className="rounded-lg border bg-muted/40 px-3 py-2.5 text-sm">
<Input disabled {...field} /> {field.value ? (
</FormControl> <span className="font-mono text-foreground">{field.value}</span>
<FormMessage /> ) : (
<span className="text-muted-foreground">
<Trans>No inherited claim</Trans>
</span>
)}
</div>
</FormItem> </FormItem>
)} )}
/> />
@@ -715,108 +718,113 @@ const OrganisationAdminForm = ({ organisation, licenseFlags }: OrganisationAdmin
)} )}
/> />
<FormField <div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
control={form.control} <FormField
name="claims.teamCount" control={form.control}
render={({ field }) => ( name="claims.teamCount"
<FormItem> render={({ field }) => (
<FormLabel> <FormItem>
<Trans>Team Count</Trans> <FormLabel>
</FormLabel> <Trans>Team Count</Trans>
<FormControl> </FormLabel>
<Input <FormControl>
type="number" <Input
min={0} type="number"
{...field} min={0}
onChange={(e) => field.onChange(parseInt(e.target.value, 10) || 0)} {...field}
/> onChange={(e) => field.onChange(parseInt(e.target.value, 10) || 0)}
</FormControl> />
<FormDescription> </FormControl>
<Trans>Number of teams allowed. 0 = Unlimited</Trans> <FormDescription>
</FormDescription> <Trans>Number of teams allowed. 0 = Unlimited</Trans>
<FormMessage /> </FormDescription>
</FormItem> <FormMessage />
)} </FormItem>
/> )}
/>
<FormField <FormField
control={form.control} control={form.control}
name="claims.memberCount" name="claims.memberCount"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel> <FormLabel>
<Trans>Member Count</Trans> <Trans>Member Count</Trans>
</FormLabel> </FormLabel>
<FormControl> <FormControl>
<Input <Input
type="number" type="number"
min={0} min={0}
{...field} {...field}
onChange={(e) => field.onChange(parseInt(e.target.value, 10) || 0)} onChange={(e) => field.onChange(parseInt(e.target.value, 10) || 0)}
/> />
</FormControl> </FormControl>
<FormDescription> <FormDescription>
<Trans>Number of members allowed. 0 = Unlimited</Trans> <Trans>Number of members allowed. 0 = Unlimited</Trans>
</FormDescription> </FormDescription>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
)} )}
/> />
<FormField <FormField
control={form.control} control={form.control}
name="claims.envelopeItemCount" name="claims.envelopeItemCount"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel> <FormLabel>
<Trans>Envelope Item Count</Trans> <Trans>Envelope Item Count</Trans>
</FormLabel> </FormLabel>
<FormControl> <FormControl>
<Input <Input
type="number" type="number"
min={1} min={1}
{...field} {...field}
onChange={(e) => field.onChange(parseInt(e.target.value, 10) || 0)} onChange={(e) => field.onChange(parseInt(e.target.value, 10) || 0)}
/> />
</FormControl> </FormControl>
<FormDescription> <FormDescription>
<Trans>Maximum number of uploaded files per envelope allowed</Trans> <Trans>Maximum number of uploaded files per envelope allowed</Trans>
</FormDescription> </FormDescription>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
)} )}
/> />
<FormField <FormField
control={form.control} control={form.control}
name="claims.recipientCount" name="claims.recipientCount"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel> <FormLabel>
<Trans>Recipient Count</Trans> <Trans>Recipient Count</Trans>
</FormLabel> </FormLabel>
<FormControl> <FormControl>
<Input <Input
type="number" type="number"
min={0} min={0}
{...field} {...field}
onChange={(e) => field.onChange(parseInt(e.target.value, 10) || 0)} onChange={(e) => field.onChange(parseInt(e.target.value, 10) || 0)}
/> />
</FormControl> </FormControl>
<FormDescription> <FormDescription>
<Trans>Maximum number of recipients per document allowed. 0 = Unlimited</Trans> <Trans>Maximum number of recipients per document allowed. 0 = Unlimited</Trans>
</FormDescription> </FormDescription>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
)} )}
/> />
</div>
<div> <div>
<FormLabel> <h3 className="font-semibold text-base">
<Trans>Feature Flags</Trans> <Trans>Feature Flags</Trans>
</FormLabel> </h3>
<p className="mt-1 text-muted-foreground text-sm">
<Trans>Capabilities enabled for this organisation.</Trans>
</p>
<div className="mt-2 space-y-2 rounded-md border p-4"> <div className="mt-3 space-y-2 rounded-md border p-4">
{Object.values(SUBSCRIPTION_CLAIM_FEATURE_FLAGS).map(({ key, label, isEnterprise }) => { {Object.values(SUBSCRIPTION_CLAIM_FEATURE_FLAGS).map(({ key, label, isEnterprise }) => {
const isRestrictedFeature = isEnterprise && !licenseFlags?.[key as keyof TLicenseClaim]; // eslint-disable-line @typescript-eslint/consistent-type-assertions const isRestrictedFeature = isEnterprise && !licenseFlags?.[key as keyof TLicenseClaim]; // eslint-disable-line @typescript-eslint/consistent-type-assertions
@@ -287,7 +287,11 @@ export default function AdminTeamPage({ params }: Route.ComponentProps) {
</AccordionTrigger> </AccordionTrigger>
<AccordionContent> <AccordionContent>
<div className="mt-4"> <div className="mt-4">
<AdminGlobalSettingsSection settings={team.teamGlobalSettings} isTeam /> <AdminGlobalSettingsSection
settings={team.teamGlobalSettings}
inheritedSettings={team.organisation.organisationGlobalSettings}
isTeam
/>
</div> </div>
</AccordionContent> </AccordionContent>
</AccordionItem> </AccordionItem>
@@ -1,7 +1,6 @@
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation'; import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { useSession } from '@documenso/lib/client-only/providers/session'; import { useSession } from '@documenso/lib/client-only/providers/session';
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app'; import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
import { putFile } from '@documenso/lib/universal/upload/put-file';
import { canExecuteOrganisationAction, isPersonalLayout } from '@documenso/lib/utils/organisations'; import { canExecuteOrganisationAction, isPersonalLayout } from '@documenso/lib/utils/organisations';
import type { SanitizeBrandingCssWarning } from '@documenso/lib/utils/sanitize-branding-css'; import type { SanitizeBrandingCssWarning } from '@documenso/lib/utils/sanitize-branding-css';
import { trpc } from '@documenso/trpc/react'; import { trpc } from '@documenso/trpc/react';
@@ -49,26 +48,29 @@ export default function OrganisationSettingsBrandingPage() {
const { mutateAsync: updateOrganisationSettings } = trpc.organisation.settings.update.useMutation(); const { mutateAsync: updateOrganisationSettings } = trpc.organisation.settings.update.useMutation();
const { mutateAsync: updateOrganisationBrandingLogo } = trpc.organisation.settings.updateBrandingLogo.useMutation();
const onBrandingPreferencesFormSubmit = async (data: TBrandingPreferencesFormSchema) => { const onBrandingPreferencesFormSubmit = async (data: TBrandingPreferencesFormSchema) => {
try { try {
const { brandingEnabled, brandingLogo, brandingUrl, brandingCompanyDetails, brandingColors, brandingCss } = data; const { brandingEnabled, brandingLogo, brandingUrl, brandingCompanyDetails, brandingColors, brandingCss } = data;
let uploadedBrandingLogo: string | undefined; // Upload (or clear) the logo through the dedicated, server-validated route.
if (brandingLogo instanceof File || brandingLogo === null) {
const formData = new FormData();
if (brandingLogo) { formData.append('payload', JSON.stringify({ organisationId: organisation.id }));
uploadedBrandingLogo = JSON.stringify(await putFile(brandingLogo));
}
// Empty the branding logo if the user unsets it. if (brandingLogo instanceof File) {
if (brandingLogo === null) { formData.append('brandingLogo', brandingLogo);
uploadedBrandingLogo = ''; }
await updateOrganisationBrandingLogo(formData);
} }
const result = await updateOrganisationSettings({ const result = await updateOrganisationSettings({
organisationId: organisation.id, organisationId: organisation.id,
data: { data: {
brandingEnabled: brandingEnabled ?? undefined, brandingEnabled: brandingEnabled ?? undefined,
brandingLogo: uploadedBrandingLogo,
brandingUrl, brandingUrl,
brandingCompanyDetails, brandingCompanyDetails,
brandingColors, brandingColors,
@@ -3,6 +3,7 @@ import { ORGANISATION_MEMBER_ROLE_HIERARCHY } from '@documenso/lib/constants/org
import { EXTENDED_ORGANISATION_MEMBER_ROLE_MAP } from '@documenso/lib/constants/organisations-translations'; import { EXTENDED_ORGANISATION_MEMBER_ROLE_MAP } from '@documenso/lib/constants/organisations-translations';
import { TEAM_MEMBER_ROLE_MAP } from '@documenso/lib/constants/teams-translations'; import { TEAM_MEMBER_ROLE_MAP } from '@documenso/lib/constants/teams-translations';
import { AppError } from '@documenso/lib/errors/app-error'; import { AppError } from '@documenso/lib/errors/app-error';
import { ZNameSchema } from '@documenso/lib/types/name';
import { trpc } from '@documenso/trpc/react'; import { trpc } from '@documenso/trpc/react';
import type { TFindOrganisationGroupsResponse } from '@documenso/trpc/server/organisation-router/find-organisation-groups.types'; import type { TFindOrganisationGroupsResponse } from '@documenso/trpc/server/organisation-router/find-organisation-groups.types';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
@@ -28,7 +29,6 @@ import { useMemo, useState } from 'react';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { Link } from 'react-router'; import { Link } from 'react-router';
import { z } from 'zod'; import { z } from 'zod';
import { OrganisationGroupDeleteDialog } from '~/components/dialogs/organisation-group-delete-dialog'; import { OrganisationGroupDeleteDialog } from '~/components/dialogs/organisation-group-delete-dialog';
import { GenericErrorLayout } from '~/components/general/generic-error-layout'; import { GenericErrorLayout } from '~/components/general/generic-error-layout';
import { import {
@@ -36,7 +36,6 @@ import {
OrganisationMembersMultiSelectCombobox, OrganisationMembersMultiSelectCombobox,
} from '~/components/general/organisation-members-multiselect-combobox'; } from '~/components/general/organisation-members-multiselect-combobox';
import { SettingsHeader } from '~/components/general/settings-header'; import { SettingsHeader } from '~/components/general/settings-header';
import type { Route } from './+types/o.$orgUrl.settings.groups.$id'; import type { Route } from './+types/o.$orgUrl.settings.groups.$id';
export default function OrganisationGroupSettingsPage({ params }: Route.ComponentProps) { export default function OrganisationGroupSettingsPage({ params }: Route.ComponentProps) {
@@ -113,7 +112,7 @@ export default function OrganisationGroupSettingsPage({ params }: Route.Componen
} }
const ZUpdateOrganisationGroupFormSchema = z.object({ const ZUpdateOrganisationGroupFormSchema = z.object({
name: z.string().min(1, msg`Name is required`.id), name: ZNameSchema,
organisationRole: z.nativeEnum(OrganisationMemberRole), organisationRole: z.nativeEnum(OrganisationMemberRole),
memberIds: z.array(z.string()), memberIds: z.array(z.string()),
}); });
@@ -1,6 +1,5 @@
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation'; import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app'; import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
import { putFile } from '@documenso/lib/universal/upload/put-file';
import { canExecuteOrganisationAction } from '@documenso/lib/utils/organisations'; import { canExecuteOrganisationAction } from '@documenso/lib/utils/organisations';
import type { SanitizeBrandingCssWarning } from '@documenso/lib/utils/sanitize-branding-css'; import type { SanitizeBrandingCssWarning } from '@documenso/lib/utils/sanitize-branding-css';
import { trpc } from '@documenso/trpc/react'; import { trpc } from '@documenso/trpc/react';
@@ -38,6 +37,7 @@ export default function TeamsSettingsPage() {
}); });
const { mutateAsync: updateTeamSettings } = trpc.team.settings.update.useMutation(); const { mutateAsync: updateTeamSettings } = trpc.team.settings.update.useMutation();
const { mutateAsync: updateTeamBrandingLogo } = trpc.team.settings.updateBrandingLogo.useMutation();
const canConfigureBranding = organisation.organisationClaim.flags.allowCustomBranding || !IS_BILLING_ENABLED(); const canConfigureBranding = organisation.organisationClaim.flags.allowCustomBranding || !IS_BILLING_ENABLED();
@@ -48,22 +48,23 @@ export default function TeamsSettingsPage() {
try { try {
const { brandingEnabled, brandingLogo, brandingUrl, brandingCompanyDetails, brandingColors, brandingCss } = data; const { brandingEnabled, brandingLogo, brandingUrl, brandingCompanyDetails, brandingColors, brandingCss } = data;
let uploadedBrandingLogo: string | undefined; // Upload (or clear) the logo through the dedicated, server-validated route.
if (brandingLogo instanceof File || brandingLogo === null) {
const formData = new FormData();
if (brandingLogo) { formData.append('payload', JSON.stringify({ teamId: team.id }));
uploadedBrandingLogo = JSON.stringify(await putFile(brandingLogo));
}
// Empty the branding logo if the user unsets it. if (brandingLogo instanceof File) {
if (brandingLogo === null) { formData.append('brandingLogo', brandingLogo);
uploadedBrandingLogo = ''; }
await updateTeamBrandingLogo(formData);
} }
const result = await updateTeamSettings({ const result = await updateTeamSettings({
teamId: team.id, teamId: team.id,
data: { data: {
brandingEnabled, brandingEnabled,
brandingLogo: uploadedBrandingLogo,
brandingUrl: brandingUrl || null, brandingUrl: brandingUrl || null,
brandingCompanyDetails: brandingCompanyDetails || null, brandingCompanyDetails: brandingCompanyDetails || null,
brandingColors, brandingColors,
+4 -4
View File
@@ -36,8 +36,8 @@
"@lingui/react": "^5.6.0", "@lingui/react": "^5.6.0",
"@oslojs/crypto": "^1.0.1", "@oslojs/crypto": "^1.0.1",
"@oslojs/encoding": "^1.1.0", "@oslojs/encoding": "^1.1.0",
"@react-router/node": "^7.12.0", "@react-router/node": "^7.18.1",
"@react-router/serve": "^7.12.0", "@react-router/serve": "^7.18.1",
"@simplewebauthn/browser": "^13.2.2", "@simplewebauthn/browser": "^13.2.2",
"@simplewebauthn/server": "^13.2.2", "@simplewebauthn/server": "^13.2.2",
"@tanstack/react-query": "5.90.10", "@tanstack/react-query": "5.90.10",
@@ -81,8 +81,8 @@
"@babel/preset-typescript": "^7.28.5", "@babel/preset-typescript": "^7.28.5",
"@lingui/babel-plugin-lingui-macro": "^5.6.0", "@lingui/babel-plugin-lingui-macro": "^5.6.0",
"@lingui/vite-plugin": "^5.6.0", "@lingui/vite-plugin": "^5.6.0",
"@react-router/dev": "^7.12.0", "@react-router/dev": "^7.18.1",
"@react-router/remix-routes-option-adapter": "^7.12.0", "@react-router/remix-routes-option-adapter": "^7.18.1",
"@rollup/plugin-babel": "^6.1.0", "@rollup/plugin-babel": "^6.1.0",
"@rollup/plugin-commonjs": "^28.0.9", "@rollup/plugin-commonjs": "^28.0.9",
"@rollup/plugin-json": "^6.1.0", "@rollup/plugin-json": "^6.1.0",
+1 -28
View File
@@ -1,9 +1,8 @@
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session'; import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app'; import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; import { AppError } from '@documenso/lib/errors/app-error';
import { verifyEmbeddingPresignToken } from '@documenso/lib/server-only/embedding-presign/verify-embedding-presign-token'; import { verifyEmbeddingPresignToken } from '@documenso/lib/server-only/embedding-presign/verify-embedding-presign-token';
import { putNormalizedPdfFileServerSide } from '@documenso/lib/universal/upload/put-file.server'; import { putNormalizedPdfFileServerSide } from '@documenso/lib/universal/upload/put-file.server';
import { getPresignPostUrl } from '@documenso/lib/universal/upload/server-actions';
import { prisma } from '@documenso/prisma'; import { prisma } from '@documenso/prisma';
import { sValidator } from '@hono/standard-validator'; import { sValidator } from '@hono/standard-validator';
import type { Prisma } from '@prisma/client'; import type { Prisma } from '@prisma/client';
@@ -12,14 +11,11 @@ import { Hono } from 'hono';
import type { HonoEnv } from '../../router'; import type { HonoEnv } from '../../router';
import { checkEnvelopeFileAccess, handleEnvelopeItemFileRequest, resolveFileUploadUserId } from './files.helpers'; import { checkEnvelopeFileAccess, handleEnvelopeItemFileRequest, resolveFileUploadUserId } from './files.helpers';
import { import {
isAllowedUploadContentType,
type TGetPresignedPostUrlResponse,
ZGetEnvelopeItemFileDownloadRequestParamsSchema, ZGetEnvelopeItemFileDownloadRequestParamsSchema,
ZGetEnvelopeItemFileRequestParamsSchema, ZGetEnvelopeItemFileRequestParamsSchema,
ZGetEnvelopeItemFileRequestQuerySchema, ZGetEnvelopeItemFileRequestQuerySchema,
ZGetEnvelopeItemFileTokenDownloadRequestParamsSchema, ZGetEnvelopeItemFileTokenDownloadRequestParamsSchema,
ZGetEnvelopeItemFileTokenRequestParamsSchema, ZGetEnvelopeItemFileTokenRequestParamsSchema,
ZGetPresignedPostUrlRequestSchema,
ZUploadPdfRequestSchema, ZUploadPdfRequestSchema,
} from './files.types'; } from './files.types';
import getEnvelopeItemPdfRoute from './routes/get-envelope-item-pdf'; import getEnvelopeItemPdfRoute from './routes/get-envelope-item-pdf';
@@ -61,29 +57,6 @@ export const filesRoute = new Hono<HonoEnv>()
return c.json({ error: 'Upload failed' }, 500); return c.json({ error: 'Upload failed' }, 500);
} }
}) })
.post('/presigned-post-url', sValidator('json', ZGetPresignedPostUrlRequestSchema), async (c) => {
const userId = await resolveFileUploadUserId(c);
if (!userId) {
return c.json({ error: 'Unauthorized' }, 401);
}
const { fileName, contentType } = c.req.valid('json');
if (!isAllowedUploadContentType(contentType)) {
return c.json({ error: 'Unsupported content type' }, 400);
}
try {
const { key, url } = await getPresignPostUrl(fileName, contentType, userId);
return c.json({ key, url } satisfies TGetPresignedPostUrlResponse);
} catch (err) {
console.error(err);
throw new AppError(AppErrorCode.UNKNOWN_ERROR);
}
})
.get( .get(
'/envelope/:envelopeId/envelopeItem/:envelopeItemId', '/envelope/:envelopeId/envelopeItem/:envelopeItemId',
sValidator('param', ZGetEnvelopeItemFileRequestParamsSchema), sValidator('param', ZGetEnvelopeItemFileRequestParamsSchema),
@@ -13,27 +13,6 @@ export const ZUploadPdfResponseSchema = DocumentDataSchema.pick({
export type TUploadPdfRequest = z.infer<typeof ZUploadPdfRequestSchema>; export type TUploadPdfRequest = z.infer<typeof ZUploadPdfRequestSchema>;
export type TUploadPdfResponse = z.infer<typeof ZUploadPdfResponseSchema>; export type TUploadPdfResponse = z.infer<typeof ZUploadPdfResponseSchema>;
export const ALLOWED_UPLOAD_CONTENT_TYPES = ['application/pdf', 'image/jpeg', 'image/png', 'image/webp'] as const;
export const isAllowedUploadContentType = (contentType: string): boolean => {
const normalizedContentType = contentType.split(';').at(0)?.trim().toLowerCase();
return ALLOWED_UPLOAD_CONTENT_TYPES.some((allowed) => allowed === normalizedContentType);
};
export const ZGetPresignedPostUrlRequestSchema = z.object({
fileName: z.string().min(1),
contentType: z.string().min(1),
});
export const ZGetPresignedPostUrlResponseSchema = z.object({
key: z.string().min(1),
url: z.string().min(1),
});
export type TGetPresignedPostUrlRequest = z.infer<typeof ZGetPresignedPostUrlRequestSchema>;
export type TGetPresignedPostUrlResponse = z.infer<typeof ZGetPresignedPostUrlResponseSchema>;
export const ZGetEnvelopeItemFileRequestParamsSchema = z.object({ export const ZGetEnvelopeItemFileRequestParamsSchema = z.object({
envelopeId: z.string().min(1), envelopeId: z.string().min(1),
envelopeItemId: z.string().min(1), envelopeItemId: z.string().min(1),
-1
View File
@@ -105,7 +105,6 @@ app.route('/api/auth', auth);
// Files route. // Files route.
app.use('/api/files/upload-pdf', fileRateLimitMiddleware); app.use('/api/files/upload-pdf', fileRateLimitMiddleware);
app.use('/api/files/presigned-post-url', fileRateLimitMiddleware);
app.route('/api/files', filesRoute); app.route('/api/files', filesRoute);
// AI route. // AI route.
+2048 -1264
View File
File diff suppressed because it is too large Load Diff
@@ -44,46 +44,6 @@ test.describe('File upload endpoint authorization', () => {
expect(res.status()).toBe(401); expect(res.status()).toBe(401);
}); });
test('rejects an unauthenticated presigned-post-url request', async ({ request }) => {
const res = await request.post(`${WEBAPP_BASE_URL}/api/files/presigned-post-url`, {
headers: { 'Content-Type': 'application/json' },
data: { fileName: 'test.pdf', contentType: 'application/pdf' },
});
expect(res.ok()).toBeFalsy();
expect(res.status()).toBe(401);
});
test('rejects a presigned-post-url request with an invalid presign token', async ({ request }) => {
const res = await request.post(`${WEBAPP_BASE_URL}/api/files/presigned-post-url`, {
headers: {
'Content-Type': 'application/json',
Authorization: 'Bearer not-a-real-token',
},
data: { fileName: 'test.pdf', contentType: 'application/pdf' },
});
expect(res.ok()).toBeFalsy();
expect(res.status()).toBe(401);
});
test('rejects a presigned-post-url request with a disallowed content type', async ({ request }) => {
const { user, team } = await seedUser();
const presignToken = await createPresignTokenForUser(user.id, team.id);
const res = await request.post(`${WEBAPP_BASE_URL}/api/files/presigned-post-url`, {
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${presignToken}`,
},
data: { fileName: 'malware.exe', contentType: 'application/x-msdownload' },
});
// Authenticated, but the content type is not on the allow-list.
expect(res.ok()).toBeFalsy();
expect(res.status()).toBe(400);
});
test('allows an upload-pdf request authorized by a valid presign token', async ({ request }) => { test('allows an upload-pdf request authorized by a valid presign token', async ({ request }) => {
const { user, team } = await seedUser(); const { user, team } = await seedUser();
const presignToken = await createPresignTokenForUser(user.id, team.id); const presignToken = await createPresignTokenForUser(user.id, team.id);
@@ -0,0 +1,37 @@
import { optimiseBrandingLogo } from '@documenso/lib/utils/images/logo';
import { expect, test } from '@playwright/test';
import sharp from 'sharp';
const makePng = async (width = 1200, height = 1200) =>
sharp({
create: { width, height, channels: 3, background: { r: 10, g: 20, b: 30 } },
})
.png()
.toBuffer();
test.describe('optimiseBrandingLogo', () => {
test('re-encodes a valid image to a PNG buffer', async () => {
const input = await makePng();
const output = await optimiseBrandingLogo(input);
const metadata = await sharp(output).metadata();
expect(metadata.format).toBe('png');
});
test('bounds the image to a maximum of 512px on its largest side', async () => {
const input = await makePng(2000, 1000);
const output = await optimiseBrandingLogo(input);
const metadata = await sharp(output).metadata();
expect(metadata.width).toBeLessThanOrEqual(512);
expect(metadata.height).toBeLessThanOrEqual(512);
});
test('rejects input that is not a valid image', async () => {
await expect(optimiseBrandingLogo(Buffer.from('this is not an image'))).rejects.toThrow();
});
});
@@ -0,0 +1,225 @@
import fs from 'node:fs';
import path from 'node:path';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { prisma } from '@documenso/prisma';
import { seedUser } from '@documenso/prisma/seed/users';
import { expect, type Page, test } from '@playwright/test';
import { apiSignin } from './fixtures/authentication';
test.describe.configure({ mode: 'parallel' });
const LOGO_PATH = path.join(__dirname, '../../assets/logo.png');
type MultipartFile = { name: string; mimeType: string; buffer: Buffer };
const enableBrandingAndUpload = async (page: Page) => {
// Enable custom branding so the file input is no longer disabled.
await page.getByTestId('enable-branding').click();
await page.getByRole('option', { name: 'Yes' }).click();
// Upload the logo file through the real multipart route.
await page.locator('input[type="file"]').setInputFiles(LOGO_PATH);
await page.getByRole('button', { name: 'Save changes' }).first().click();
await expect(page.getByText('Your branding preferences have been updated').first()).toBeVisible();
};
/**
* POST a logo straight to the dedicated multipart tRPC route using the
* authenticated browser cookies. This bypasses the client-side form validation,
* which is the only way to exercise the server-side image validation /
* sanitisation (`zfdBrandingImageFile` + `optimiseBrandingLogo`) and the entitlement gate.
*/
const postOrganisationBrandingLogo = async (page: Page, organisationId: string, file: MultipartFile | null) => {
const multipart: Record<string, string | MultipartFile> = {
payload: JSON.stringify({ organisationId }),
};
if (file) {
multipart.brandingLogo = file;
}
return await page
.context()
.request.post(`${NEXT_PUBLIC_WEBAPP_URL()}/api/trpc/organisation.settings.updateBrandingLogo`, { multipart });
};
/**
* Grant the organisation the custom-branding entitlement. The positive branding
* flows require it whenever billing is enabled; with billing disabled the gate is
* bypassed, so this keeps these tests valid in both modes.
*/
const grantCustomBranding = async (organisationClaimId: string) => {
await prisma.organisationClaim.update({
where: { id: organisationClaimId },
data: { flags: { allowLegacyEnvelopes: true, allowCustomBranding: true } },
});
};
test('[BRANDING_LOGO]: uploads an organisation branding logo via the dedicated route', async ({ page }) => {
const { user, organisation } = await seedUser({ isPersonalOrganisation: false });
await grantCustomBranding(organisation.organisationClaim.id);
await apiSignin({
page,
email: user.email,
redirectPath: `/o/${organisation.url}/settings/branding`,
});
await enableBrandingAndUpload(page);
const settings = await prisma.organisationGlobalSettings.findUniqueOrThrow({
where: { id: organisation.organisationGlobalSettingsId },
});
expect(settings.brandingLogo).toBeTruthy();
const parsed = JSON.parse(settings.brandingLogo);
expect(parsed).toHaveProperty('type');
expect(parsed).toHaveProperty('data');
});
test('[BRANDING_LOGO]: uploads a team branding logo via the dedicated route', async ({ page }) => {
const { user, team, organisation } = await seedUser({ isPersonalOrganisation: false });
await grantCustomBranding(organisation.organisationClaim.id);
await apiSignin({
page,
email: user.email,
redirectPath: `/t/${team.url}/settings/branding`,
});
await enableBrandingAndUpload(page);
// TeamGlobalSettings has no `teamId` column (the FK lives on Team), so read it
// through the team relation.
const teamWithSettings = await prisma.team.findUniqueOrThrow({
where: { id: team.id },
include: { teamGlobalSettings: true },
});
expect(teamWithSettings.teamGlobalSettings?.brandingLogo).toBeTruthy();
const parsed = JSON.parse(teamWithSettings.teamGlobalSettings?.brandingLogo ?? '');
expect(parsed).toHaveProperty('type');
expect(parsed).toHaveProperty('data');
});
test('[BRANDING_LOGO]: clears the organisation branding logo when the user removes it', async ({ page }) => {
const { user, organisation } = await seedUser({ isPersonalOrganisation: false });
await grantCustomBranding(organisation.organisationClaim.id);
await apiSignin({
page,
email: user.email,
redirectPath: `/o/${organisation.url}/settings/branding`,
});
await enableBrandingAndUpload(page);
// Confirm the logo was stored before we clear it.
const settings = await prisma.organisationGlobalSettings.findUniqueOrThrow({
where: { id: organisation.organisationGlobalSettingsId },
});
expect(settings.brandingLogo).toBeTruthy();
// Remove the logo and save again.
await page.getByRole('button', { name: 'Remove' }).click();
await page.getByRole('button', { name: 'Save changes' }).first().click();
// Clearing the logo persists an empty string via the dedicated route.
await expect
.poll(async () => {
const updated = await prisma.organisationGlobalSettings.findUniqueOrThrow({
where: { id: organisation.organisationGlobalSettingsId },
});
return updated.brandingLogo;
})
.toBe('');
});
test('[BRANDING_LOGO]: validates and sanitises the logo on the server', async ({ page }) => {
const { user, organisation } = await seedUser({ isPersonalOrganisation: false });
await grantCustomBranding(organisation.organisationClaim.id);
await apiSignin({
page,
email: user.email,
redirectPath: `/o/${organisation.url}/settings/branding`,
});
// Positive control: a genuine PNG is accepted and stored. This also proves the
// direct multipart request shape matches what the route expects.
const validResponse = await postOrganisationBrandingLogo(page, organisation.id, {
name: 'logo.png',
mimeType: 'image/png',
buffer: fs.readFileSync(LOGO_PATH),
});
expect(validResponse.ok()).toBeTruthy();
const afterValid = await prisma.organisationGlobalSettings.findUniqueOrThrow({
where: { id: organisation.organisationGlobalSettingsId },
});
expect(afterValid.brandingLogo).toBeTruthy();
// Bytes that pass the MIME/size allowlist but are not a real image must be
// rejected by the server (the `sharp` re-encode) without changing stored state.
const invalidResponse = await postOrganisationBrandingLogo(page, organisation.id, {
name: 'fake.png',
mimeType: 'image/png',
buffer: Buffer.from('this is definitely not a valid png'),
});
expect(invalidResponse.ok()).toBeFalsy();
expect(invalidResponse.status()).toBeGreaterThanOrEqual(400);
expect(invalidResponse.status()).toBeLessThan(500);
const afterInvalid = await prisma.organisationGlobalSettings.findUniqueOrThrow({
where: { id: organisation.organisationGlobalSettingsId },
});
// The previously stored, valid logo is left untouched by the rejected upload.
expect(afterInvalid.brandingLogo).toBe(afterValid.brandingLogo);
});
test('[BRANDING_LOGO]: rejects setting a logo without the custom-branding entitlement', async ({ page }) => {
// The entitlement is only enforced when billing is enabled; with billing off
// the check is intentionally skipped server-side, so this can't be exercised.
test.skip(
process.env.NEXT_PUBLIC_FEATURE_BILLING_ENABLED !== 'true',
'Entitlement is only enforced when billing is enabled.',
);
// Seeded organisations have no `allowCustomBranding` claim flag.
const { user, organisation } = await seedUser({ isPersonalOrganisation: false });
await apiSignin({
page,
email: user.email,
redirectPath: `/o/${organisation.url}/settings/branding`,
});
const response = await postOrganisationBrandingLogo(page, organisation.id, {
name: 'logo.png',
mimeType: 'image/png',
buffer: fs.readFileSync(LOGO_PATH),
});
expect(response.ok()).toBeFalsy();
const settings = await prisma.organisationGlobalSettings.findUniqueOrThrow({
where: { id: organisation.organisationGlobalSettingsId },
});
expect(settings.brandingLogo).toBeFalsy();
});
@@ -142,3 +142,38 @@ test('[SIGNING_BRANDING]: embedded signing does not render custom logo Brand Web
await expect(page.locator(`a[href="${BRANDING_URL}"]`)).toHaveCount(0); await expect(page.locator(`a[href="${BRANDING_URL}"]`)).toHaveCount(0);
await expect(page.getByRole('link', { name: `${team.name}'s Logo` })).toHaveCount(0); await expect(page.getByRole('link', { name: `${team.name}'s Logo` })).toHaveCount(0);
}); });
test('[SIGNING_BRANDING]: custom logo renders when branding is enabled and is hidden when disabled', async ({
page,
}) => {
const { user, team, organisation } = await seedUser();
await enableOrganisationBranding({
organisationGlobalSettingsId: organisation.organisationGlobalSettingsId,
});
const { recipients } = await seedPendingDocumentWithFullFields({
owner: user,
teamId: team.id,
recipients: ['enabled-disabled-branding-signer@test.documenso.com'],
fields: [FieldType.SIGNATURE],
updateDocumentOptions: { internalVersion: 2 },
});
// Branding enabled → the custom logo is rendered on the signing page.
await page.goto(`/sign/${recipients[0].token}`);
await expectPlainBrandingLogo(page, `${team.name}'s Logo`);
// Disable branding while keeping the stored logo (the team inherits this).
await prisma.organisationGlobalSettings.update({
where: { id: organisation.organisationGlobalSettingsId },
data: { brandingEnabled: false },
});
// Branding disabled → the custom logo is gone and the Documenso fallback
// (an internal link to "/") is shown instead.
await page.goto(`/sign/${recipients[0].token}`);
await expect(page.getByRole('img', { name: `${team.name}'s Logo` })).toHaveCount(0);
await expect(page.locator('a[href="/"]').first()).toBeVisible();
});
+1 -1
View File
@@ -1,4 +1,4 @@
import { ZNameSchema } from '@documenso/lib/constants/auth'; import { ZNameSchema } from '@documenso/lib/types/name';
import { zEmail } from '@documenso/lib/utils/zod'; import { zEmail } from '@documenso/lib/utils/zod';
import { z } from 'zod'; import { z } from 'zod';
-15
View File
@@ -1,25 +1,10 @@
import MailChecker from 'mailchecker'; import MailChecker from 'mailchecker';
import { z } from 'zod';
import { env } from '../utils/env'; import { env } from '../utils/env';
import { NEXT_PUBLIC_WEBAPP_URL } from './app'; import { NEXT_PUBLIC_WEBAPP_URL } from './app';
export const SALT_ROUNDS = 12; export const SALT_ROUNDS = 12;
export const URL_PATTERN = /https?:\/\/|www\./i;
/**
* Shared name schema that disallows URLs to prevent phishing via email rendering.
*/
export const ZNameSchema = z
.string()
.trim()
.min(3, { message: 'Please enter a valid name.' })
.max(255, { message: 'Name cannot be more than 255 characters.' })
.refine((value) => !URL_PATTERN.test(value), {
message: 'Name cannot contain URLs.',
});
export const IDENTITY_PROVIDER_NAME: Record<string, string> = { export const IDENTITY_PROVIDER_NAME: Record<string, string> = {
DOCUMENSO: 'Documenso', DOCUMENSO: 'Documenso',
GOOGLE: 'Google', GOOGLE: 'Google',
+10
View File
@@ -9,3 +9,13 @@
* cap so a malicious or runaway payload can't exhaust PostCSS/server memory. * cap so a malicious or runaway payload can't exhaust PostCSS/server memory.
*/ */
export const BRANDING_CSS_MAX_LENGTH = 256 * 1024; export const BRANDING_CSS_MAX_LENGTH = 256 * 1024;
/**
* Branding logo upload constraints. Enforced server-side at the TRPC request
* boundary (`zfdBrandingImageFile`) and reused by the client form for matching UX.
*/
export const BRANDING_LOGO_MAX_SIZE_MB = 5;
export const BRANDING_LOGO_MAX_SIZE_BYTES = BRANDING_LOGO_MAX_SIZE_MB * 1024 * 1024;
export const BRANDING_LOGO_ALLOWED_TYPES: string[] = ['image/jpeg', 'image/png', 'image/webp'];
@@ -0,0 +1,26 @@
import { AppError, AppErrorCode } from '../../errors/app-error';
import { putFileServerSide } from '../../universal/upload/put-file.server';
import { optimiseBrandingLogo } from '../../utils/images/logo';
/**
* Validate, sanitise and store an uploaded branding logo. Returns the
* `JSON.stringify({ type, data })` reference persisted in the `brandingLogo`
* column (the same format the serving endpoints already expect).
*/
export const buildBrandingLogoData = async (file: File): Promise<string> => {
const buffer = Buffer.from(await file.arrayBuffer());
const optimised = await optimiseBrandingLogo(buffer).catch(() => {
throw new AppError(AppErrorCode.INVALID_BODY, {
message: 'The branding logo must be a valid image file.',
});
});
const documentData = await putFileServerSide({
name: 'branding-logo.png',
type: 'image/png',
arrayBuffer: async () => Promise.resolve(optimised),
});
return JSON.stringify(documentData);
};
@@ -57,7 +57,10 @@ export const UNSAFE_createEnvelopeItems = async ({
flattenForm: envelope.type !== 'TEMPLATE', flattenForm: envelope.type !== 'TEMPLATE',
}); });
const { cleanedPdf, placeholders } = await extractPdfPlaceholders(normalized); const { cleanedPdf, placeholders } = await extractPdfPlaceholders(normalized, {
envelopeId: envelope.id,
fileName: file.name,
});
const { documentData } = await putPdfFileServerSide({ const { documentData } = await putPdfFileServerSide({
name: file.name, name: file.name,
@@ -85,7 +85,10 @@ export const UNSAFE_replaceEnvelopeItemPdf = async ({
flattenForm: envelope.type !== 'TEMPLATE', flattenForm: envelope.type !== 'TEMPLATE',
}); });
const { cleanedPdf, placeholders } = await extractPdfPlaceholders(normalized); const { cleanedPdf, placeholders } = await extractPdfPlaceholders(normalized, {
envelopeId: envelope.id,
fileName: data.file.name,
});
// Upload the new PDF and get a new DocumentData record. // Upload the new PDF and get a new DocumentData record.
const { documentData: newDocumentData, filePageCount } = await putPdfFileServerSide({ const { documentData: newDocumentData, filePageCount } = await putPdfFileServerSide({
@@ -1,8 +1,15 @@
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { type TFieldAndMeta, ZEnvelopeFieldAndMetaSchema } from '@documenso/lib/types/field-meta'; import { type TFieldAndMeta, ZEnvelopeFieldAndMetaSchema } from '@documenso/lib/types/field-meta';
import { logger } from '@documenso/lib/utils/logger';
import { PDF, rgb } from '@libpdf/core'; import { PDF, rgb } from '@libpdf/core';
import type { FieldType, Recipient } from '@prisma/client'; import type { FieldType, Recipient } from '@prisma/client';
import { parseFieldMetaFromPlaceholder, parseFieldTypeFromPlaceholder } from './helpers'; import {
parseFieldMetaFromPlaceholder,
parseFieldTypeFromPlaceholder,
parsePlaceholderData,
parseRawFieldMetaFromPlaceholder,
} from './helpers';
const PLACEHOLDER_REGEX = /\{\{([^}]+)\}\}/g; const PLACEHOLDER_REGEX = /\{\{([^}]+)\}\}/g;
const DEFAULT_FIELD_HEIGHT_PERCENT = 2; const DEFAULT_FIELD_HEIGHT_PERCENT = 2;
@@ -61,7 +68,15 @@ export type FieldToCreate = TFieldAndMeta & {
height: number; height: number;
}; };
export const extractPlaceholdersFromPDF = async (pdf: Buffer): Promise<PlaceholderInfo[]> => { type ExtractPlaceholdersLogContext = {
envelopeId?: string;
fileName?: string;
};
export const extractPlaceholdersFromPDF = async (
pdf: Buffer,
logContext?: ExtractPlaceholdersLogContext,
): Promise<PlaceholderInfo[]> => {
const pdfDoc = await PDF.load(new Uint8Array(pdf)); const pdfDoc = await PDF.load(new Uint8Array(pdf));
const placeholders: PlaceholderInfo[] = []; const placeholders: PlaceholderInfo[] = [];
@@ -85,7 +100,7 @@ export const extractPlaceholdersFromPDF = async (pdf: Buffer): Promise<Placehold
continue; continue;
} }
const placeholderData = innerMatch[1].split(',').map((property) => property.trim()); const placeholderData = parsePlaceholderData(innerMatch[1]);
const [fieldTypeString, recipientOrMeta, ...fieldMetaData] = placeholderData; const [fieldTypeString, recipientOrMeta, ...fieldMetaData] = placeholderData;
let fieldType: FieldType; let fieldType: FieldType;
@@ -109,14 +124,51 @@ export const extractPlaceholdersFromPDF = async (pdf: Buffer): Promise<Placehold
const recipient = recipientOrMeta; const recipient = recipientOrMeta;
const rawFieldMeta = Object.fromEntries(fieldMetaData.map((property) => property.split('='))); /*
Parse and validate the field metadata. A malformed selection placeholder
(e.g. an unknown validation rule or a default value that doesn't match an
option) is skipped like an invalid field type rather than aborting the whole
upload, which may contain other valid placeholders and files.
*/
let fieldAndMeta: TFieldAndMeta;
const parsedFieldMeta = parseFieldMetaFromPlaceholder(rawFieldMeta, fieldType); try {
const rawFieldMeta = parseRawFieldMetaFromPlaceholder(fieldMetaData);
const parsedFieldMeta = parseFieldMetaFromPlaceholder(rawFieldMeta, fieldType);
const fieldAndMeta: TFieldAndMeta = ZEnvelopeFieldAndMetaSchema.parse({ const parsedFieldAndMeta = ZEnvelopeFieldAndMetaSchema.safeParse({
type: fieldType, type: fieldType,
fieldMeta: parsedFieldMeta, fieldMeta: parsedFieldMeta,
}); });
/*
Surface schema failures as INVALID_BODY (400) instead of letting the raw
ZodError bubble up to the caller as an INTERNAL_SERVER_ERROR (500).
*/
if (!parsedFieldAndMeta.success) {
throw new AppError(AppErrorCode.INVALID_BODY, {
message: `Invalid field metadata for placeholder "${placeholder}": ${parsedFieldAndMeta.error.message}`,
});
}
fieldAndMeta = parsedFieldAndMeta.data;
} catch (error) {
const appError = AppError.parseError(error);
logger.warn(
{
envelopeId: logContext?.envelopeId,
fileName: logContext?.fileName,
placeholder,
page: page.index + 1,
code: appError.code,
message: appError.message,
},
'Skipping placeholder with invalid field metadata',
);
continue;
}
/* /*
LibPDF returns bbox in points with bottom-left origin. LibPDF returns bbox in points with bottom-left origin.
@@ -182,8 +234,9 @@ export const removePlaceholdersFromPDF = async (pdf: Buffer, placeholders?: Plac
*/ */
export const extractPdfPlaceholders = async ( export const extractPdfPlaceholders = async (
pdf: Buffer, pdf: Buffer,
logContext?: ExtractPlaceholdersLogContext,
): Promise<{ cleanedPdf: Buffer; placeholders: PlaceholderInfo[] }> => { ): Promise<{ cleanedPdf: Buffer; placeholders: PlaceholderInfo[] }> => {
const placeholders = await extractPlaceholdersFromPDF(pdf); const placeholders = await extractPlaceholdersFromPDF(pdf, logContext);
if (placeholders.length === 0) { if (placeholders.length === 0) {
return { cleanedPdf: pdf, placeholders: [] }; return { cleanedPdf: pdf, placeholders: [] };
@@ -0,0 +1,235 @@
import { FieldType } from '@prisma/client';
import { describe, expect, it } from 'vitest';
import { AppError, AppErrorCode } from '../../errors/app-error';
import {
parseFieldMetaFromPlaceholder,
parseFieldTypeFromPlaceholder,
parsePlaceholderData,
parseRawFieldMetaFromPlaceholder,
} from './helpers';
const expectInvalidBody = (fn: () => unknown) => {
try {
fn();
expect.unreachable('Expected an AppError to be thrown');
} catch (error) {
expect(error).toBeInstanceOf(AppError);
expect((error as AppError).code).toBe(AppErrorCode.INVALID_BODY);
}
};
describe('parseFieldTypeFromPlaceholder function', () => {
it('maps known field type strings to the FieldType enum', () => {
expect(parseFieldTypeFromPlaceholder('signature')).toBe(FieldType.SIGNATURE);
expect(parseFieldTypeFromPlaceholder('radio')).toBe(FieldType.RADIO);
expect(parseFieldTypeFromPlaceholder('checkbox')).toBe(FieldType.CHECKBOX);
expect(parseFieldTypeFromPlaceholder('dropdown')).toBe(FieldType.DROPDOWN);
});
it('is case-insensitive and trims surrounding whitespace', () => {
expect(parseFieldTypeFromPlaceholder(' SiGnAtUrE ')).toBe(FieldType.SIGNATURE);
expect(parseFieldTypeFromPlaceholder('RADIO')).toBe(FieldType.RADIO);
});
it('throws INVALID_BODY for an unknown field type', () => {
expectInvalidBody(() => parseFieldTypeFromPlaceholder('FILE'));
});
});
describe('parsePlaceholderData function', () => {
it('splits top-level parts on commas and trims each token', () => {
expect(parsePlaceholderData('SIGNATURE, r1, required=true')).toEqual(['SIGNATURE', 'r1', 'required=true']);
});
it('does not split on escaped commas', () => {
expect(parsePlaceholderData('dropdown, r1, options=Legal\\, Compliance|Sales')).toEqual([
'dropdown',
'r1',
'options=Legal\\, Compliance|Sales',
]);
});
});
describe('parseRawFieldMetaFromPlaceholder function', () => {
it('splits each token into a key/value entry', () => {
expect(parseRawFieldMetaFromPlaceholder(['required=true', 'fontSize=12'])).toEqual({
required: 'true',
fontSize: '12',
});
});
it('only splits on the first unescaped equals sign', () => {
expect(parseRawFieldMetaFromPlaceholder(['label=a=b'])).toEqual({ label: 'a=b' });
});
it('drops tokens without a value and overwrites duplicate keys with the last', () => {
expect(parseRawFieldMetaFromPlaceholder(['required', 'fontSize=12', 'fontSize=14'])).toEqual({
fontSize: '14',
});
});
});
describe('parseFieldMetaFromPlaceholder function', () => {
describe('non-field-meta cases', () => {
it('returns undefined for signature and free signature fields', () => {
expect(parseFieldMetaFromPlaceholder({ required: 'true' }, FieldType.SIGNATURE)).toBeUndefined();
expect(parseFieldMetaFromPlaceholder({}, FieldType.FREE_SIGNATURE)).toBeUndefined();
});
it('returns undefined when there is no metadata', () => {
expect(parseFieldMetaFromPlaceholder({}, FieldType.TEXT)).toBeUndefined();
});
});
describe('generic metadata', () => {
it('coerces required/readOnly to booleans (case-insensitive)', () => {
expect(parseFieldMetaFromPlaceholder({ required: 'TRUE', readOnly: 'false' }, FieldType.TEXT)).toEqual({
type: 'text',
required: true,
readOnly: false,
});
});
it('coerces numeric properties to numbers', () => {
expect(parseFieldMetaFromPlaceholder({ fontSize: '14' }, FieldType.TEXT)).toEqual({
type: 'text',
fontSize: 14,
});
});
it('drops numeric properties that are not a number', () => {
const parsed = parseFieldMetaFromPlaceholder({ fontSize: 'abc' }, FieldType.TEXT);
expect(parsed).toEqual({ type: 'text' });
expect(parsed).not.toHaveProperty('fontSize');
});
it('keeps label/placeholder for non-selection fields', () => {
expect(parseFieldMetaFromPlaceholder({ label: 'Company Name', placeholder: 'Acme' }, FieldType.TEXT)).toEqual({
type: 'text',
label: 'Company Name',
placeholder: 'Acme',
});
});
});
describe('radio fields', () => {
it('builds stable values from options', () => {
expect(parseFieldMetaFromPlaceholder({ options: 'Yes|No|Maybe' }, FieldType.RADIO)).toEqual({
type: 'radio',
values: [
{ id: 1, checked: false, value: 'Yes' },
{ id: 2, checked: false, value: 'No' },
{ id: 3, checked: false, value: 'Maybe' },
],
});
});
it('marks only the selected option as checked', () => {
const parsed = parseFieldMetaFromPlaceholder({ options: 'Yes|No|Maybe', selected: 'No' }, FieldType.RADIO);
expect(parsed).toEqual({
type: 'radio',
values: [
{ id: 1, checked: false, value: 'Yes' },
{ id: 2, checked: true, value: 'No' },
{ id: 3, checked: false, value: 'Maybe' },
],
});
});
it('throws when a default value is provided without options', () => {
expectInvalidBody(() => parseFieldMetaFromPlaceholder({ selected: 'No' }, FieldType.RADIO));
});
it('throws when the default value does not match an option', () => {
expectInvalidBody(() => parseFieldMetaFromPlaceholder({ options: 'Yes|No', selected: 'Maybe' }, FieldType.RADIO));
});
it('throws when options is empty', () => {
expectInvalidBody(() => parseFieldMetaFromPlaceholder({ options: '' }, FieldType.RADIO));
});
});
describe('checkbox fields', () => {
it('builds values with checked state, validation rule alias and length', () => {
const parsed = parseFieldMetaFromPlaceholder(
{
options: 'Email|SMS|Phone',
checked: 'Email|Phone',
validationRule: 'atLeast',
validationLength: '1',
},
FieldType.CHECKBOX,
);
expect(parsed).toEqual({
type: 'checkbox',
validationRule: 'Select at least',
validationLength: 1,
values: [
{ id: 1, checked: true, value: 'Email' },
{ id: 2, checked: false, value: 'SMS' },
{ id: 3, checked: true, value: 'Phone' },
],
});
});
it('throws for an unknown validation rule', () => {
expectInvalidBody(() =>
parseFieldMetaFromPlaceholder({ options: 'A|B', validationRule: 'nope' }, FieldType.CHECKBOX),
);
});
it('throws when checked values are provided without options', () => {
expectInvalidBody(() => parseFieldMetaFromPlaceholder({ checked: 'A' }, FieldType.CHECKBOX));
});
it('throws when a checked value does not match an option', () => {
expectInvalidBody(() => parseFieldMetaFromPlaceholder({ options: 'A|B', checked: 'C' }, FieldType.CHECKBOX));
});
});
describe('dropdown fields', () => {
it('builds values and sets a matching default value', () => {
expect(
parseFieldMetaFromPlaceholder(
{ options: 'United States|Canada|United Kingdom', defaultValue: 'Canada' },
FieldType.DROPDOWN,
),
).toEqual({
type: 'dropdown',
values: [{ value: 'United States' }, { value: 'Canada' }, { value: 'United Kingdom' }],
defaultValue: 'Canada',
});
});
it('throws when the default value does not match an option', () => {
expectInvalidBody(() => parseFieldMetaFromPlaceholder({ options: 'A|B', defaultValue: 'C' }, FieldType.DROPDOWN));
});
});
describe('selection field options parsing', () => {
it('trims option values and drops empty entries', () => {
expect(parseFieldMetaFromPlaceholder({ options: ' A || B ' }, FieldType.DROPDOWN)).toEqual({
type: 'dropdown',
values: [{ value: 'A' }, { value: 'B' }],
});
});
it('parses escaped delimiters through the full placeholder pipeline', () => {
const [, , ...fieldMetaData] = parsePlaceholderData(
'dropdown, r1, options=Sales\\|Ops|Legal\\, Compliance|A\\=B',
);
const rawFieldMeta = parseRawFieldMetaFromPlaceholder(fieldMetaData);
const parsed = parseFieldMetaFromPlaceholder(rawFieldMeta, FieldType.DROPDOWN);
expect(parsed).toEqual({
type: 'dropdown',
values: [{ value: 'Sales|Ops' }, { value: 'Legal, Compliance' }, { value: 'A=B' }],
});
});
});
});
+311 -5
View File
@@ -45,6 +45,134 @@ type RecipientPlaceholderInfo = {
recipientIndex: number; recipientIndex: number;
}; };
const CHECKBOX_VALIDATION_RULE_BY_ALIAS: Record<string, string> = {
atLeast: 'Select at least',
exactly: 'Select exactly',
atMost: 'Select at most',
};
/*
Split a string on a delimiter, treating `\` as an escape for the next character.
Delimiters preceded by `\` are kept in the output instead of splitting (e.g. `\,`, `\=`, `\|`).
With delimiter ',' (top-level placeholder parts):
'radio, r1, options=Card/Check|Bank Transfer, selected=Bank Transfer'
-> ['radio', ' r1', ' options=Card/Check|Bank Transfer', ' selected=Bank Transfer']
With delimiter '=' (split one field metadata token into key + value):
'options=Card/Check|Bank Transfer'
-> ['options', 'Card/Check|Bank Transfer']
With delimiter '|' (split option list inside 'options='):
'Card/Check|Bank Transfer'
-> ['Card/Check', 'Bank Transfer']
*/
const splitPlaceholderToken = (value: string, delimiter: string): string[] => {
const parts: string[] = [];
let currentPart = '';
for (let index = 0; index < value.length; index++) {
const char = value[index];
const nextChar = value[index + 1];
if (char === '\\' && nextChar) {
currentPart += char + nextChar;
index++;
continue;
}
if (char === delimiter) {
parts.push(currentPart);
currentPart = '';
continue;
}
currentPart += char;
}
parts.push(currentPart);
return parts;
};
/*
Removes the escape backslashes left over after splitting, so \,=, \|, \\ become their literal characters.
E.g.
'Legal\, Compliance' -> 'Legal, Compliance'
'Card\|Check' -> 'Card|Check'
'A\=B' -> 'A=B'
'C\D' -> 'C\D'
*/
const unescapePlaceholderValue = (value: string): string => {
return value.replace(/\\([,=|\\])/g, '$1');
};
/*
Cleans up a selection option/default after splitting:
unescapes literal delimiters, collapses repeated whitespace, and trims the ends.
E.g.
' Legal\, Compliance ' -> 'Legal, Compliance'
*/
const normalizePlaceholderSelectionValue = (value: string): string => {
return unescapePlaceholderValue(value).replace(/\s+/g, ' ').trim();
};
/*
Split an options string into individual choices.
Splits on unescaped '|', then unescapes, trims, and drops empty entries.
E.g.
'Card/Check|Bank Transfer' -> ['Card/Check', 'Bank Transfer']
'Card\\|Check|Bank Transfer' -> ['Card|Check', 'Bank Transfer']
*/
const parsePlaceholderOptions = (value: string): string[] => {
return splitPlaceholderToken(value, '|')
.map((option) => normalizePlaceholderSelectionValue(option))
.filter((option) => option.length > 0);
};
/*
Split a placeholder string into top-level parts (field type, recipient, metadata).
Splits on unescaped commas, then trims whitespace.
E.g.
'SIGNATURE, r1, required=true'
-> ['SIGNATURE', 'r1', 'required=true']
*/
export const parsePlaceholderData = (value: string): string[] => {
return splitPlaceholderToken(value, ',').map((token) => token.trim());
};
/*
Transforms the field metadata string array into a record of key/value pairs.
Each token is split on the first unescaped '='; tokens with no key or no '=' are dropped.
E.g.
['required=true', 'fontSize=12', 'label=a=b']
-> { required: 'true', fontSize: '12', label: 'a=b' }
*/
export const parseRawFieldMetaFromPlaceholder = (fieldMetaData: string[]): Record<string, string> => {
const rawFieldMeta: Record<string, string> = {};
for (const fieldMeta of fieldMetaData) {
// Split on the first '=' only; any further '=' stays part of the value (e.g. 'label=a=b').
const [rawKey, ...valueParts] = splitPlaceholderToken(fieldMeta, '=');
if (!rawKey || valueParts.length === 0) {
continue;
}
const key = rawKey.trim();
const value = valueParts.join('=').trim();
rawFieldMeta[key] = value;
}
return rawFieldMeta;
};
/* /*
Parse field type string to FieldType enum. Parse field type string to FieldType enum.
Normalizes the input (uppercase, trim) and validates it's a valid field type. Normalizes the input (uppercase, trim) and validates it's a valid field type.
@@ -72,6 +200,169 @@ export const parseFieldTypeFromPlaceholder = (fieldTypeString: string): FieldTyp
}); });
}; };
const getDefaultFieldMetaValue = (rawFieldMeta: Record<string, string>) => {
const defaultValue = rawFieldMeta.defaultValue ?? rawFieldMeta.default ?? rawFieldMeta.selected;
return defaultValue ? normalizePlaceholderSelectionValue(defaultValue) : undefined;
};
const parseCheckboxValidationRule = (value: string): string => {
const validationRule = CHECKBOX_VALIDATION_RULE_BY_ALIAS[value];
if (!validationRule) {
throw new AppError(AppErrorCode.INVALID_BODY, {
message: `Invalid checkbox placeholder validation rule: ${value}`,
});
}
return validationRule;
};
const parseSelectionFieldOptions = (
rawFieldMeta: Record<string, string>,
fieldType: FieldType,
): string[] | undefined => {
const rawOptions = rawFieldMeta.options;
if (rawOptions === undefined) {
return;
}
const parsedOptions = parsePlaceholderOptions(rawOptions);
if (parsedOptions.length === 0) {
throw new AppError(AppErrorCode.INVALID_BODY, {
message: `${fieldType} placeholder options must contain at least one value`,
});
}
return parsedOptions;
};
const applyRadioFieldOptions = (parsedFieldMeta: Record<string, unknown>, rawFieldMeta: Record<string, string>) => {
const options = parseSelectionFieldOptions(rawFieldMeta, FieldType.RADIO);
const defaultValue = getDefaultFieldMetaValue(rawFieldMeta);
if (!options && defaultValue) {
throw new AppError(AppErrorCode.INVALID_BODY, {
message: 'Radio placeholder default value requires options',
});
}
if (!options) {
return;
}
const selectedOptionIndex = defaultValue ? options.findIndex((option) => option === defaultValue) : -1;
if (defaultValue && selectedOptionIndex === -1) {
throw new AppError(AppErrorCode.INVALID_BODY, {
message: `Radio placeholder default value "${defaultValue}" must match one of the options`,
});
}
parsedFieldMeta.values = options.map((option, index) => ({
id: index + 1,
checked: index === selectedOptionIndex,
value: option,
}));
};
const applyCheckboxFieldOptions = (parsedFieldMeta: Record<string, unknown>, rawFieldMeta: Record<string, string>) => {
const options = parseSelectionFieldOptions(rawFieldMeta, FieldType.CHECKBOX);
const checkedValues = rawFieldMeta.checked ? parsePlaceholderOptions(rawFieldMeta.checked) : [];
if (!options && checkedValues.length > 0) {
throw new AppError(AppErrorCode.INVALID_BODY, {
message: 'Checkbox placeholder checked values require options',
});
}
if (!options) {
return;
}
const unmatchedCheckedValues = checkedValues.filter((checkedValue) => !options.includes(checkedValue));
if (unmatchedCheckedValues.length > 0) {
const unmatchedCheckedValue = unmatchedCheckedValues[0];
throw new AppError(AppErrorCode.INVALID_BODY, {
message: [`Checkbox placeholder checked value "${unmatchedCheckedValue}"`, 'must match one of the options'].join(
' ',
),
});
}
parsedFieldMeta.values = options.map((option, index) => ({
id: index + 1,
checked: checkedValues.includes(option),
value: option,
}));
};
const applyDropdownFieldOptions = (parsedFieldMeta: Record<string, unknown>, rawFieldMeta: Record<string, string>) => {
const options = parseSelectionFieldOptions(rawFieldMeta, FieldType.DROPDOWN);
const defaultValue = getDefaultFieldMetaValue(rawFieldMeta);
if (!options && defaultValue) {
throw new AppError(AppErrorCode.INVALID_BODY, {
message: 'Dropdown placeholder default value requires options',
});
}
if (!options) {
return;
}
if (defaultValue && !options.includes(defaultValue)) {
throw new AppError(AppErrorCode.INVALID_BODY, {
message: `Dropdown placeholder default value "${defaultValue}" must match one of the options`,
});
}
parsedFieldMeta.values = options.map((option) => ({
value: option,
}));
if (defaultValue) {
parsedFieldMeta.defaultValue = defaultValue;
}
};
/*
Generic field metadata properties are simple properties consisting of a key and a value.
E.g. 'required=true', 'fontSize=12', 'textAlign=left'
They don't require special handling.
Special field metadata properties are complex properties consisting of a key and a value with multiple parts.
E.g. 'options=Card/Check|Bank Transfer', 'checked=Card|Check', 'selected=Bank Transfer'
They require special handling.
*/
const shouldSkipGenericFieldMetaParsing = (property: string, fieldType: FieldType): boolean => {
if (property === 'options' || property === 'default' || property === 'selected') {
return true;
}
const isSelectionField =
fieldType === FieldType.CHECKBOX || fieldType === FieldType.RADIO || fieldType === FieldType.DROPDOWN;
if (!isSelectionField) {
return false;
}
if (
property === 'label' ||
property === 'placeholder' ||
property === 'defaultValue' ||
(fieldType === FieldType.CHECKBOX && property === 'checked')
) {
return true;
}
return false;
};
/* /*
Transform raw field metadata from placeholder format to schema format. Transform raw field metadata from placeholder format to schema format.
Users should provide properly capitalized property names (e.g., readOnly, fontSize, textAlign). Users should provide properly capitalized property names (e.g., readOnly, fontSize, textAlign).
@@ -91,7 +382,7 @@ export const parseFieldMetaFromPlaceholder = (
const fieldTypeString = String(fieldType).toLowerCase(); const fieldTypeString = String(fieldType).toLowerCase();
const parsedFieldMeta: Record<string, boolean | number | string> = { const parsedFieldMeta: Record<string, unknown> = {
type: fieldTypeString, type: fieldTypeString,
}; };
@@ -104,24 +395,39 @@ export const parseFieldMetaFromPlaceholder = (
const rawFieldMetaEntries = Object.entries(rawFieldMeta); const rawFieldMetaEntries = Object.entries(rawFieldMeta);
for (const [property, value] of rawFieldMetaEntries) { for (const [property, value] of rawFieldMetaEntries) {
if (shouldSkipGenericFieldMetaParsing(property, fieldType)) {
continue;
}
const unescapedValue = unescapePlaceholderValue(value);
if (property === 'readOnly' || property === 'required') { if (property === 'readOnly' || property === 'required') {
parsedFieldMeta[property] = value === 'true'; parsedFieldMeta[property] = unescapedValue.toLowerCase() === 'true';
} else if (property === 'validationRule' && fieldType === FieldType.CHECKBOX) {
parsedFieldMeta[property] = parseCheckboxValidationRule(unescapedValue);
} else if ( } else if (
property === 'fontSize' || property === 'fontSize' ||
property === 'maxValue' || property === 'maxValue' ||
property === 'minValue' || property === 'minValue' ||
property === 'characterLimit' property === 'characterLimit' ||
property === 'validationLength'
) { ) {
const numValue = Number(value); const numValue = Number(unescapedValue);
if (!Number.isNaN(numValue)) { if (!Number.isNaN(numValue)) {
parsedFieldMeta[property] = numValue; parsedFieldMeta[property] = numValue;
} }
} else { } else {
parsedFieldMeta[property] = value; parsedFieldMeta[property] = unescapedValue;
} }
} }
match(fieldType)
.with(FieldType.RADIO, () => applyRadioFieldOptions(parsedFieldMeta, rawFieldMeta))
.with(FieldType.CHECKBOX, () => applyCheckboxFieldOptions(parsedFieldMeta, rawFieldMeta))
.with(FieldType.DROPDOWN, () => applyDropdownFieldOptions(parsedFieldMeta, rawFieldMeta))
.otherwise(() => undefined);
return parsedFieldMeta; return parsedFieldMeta;
}; };
@@ -1,4 +1,4 @@
import { QUOTA_WARNING_THRESHOLD } from './get-quota-alert-kind'; import { isQuotaExceeded, isQuotaNearing } from '../../universal/quota-usage';
export type QuotaFlags = { export type QuotaFlags = {
isDocumentQuotaExceeded: boolean; isDocumentQuotaExceeded: boolean;
@@ -22,39 +22,6 @@ type ComputeQuotaFlagsOptions = {
}; };
}; };
/**
* A quota of `null` means unlimited (never exceeded). A quota of `0` means
* blocked (always exceeded). Otherwise usage `>=` quota is exceeded.
*/
const isQuotaExceeded = (quota: number | null, usage: number): boolean => {
if (quota === null) {
return false;
}
if (quota === 0) {
return true;
}
return usage >= quota;
};
/**
* A counter is "nearing" its quota once usage reaches the warning threshold
* (80% of the quota, rounded up) but has not yet been exceeded. Nearing and
* exceeded are mutually exclusive per counter.
*/
const isQuotaNearing = (quota: number | null, usage: number): boolean => {
if (quota === null || quota === 0) {
return false;
}
if (isQuotaExceeded(quota, usage)) {
return false;
}
return usage >= Math.ceil(quota * QUOTA_WARNING_THRESHOLD);
};
export const computeQuotaFlags = ({ quotas, usage }: ComputeQuotaFlagsOptions): QuotaFlags => { export const computeQuotaFlags = ({ quotas, usage }: ComputeQuotaFlagsOptions): QuotaFlags => {
return { return {
isDocumentQuotaExceeded: isQuotaExceeded(quotas.documentQuota, usage?.documentCount ?? 0), isDocumentQuotaExceeded: isQuotaExceeded(quotas.documentQuota, usage?.documentCount ?? 0),
@@ -1,4 +1,4 @@
export const QUOTA_WARNING_THRESHOLD = 0.8; import { getQuotaWarningCount } from '../../universal/quota-usage';
export type QuotaAlertKind = 'quota' | 'quotaNearing'; export type QuotaAlertKind = 'quota' | 'quotaNearing';
@@ -32,7 +32,7 @@ export const getQuotaAlertKind = (opts: GetQuotaAlertKindOptions): QuotaAlertKin
// From here newCount < quota, so for tiny quotas (1-4) where the rounded-up // From here newCount < quota, so for tiny quotas (1-4) where the rounded-up
// warning threshold equals the quota itself, the warning can never fire — the // warning threshold equals the quota itself, the warning can never fire — the
// exhausting request is handled by the quota branch above. // exhausting request is handled by the quota branch above.
const warningCount = Math.ceil(quota * QUOTA_WARNING_THRESHOLD); const warningCount = getQuotaWarningCount(quota);
const didCrossWarning = newCount >= warningCount && previousCount < warningCount; const didCrossWarning = newCount >= warningCount && previousCount < warningCount;
+145
View File
@@ -0,0 +1,145 @@
import { describe, expect, it } from 'vitest';
import { ZNameSchema } from './name';
describe('ZNameSchema', () => {
describe('valid names', () => {
it('accepts a normal name', () => {
expect(ZNameSchema.safeParse('Example User')).toEqual({
success: true,
data: 'Example User',
});
});
it('accepts international characters', () => {
expect(ZNameSchema.safeParse('Døcumensø Üser')).toEqual({
success: true,
data: 'Døcumensø Üser',
});
});
it('trims surrounding whitespace', () => {
expect(ZNameSchema.safeParse(' Documenso User ')).toEqual({
success: true,
data: 'Documenso User',
});
});
it('accepts names at the minimum length', () => {
expect(ZNameSchema.safeParse('DU')).toEqual({
success: true,
data: 'DU',
});
});
it('accepts names at the maximum length', () => {
const name =
'DocumensoUser DocumensoUser DocumensoUser DocumensoUser DocumensoUser DocumensoUser DocumensoUser Do';
expect(name.length).toBe(100);
expect(ZNameSchema.safeParse(name)).toEqual({
success: true,
data: name,
});
});
});
describe('length validation', () => {
it('rejects names shorter than 2 characters', () => {
expect(ZNameSchema.safeParse('D')).toMatchObject({
success: false,
error: {
issues: [{ message: 'Please enter a valid name.' }],
},
});
});
it('rejects names longer than 100 characters', () => {
const name =
'DocumensoUser DocumensoUser DocumensoUser DocumensoUser DocumensoUser DocumensoUser DocumensoUser Doc';
expect(name.length).toBe(101);
expect(ZNameSchema.safeParse(name)).toMatchObject({
success: false,
error: {
issues: [{ message: 'Name cannot be more than 100 characters.' }],
},
});
});
it('rejects whitespace-only input after trim', () => {
expect(ZNameSchema.safeParse(' ')).toMatchObject({
success: false,
});
});
});
describe('URL validation', () => {
it.each([
'https://example.com',
'http://example.com',
'HTTPS://EXAMPLE.COM',
'Northwind www.example.com',
'www.example.com',
])('rejects URLs in names: %s', (value) => {
expect(ZNameSchema.safeParse(value)).toMatchObject({
success: false,
error: {
issues: expect.arrayContaining([expect.objectContaining({ message: 'Name cannot contain URLs.' })]),
},
});
});
});
describe('invalid character validation', () => {
it.each([
['NUL character', 'Acme\u0000Corp'],
['zero-width space', 'Acme\u200bCorp'],
['bidi override', 'Acme\u202eCorp'],
['byte order mark', 'Acme\ufeffCorp'],
['lone surrogate', 'Acme\ud800Corp'],
['tag character', `Acme${String.fromCodePoint(0xe0041)}Corp`],
['noncharacter', 'Acme\ufffeCorp'],
['private use character', 'Acme\ue000Corp'],
['Hangul filler', 'Acme\u3164Corp'],
['braille blank', 'Acme\u2800Corp'],
['combining grapheme joiner', 'Acme\u034fCorp'],
])('rejects names containing a %s', (_label, value) => {
expect(ZNameSchema.safeParse(value)).toMatchObject({
success: false,
error: {
issues: expect.arrayContaining([expect.objectContaining({ message: 'Name contains invalid characters.' })]),
},
});
});
it.each([
['fixed form', String.raw`Acme\u200bCorp`],
['uppercase U', String.raw`Acme\U200BCorp`],
['braced form', String.raw`Acme\u{200b}Corp`],
['braced form with leading zeros', String.raw`Acme\u{0000200b}Corp`],
['lone surrogate', String.raw`Acme\ud800Corp`],
])('rejects literal \\u escape sequences stored as text (%s)', (_label, value) => {
expect(ZNameSchema.safeParse(value)).toMatchObject({
success: false,
error: {
issues: expect.arrayContaining([expect.objectContaining({ message: 'Name contains invalid characters.' })]),
},
});
});
it.each([
['escape of a valid code point', String.raw`Acme\u0041Corp`],
['braced escape of a valid astral code point', String.raw`Acme\u{1F600}Corp`],
['braced escape beyond the Unicode range', String.raw`Acme\u{FFFFFFF}Corp`],
['incomplete escape sequence', String.raw`Acme\u00 Corp`],
['unterminated braced escape', String.raw`Acme\u{200bCorp`],
['astral characters such as emoji', 'Acme 😀 Corp'],
['emoji with a variation selector', 'I ❤️ Docs'],
])('accepts %s', (_label, value) => {
expect(ZNameSchema.safeParse(value)).toMatchObject({
success: true,
});
});
});
});
+68
View File
@@ -0,0 +1,68 @@
import { z } from 'zod';
export const URL_PATTERN = /https?:\/\/|www\./i;
/**
* Characters that render as empty/invisible or break text layout:
*
* - `\p{C}` - control, format, lone surrogate, private use and
* unassigned code points (NUL, zero-width spaces, bidi
* overrides, BOM, tag characters, noncharacters).
* - `\p{Zl}\p{Zp}` - line and paragraph separators.
* - `\u{034F}` - combining grapheme joiner (invisible). Kept outside the
* character class because it is a combining mark, which
* lint rules reject inside classes.
* - remaining - letters that render as blank (Hangul fillers, braille blank).
*
* The `\p{...}` classes are maintained by the Unicode database, so newly
* assigned characters in these categories are covered automatically.
*/
const INVALID_CHARACTER_REGEX = /[\p{C}\p{Zl}\p{Zp}\u{115F}\u{1160}\u{2800}\u{3164}\u{FFA0}]|\u{034F}/u;
const hasInvalidCharacter = (value: string) => INVALID_CHARACTER_REGEX.test(value);
/**
* Matches literal `\uXXXX` and `\u{XXXX}` escape sequences stored verbatim as
* text (e.g. the 6 characters `\`, `u`, `2`, `0`, `0`, `b`), which can still
* break rendering downstream if anything decodes them.
*/
const ESCAPE_SEQUENCE_PATTERN = /\\u(?:([0-9a-f]{4})|\{([0-9a-f]+)\})/gi;
const hasInvalidEscapeSequence = (value: string) => {
for (const [, fixedHex, bracedHex] of value.matchAll(ESCAPE_SEQUENCE_PATTERN)) {
const codePoint = parseInt(fixedHex ?? bracedHex, 16);
if (codePoint > 0x10ffff) {
continue;
}
// Decode the escape and run it through the same character policy as the
// unescaped check, so the two can never drift apart.
if (hasInvalidCharacter(String.fromCodePoint(codePoint))) {
return true;
}
}
return false;
};
export const hasInvalidTextCharacters = (value: string) =>
hasInvalidCharacter(value) || hasInvalidEscapeSequence(value);
/**
* Shared name schema that disallows URLs to prevent phishing via email rendering,
* and invisible/control characters that render as empty or break the UI.
*/
export const ZNameSchema = z
.string()
.trim()
.min(2, { message: 'Please enter a valid name.' })
.max(100, { message: 'Name cannot be more than 100 characters.' })
.refine((value) => !URL_PATTERN.test(value), {
message: 'Name cannot contain URLs.',
})
.refine((value) => !hasInvalidTextCharacters(value), {
message: 'Name contains invalid characters.',
});
export type TName = z.infer<typeof ZNameSchema>;
+32 -7
View File
@@ -6,14 +6,39 @@ import { z } from 'zod';
* *
* Example: "5m", "1h", "1d" * Example: "5m", "1h", "1d"
*/ */
export const ZRateLimitWindowSchema = z.string().regex(/^\d+[smhd]$/); export const RATE_LIMIT_WINDOW_REGEX = /^\d+[smhd]$/;
export const ZRateLimitArraySchema = z.array( const RATE_LIMIT_WINDOW_ERROR_MESSAGE = 'Use a duration with a unit, e.g. 5m, 1h, or 24h';
z.object({ const RATE_LIMIT_DUPLICATE_WINDOW_ERROR_MESSAGE = 'Use a unique window for each rate limit';
window: ZRateLimitWindowSchema,
max: z.number().int().positive(), export const ZRateLimitWindowSchema = z.string().trim().regex(RATE_LIMIT_WINDOW_REGEX, {
}), message: RATE_LIMIT_WINDOW_ERROR_MESSAGE,
); });
export const ZRateLimitArraySchema = z
.array(
z.object({
window: ZRateLimitWindowSchema,
max: z.number().int().positive(),
}),
)
.superRefine((entries, ctx) => {
const windows = new Set<string>();
entries.forEach((entry, index) => {
const window = entry.window.trim();
if (windows.has(window)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: RATE_LIMIT_DUPLICATE_WINDOW_ERROR_MESSAGE,
path: [index, 'window'],
});
}
windows.add(window);
});
});
export type TRateLimitArray = z.infer<typeof ZRateLimitArraySchema>; export type TRateLimitArray = z.infer<typeof ZRateLimitArraySchema>;
@@ -0,0 +1,99 @@
import { describe, expect, it } from 'vitest';
import {
getQuotaUsagePercent,
getQuotaWarningCount,
isQuotaExceeded,
isQuotaNearing,
normalizeCapacityLimit,
} from './quota-usage';
describe('isQuotaExceeded', () => {
it('treats null quota as unlimited (never exceeded)', () => {
expect(isQuotaExceeded(null, 0)).toBe(false);
expect(isQuotaExceeded(null, 1_000_000)).toBe(false);
});
it('treats a zero quota as blocked (always exceeded)', () => {
expect(isQuotaExceeded(0, 0)).toBe(true);
expect(isQuotaExceeded(0, 5)).toBe(true);
});
it('is exceeded once usage reaches the quota (>= boundary)', () => {
expect(isQuotaExceeded(10, 9)).toBe(false);
expect(isQuotaExceeded(10, 10)).toBe(true);
expect(isQuotaExceeded(10, 11)).toBe(true);
});
});
describe('getQuotaWarningCount', () => {
it('rounds the 80% threshold up', () => {
expect(getQuotaWarningCount(10)).toBe(8);
expect(getQuotaWarningCount(100)).toBe(80);
// 5 * 0.8 = 4 exactly.
expect(getQuotaWarningCount(5)).toBe(4);
// 3 * 0.8 = 2.4 -> 3, so the warning count equals the quota itself.
expect(getQuotaWarningCount(3)).toBe(3);
});
});
describe('isQuotaNearing', () => {
it('is never nearing for unlimited or blocked quotas', () => {
expect(isQuotaNearing(null, 5)).toBe(false);
expect(isQuotaNearing(0, 5)).toBe(false);
});
it('is nearing from the warning threshold up to (but not including) the quota', () => {
expect(isQuotaNearing(10, 7)).toBe(false);
expect(isQuotaNearing(10, 8)).toBe(true);
expect(isQuotaNearing(10, 9)).toBe(true);
});
it('is not nearing once exceeded (nearing and exceeded are mutually exclusive)', () => {
expect(isQuotaNearing(10, 10)).toBe(false);
expect(isQuotaNearing(10, 11)).toBe(false);
});
it('can never fire for tiny quotas where the warning count equals the quota', () => {
// getQuotaWarningCount(3) === 3, so usage >= 3 is already exceeded.
expect(isQuotaNearing(3, 2)).toBe(false);
expect(isQuotaNearing(3, 3)).toBe(false);
});
it('agrees with the warning-count helper at the boundary', () => {
const quota = 250;
const warningCount = getQuotaWarningCount(quota);
expect(isQuotaNearing(quota, warningCount - 1)).toBe(false);
expect(isQuotaNearing(quota, warningCount)).toBe(true);
});
});
describe('getQuotaUsagePercent', () => {
it('returns 0 for unlimited or non-positive quotas', () => {
expect(getQuotaUsagePercent(5, null)).toBe(0);
expect(getQuotaUsagePercent(5, 0)).toBe(0);
expect(getQuotaUsagePercent(5, -10)).toBe(0);
});
it('rounds the percentage to the nearest integer', () => {
expect(getQuotaUsagePercent(1, 3)).toBe(33);
expect(getQuotaUsagePercent(2, 3)).toBe(67);
expect(getQuotaUsagePercent(50, 100)).toBe(50);
});
it('clamps the percentage to 100 when usage exceeds the quota', () => {
expect(getQuotaUsagePercent(150, 100)).toBe(100);
});
});
describe('normalizeCapacityLimit', () => {
it('maps 0 (unlimited for capacity limits) to null', () => {
expect(normalizeCapacityLimit(0)).toBeNull();
});
it('passes positive limits through unchanged', () => {
expect(normalizeCapacityLimit(1)).toBe(1);
expect(normalizeCapacityLimit(25)).toBe(25);
});
});
+57
View File
@@ -0,0 +1,57 @@
export const QUOTA_WARNING_THRESHOLD = 0.8;
/**
* Monthly quotas: `null` = unlimited, `0` = blocked. Usage `>=` quota is exceeded.
*/
export const isQuotaExceeded = (quota: number | null, usage: number): boolean => {
if (quota === null) {
return false;
}
if (quota === 0) {
return true;
}
return usage >= quota;
};
/**
* The usage count at which a positive quota starts "nearing" (80% rounded up).
* The single source for the warning threshold math so the UI panel, quota flags,
* and the per-request alert path can't drift apart.
*/
export const getQuotaWarningCount = (quota: number): number => {
return Math.ceil(quota * QUOTA_WARNING_THRESHOLD);
};
/**
* Nearing once usage reaches the warning threshold (80% rounded up) but is not exceeded.
*/
export const isQuotaNearing = (quota: number | null, usage: number): boolean => {
if (quota === null || quota === 0) {
return false;
}
if (isQuotaExceeded(quota, usage)) {
return false;
}
return usage >= getQuotaWarningCount(quota);
};
export const getQuotaUsagePercent = (usage: number, quota: number | null): number => {
if (quota === null || quota <= 0) {
return 0;
}
return Math.min(100, Math.round((usage / quota) * 100));
};
/** Member/team capacity limits use `0` for unlimited. */
export const normalizeCapacityLimit = (limit: number): number | null => {
if (limit === 0) {
return null;
}
return limit;
};
+1 -71
View File
@@ -1,10 +1,5 @@
import { env } from '@documenso/lib/utils/env'; import type { TUploadPdfResponse } from '@documenso/remix/server/api/files/files.types';
import type { TGetPresignedPostUrlResponse, TUploadPdfResponse } from '@documenso/remix/server/api/files/files.types';
import { DocumentDataType } from '@prisma/client';
import { base64 } from '@scure/base';
import { match } from 'ts-pattern';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
import { AppError } from '../../errors/app-error'; import { AppError } from '../../errors/app-error';
type File = { type File = {
@@ -58,68 +53,3 @@ export const putPdfFile = async (file: File, options?: PutFileOptions) => {
return result; return result;
}; };
/**
* Uploads a file to the appropriate storage location.
*/
export const putFile = async (file: File, options?: PutFileOptions) => {
const NEXT_PUBLIC_UPLOAD_TRANSPORT = env('NEXT_PUBLIC_UPLOAD_TRANSPORT');
return await match(NEXT_PUBLIC_UPLOAD_TRANSPORT)
.with('s3', async () => putFileInObjectStorage(file, {}, options))
.with('azure-blob', async () => putFileInObjectStorage(file, { 'x-ms-blob-type': 'BlockBlob' }, options))
.otherwise(async () => putFileInDatabase(file));
};
const putFileInDatabase = async (file: File) => {
const contents = await file.arrayBuffer();
const binaryData = new Uint8Array(contents);
const asciiData = base64.encode(binaryData);
return {
type: DocumentDataType.BYTES_64,
data: asciiData,
};
};
const putFileInObjectStorage = async (file: File, extraHeaders: Record<string, string>, options?: PutFileOptions) => {
const getPresignedUrlResponse = await fetch(`${NEXT_PUBLIC_WEBAPP_URL()}/api/files/presigned-post-url`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...buildUploadAuthHeaders(options),
},
body: JSON.stringify({
fileName: file.name,
contentType: file.type,
}),
});
if (!getPresignedUrlResponse.ok) {
throw new Error(`Failed to get presigned post url, failed with status code ${getPresignedUrlResponse.status}`);
}
const { url, key }: TGetPresignedPostUrlResponse = await getPresignedUrlResponse.json();
const body = await file.arrayBuffer();
const response = await fetch(url, {
method: 'PUT',
headers: {
'Content-Type': 'application/octet-stream',
...extraHeaders,
},
body,
});
if (!response.ok) {
throw new Error(`Failed to upload file "${file.name}", failed with status code ${response.status}`);
}
return {
type: DocumentDataType.S3_PATH,
data: key,
};
};
+12
View File
@@ -8,3 +8,15 @@ export const loadLogo = async (file: Uint8Array) => {
content, content,
}; };
}; };
/**
* Validate and sanitise an uploaded branding logo. Re-encoding through `sharp`
* proves the bytes are a real raster image and strips any embedded payloads.
* Throws if the input cannot be parsed as an image.
*/
export const optimiseBrandingLogo = async (input: Buffer | Uint8Array): Promise<Buffer> => {
return await sharp(input)
.resize(512, 512, { fit: 'inside', withoutEnlargement: true })
.png({ quality: 80 })
.toBuffer();
};
@@ -1,11 +1,10 @@
import { ZNameSchema } from '@documenso/lib/types/name';
import { z } from 'zod'; import { z } from 'zod';
import { ZOrganisationNameSchema } from '../organisation-router/create-organisation.types';
export const ZCreateAdminOrganisationRequestSchema = z.object({ export const ZCreateAdminOrganisationRequestSchema = z.object({
ownerUserId: z.number(), ownerUserId: z.number(),
data: z.object({ data: z.object({
name: ZOrganisationNameSchema, name: ZNameSchema,
}), }),
}); });
@@ -1,8 +1,9 @@
import { ZNameSchema } from '@documenso/lib/types/name';
import { ZClaimFlagsSchema, ZRateLimitArraySchema } from '@documenso/lib/types/subscription'; import { ZClaimFlagsSchema, ZRateLimitArraySchema } from '@documenso/lib/types/subscription';
import { z } from 'zod'; import { z } from 'zod';
export const ZCreateSubscriptionClaimRequestSchema = z.object({ export const ZCreateSubscriptionClaimRequestSchema = z.object({
name: z.string().min(1), name: ZNameSchema,
teamCount: z.number().int().min(0), teamCount: z.number().int().min(0),
memberCount: z.number().int().min(0), memberCount: z.number().int().min(0),
envelopeItemCount: z.number().int().min(1), envelopeItemCount: z.number().int().min(1),
@@ -1,4 +1,4 @@
import { ZNameSchema } from '@documenso/lib/constants/auth'; import { ZNameSchema } from '@documenso/lib/types/name';
import { z } from 'zod'; import { z } from 'zod';
export const ZCreateUserRequestSchema = z.object({ export const ZCreateUserRequestSchema = z.object({
@@ -1,9 +1,10 @@
import { ZEmailTransportConfigSchema } from '@documenso/lib/server-only/email/email-transport-config'; import { ZEmailTransportConfigSchema } from '@documenso/lib/server-only/email/email-transport-config';
import { ZNameSchema } from '@documenso/lib/types/name';
import { z } from 'zod'; import { z } from 'zod';
export const ZCreateEmailTransportRequestSchema = z.object({ export const ZCreateEmailTransportRequestSchema = z.object({
name: z.string().min(1), name: ZNameSchema,
fromName: z.string().min(1), fromName: ZNameSchema,
fromAddress: z.string().email(), fromAddress: z.string().email(),
config: ZEmailTransportConfigSchema, config: ZEmailTransportConfigSchema,
}); });
@@ -4,6 +4,7 @@ import {
ZSmtpApiConfigSchema, ZSmtpApiConfigSchema,
ZSmtpAuthConfigSchema, ZSmtpAuthConfigSchema,
} from '@documenso/lib/server-only/email/email-transport-config'; } from '@documenso/lib/server-only/email/email-transport-config';
import { ZNameSchema } from '@documenso/lib/types/name';
import { z } from 'zod'; import { z } from 'zod';
// Reuses the canonical transport config schemas, but relaxes the secret field so // Reuses the canonical transport config schemas, but relaxes the secret field so
@@ -21,8 +22,8 @@ const ZUpdateConfigSchema = z.discriminatedUnion('type', [
export const ZUpdateEmailTransportRequestSchema = z.object({ export const ZUpdateEmailTransportRequestSchema = z.object({
id: z.string(), id: z.string(),
data: z.object({ data: z.object({
name: z.string().min(1), name: ZNameSchema,
fromName: z.string().min(1), fromName: ZNameSchema,
fromAddress: z.string().email(), fromAddress: z.string().email(),
config: ZUpdateConfigSchema, config: ZUpdateConfigSchema,
}), }),
@@ -30,6 +30,7 @@ export const getAdminTeamRoute = adminProcedure
name: true, name: true,
url: true, url: true,
ownerUserId: true, ownerUserId: true,
organisationGlobalSettings: true,
}, },
}, },
teamEmail: true, teamEmail: true,
@@ -1,5 +1,6 @@
import { OrganisationMemberRoleSchema } from '@documenso/prisma/generated/zod/inputTypeSchemas/OrganisationMemberRoleSchema'; import { OrganisationMemberRoleSchema } from '@documenso/prisma/generated/zod/inputTypeSchemas/OrganisationMemberRoleSchema';
import { TeamMemberRoleSchema } from '@documenso/prisma/generated/zod/inputTypeSchemas/TeamMemberRoleSchema'; import { TeamMemberRoleSchema } from '@documenso/prisma/generated/zod/inputTypeSchemas/TeamMemberRoleSchema';
import OrganisationGlobalSettingsSchema from '@documenso/prisma/generated/zod/modelSchema/OrganisationGlobalSettingsSchema';
import OrganisationMemberInviteSchema from '@documenso/prisma/generated/zod/modelSchema/OrganisationMemberInviteSchema'; import OrganisationMemberInviteSchema from '@documenso/prisma/generated/zod/modelSchema/OrganisationMemberInviteSchema';
import OrganisationMemberSchema from '@documenso/prisma/generated/zod/modelSchema/OrganisationMemberSchema'; import OrganisationMemberSchema from '@documenso/prisma/generated/zod/modelSchema/OrganisationMemberSchema';
import OrganisationSchema from '@documenso/prisma/generated/zod/modelSchema/OrganisationSchema'; import OrganisationSchema from '@documenso/prisma/generated/zod/modelSchema/OrganisationSchema';
@@ -19,6 +20,8 @@ export const ZGetAdminTeamResponseSchema = TeamSchema.extend({
name: true, name: true,
url: true, url: true,
ownerUserId: true, ownerUserId: true,
}).extend({
organisationGlobalSettings: OrganisationGlobalSettingsSchema,
}), }),
teamEmail: TeamEmailSchema.nullable(), teamEmail: TeamEmailSchema.nullable(),
teamGlobalSettings: TeamGlobalSettingsSchema.nullable(), teamGlobalSettings: TeamGlobalSettingsSchema.nullable(),
@@ -1,13 +1,13 @@
import { ZNameSchema } from '@documenso/lib/types/name';
import { z } from 'zod'; import { z } from 'zod';
import { ZOrganisationNameSchema } from '../organisation-router/create-organisation.types';
import { ZTeamUrlSchema } from '../team-router/schema'; import { ZTeamUrlSchema } from '../team-router/schema';
import { ZCreateSubscriptionClaimRequestSchema } from './create-subscription-claim.types'; import { ZCreateSubscriptionClaimRequestSchema } from './create-subscription-claim.types';
export const ZUpdateAdminOrganisationRequestSchema = z.object({ export const ZUpdateAdminOrganisationRequestSchema = z.object({
organisationId: z.string(), organisationId: z.string(),
data: z.object({ data: z.object({
name: ZOrganisationNameSchema.optional(), name: ZNameSchema.optional(),
url: ZTeamUrlSchema.optional(), url: ZTeamUrlSchema.optional(),
claims: ZCreateSubscriptionClaimRequestSchema.pick({ claims: ZCreateSubscriptionClaimRequestSchema.pick({
teamCount: true, teamCount: true,
@@ -1,10 +1,11 @@
import { ZNameSchema } from '@documenso/lib/types/name';
import { zEmail } from '@documenso/lib/utils/zod'; import { zEmail } from '@documenso/lib/utils/zod';
import { Role } from '@prisma/client'; import { Role } from '@prisma/client';
import { z } from 'zod'; import { z } from 'zod';
export const ZUpdateUserRequestSchema = z.object({ export const ZUpdateUserRequestSchema = z.object({
id: z.number().min(1), id: z.number().min(1),
name: z.string().nullish(), name: ZNameSchema.nullish(),
email: zEmail().optional(), email: zEmail().optional(),
roles: z.array(z.nativeEnum(Role)).optional(), roles: z.array(z.nativeEnum(Role)).optional(),
}); });
@@ -1,8 +1,9 @@
import { ZNameSchema } from '@documenso/lib/types/name';
import { z } from 'zod'; import { z } from 'zod';
export const ZCreateApiTokenRequestSchema = z.object({ export const ZCreateApiTokenRequestSchema = z.object({
teamId: z.number(), teamId: z.number(),
tokenName: z.string().min(3, { message: 'The token name should be 3 characters or longer' }), tokenName: ZNameSchema,
expirationDate: z.string().nullable(), expirationDate: z.string().nullable(),
}); });
@@ -1,8 +1,9 @@
import { ZNameSchema } from '@documenso/lib/types/name';
import { ZRegistrationResponseJSONSchema } from '@documenso/lib/types/webauthn'; import { ZRegistrationResponseJSONSchema } from '@documenso/lib/types/webauthn';
import { z } from 'zod'; import { z } from 'zod';
export const ZCreatePasskeyRequestSchema = z.object({ export const ZCreatePasskeyRequestSchema = z.object({
passkeyName: z.string().trim().min(1), passkeyName: ZNameSchema,
verificationResponse: ZRegistrationResponseJSONSchema, verificationResponse: ZRegistrationResponseJSONSchema,
}); });
@@ -1,8 +1,9 @@
import { ZNameSchema } from '@documenso/lib/types/name';
import { z } from 'zod'; import { z } from 'zod';
export const ZUpdatePasskeyRequestSchema = z.object({ export const ZUpdatePasskeyRequestSchema = z.object({
passkeyId: z.string().trim().min(1), passkeyId: z.string().trim().min(1),
name: z.string().trim().min(1), name: ZNameSchema,
}); });
export const ZUpdatePasskeyResponseSchema = z.void(); export const ZUpdatePasskeyResponseSchema = z.void();
@@ -1,9 +1,10 @@
import { ZNameSchema } from '@documenso/lib/types/name';
import { zEmail } from '@documenso/lib/utils/zod'; import { zEmail } from '@documenso/lib/utils/zod';
import { z } from 'zod'; import { z } from 'zod';
export const ZCreateOrganisationEmailRequestSchema = z.object({ export const ZCreateOrganisationEmailRequestSchema = z.object({
emailDomainId: z.string(), emailDomainId: z.string(),
emailName: z.string().min(1).max(100), emailName: ZNameSchema,
email: zEmail().toLowerCase(), email: zEmail().toLowerCase(),
// This does not need to be validated to be part of the domain. // This does not need to be validated to be part of the domain.
@@ -124,7 +124,9 @@ export const createEnvelopeRouteCaller = async ({
}); });
// Todo: Embeds - Might need to add this for client-side embeds in the future. // Todo: Embeds - Might need to add this for client-side embeds in the future.
const { cleanedPdf, placeholders } = await extractPdfPlaceholders(normalized); const { cleanedPdf, placeholders } = await extractPdfPlaceholders(normalized, {
fileName: file.name,
});
const { documentData } = await putPdfFileServerSide({ const { documentData } = await putPdfFileServerSide({
name: file.name, name: file.name,
+3 -2
View File
@@ -1,4 +1,5 @@
import { ZFolderTypeSchema } from '@documenso/lib/types/folder-type'; import { ZFolderTypeSchema } from '@documenso/lib/types/folder-type';
import { ZNameSchema } from '@documenso/lib/types/name';
import { ZFindResultResponse, ZFindSearchParamsSchema } from '@documenso/lib/types/search-params'; import { ZFindResultResponse, ZFindSearchParamsSchema } from '@documenso/lib/types/search-params';
import { DocumentVisibility } from '@documenso/prisma/generated/types'; import { DocumentVisibility } from '@documenso/prisma/generated/types';
import FolderSchema from '@documenso/prisma/generated/zod/modelSchema/FolderSchema'; import FolderSchema from '@documenso/prisma/generated/zod/modelSchema/FolderSchema';
@@ -42,7 +43,7 @@ const ZFolderParentIdSchema = z
.describe('The folder ID to place this folder within. Leave empty to place folder at the root level.'); .describe('The folder ID to place this folder within. Leave empty to place folder at the root level.');
export const ZCreateFolderRequestSchema = z.object({ export const ZCreateFolderRequestSchema = z.object({
name: z.string(), name: ZNameSchema,
parentId: ZFolderParentIdSchema.optional(), parentId: ZFolderParentIdSchema.optional(),
type: ZFolderTypeSchema.optional(), type: ZFolderTypeSchema.optional(),
}); });
@@ -52,7 +53,7 @@ export const ZCreateFolderResponseSchema = ZFolderSchema;
export const ZUpdateFolderRequestSchema = z.object({ export const ZUpdateFolderRequestSchema = z.object({
folderId: z.string().describe('The ID of the folder to update'), folderId: z.string().describe('The ID of the folder to update'),
data: z.object({ data: z.object({
name: z.string().optional().describe('The name of the folder'), name: ZNameSchema.optional().describe('The name of the folder'),
parentId: ZFolderParentIdSchema.optional().nullable(), parentId: ZFolderParentIdSchema.optional().nullable(),
visibility: z.nativeEnum(DocumentVisibility).optional().describe('The visibility of the folder'), visibility: z.nativeEnum(DocumentVisibility).optional().describe('The visibility of the folder'),
pinned: z.boolean().optional().describe('Whether the folder should be pinned'), pinned: z.boolean().optional().describe('Whether the folder should be pinned'),
@@ -1,3 +1,4 @@
import { ZNameSchema } from '@documenso/lib/types/name';
import { OrganisationMemberRole } from '@prisma/client'; import { OrganisationMemberRole } from '@prisma/client';
import { z } from 'zod'; import { z } from 'zod';
@@ -14,7 +15,7 @@ import { z } from 'zod';
export const ZCreateOrganisationGroupRequestSchema = z.object({ export const ZCreateOrganisationGroupRequestSchema = z.object({
organisationId: z.string(), organisationId: z.string(),
organisationRole: z.nativeEnum(OrganisationMemberRole), organisationRole: z.nativeEnum(OrganisationMemberRole),
name: z.string().max(100), name: ZNameSchema,
memberIds: z.array(z.string()), memberIds: z.array(z.string()),
}); });
@@ -1,3 +1,4 @@
import { ZNameSchema } from '@documenso/lib/types/name';
import { z } from 'zod'; import { z } from 'zod';
// export const createOrganisationMeta: TrpcOpenApiMeta = { // export const createOrganisationMeta: TrpcOpenApiMeta = {
@@ -10,13 +11,8 @@ import { z } from 'zod';
// }, // },
// }; // };
export const ZOrganisationNameSchema = z
.string()
.min(3, { message: 'Minimum 3 characters' })
.max(50, { message: 'Maximum 50 characters' });
export const ZCreateOrganisationRequestSchema = z.object({ export const ZCreateOrganisationRequestSchema = z.object({
name: ZOrganisationNameSchema, name: ZNameSchema,
priceId: z.string().optional(), priceId: z.string().optional(),
}); });
@@ -20,6 +20,7 @@ import { getOrganisationsRoute } from './get-organisations';
import { leaveOrganisationRoute } from './leave-organisation'; import { leaveOrganisationRoute } from './leave-organisation';
import { resendOrganisationMemberInviteRoute } from './resend-organisation-member-invite'; import { resendOrganisationMemberInviteRoute } from './resend-organisation-member-invite';
import { updateOrganisationRoute } from './update-organisation'; import { updateOrganisationRoute } from './update-organisation';
import { updateOrganisationBrandingLogoRoute } from './update-organisation-branding-logo';
import { updateOrganisationGroupRoute } from './update-organisation-group'; import { updateOrganisationGroupRoute } from './update-organisation-group';
import { updateOrganisationMemberRoute } from './update-organisation-members'; import { updateOrganisationMemberRoute } from './update-organisation-members';
import { updateOrganisationSettingsRoute } from './update-organisation-settings'; import { updateOrganisationSettingsRoute } from './update-organisation-settings';
@@ -55,6 +56,7 @@ export const organisationRouter = router({
}, },
settings: { settings: {
update: updateOrganisationSettingsRoute, update: updateOrganisationSettingsRoute,
updateBrandingLogo: updateOrganisationBrandingLogoRoute,
}, },
internal: { internal: {
getOrganisationSession: getOrganisationSessionRoute, getOrganisationSession: getOrganisationSessionRoute,
@@ -0,0 +1,69 @@
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
import { ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/organisations';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { buildBrandingLogoData } from '@documenso/lib/server-only/branding/store-branding-logo';
import { getOrganisationClaim } from '@documenso/lib/server-only/organisation/get-organisation-claims';
import { buildOrganisationWhereQuery } from '@documenso/lib/utils/organisations';
import { prisma } from '@documenso/prisma';
import { authenticatedProcedure } from '../trpc';
import {
ZUpdateOrganisationBrandingLogoRequestSchema,
ZUpdateOrganisationBrandingLogoResponseSchema,
} from './update-organisation-branding-logo.types';
export const updateOrganisationBrandingLogoRoute = authenticatedProcedure
.input(ZUpdateOrganisationBrandingLogoRequestSchema)
.output(ZUpdateOrganisationBrandingLogoResponseSchema)
.mutation(async ({ ctx, input }) => {
const { user } = ctx;
const { payload, brandingLogo } = input;
const { organisationId } = payload;
ctx.logger.info({
input: {
organisationId,
},
});
const organisation = await prisma.organisation.findFirst({
where: buildOrganisationWhereQuery({
organisationId,
userId: user.id,
roles: ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_ORGANISATION'],
}),
});
if (!organisation) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'You do not have permission to update this organisation.',
});
}
// Setting a logo requires the custom-branding entitlement; clearing it is
// always allowed so a downgraded organisation can still remove its logo.
if (brandingLogo && IS_BILLING_ENABLED()) {
const claim = await getOrganisationClaim({ organisationId });
if (claim.flags?.allowCustomBranding !== true) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'Your plan does not allow custom branding.',
});
}
}
const brandingLogoValue = brandingLogo ? await buildBrandingLogoData(brandingLogo) : '';
await prisma.organisation.update({
where: {
id: organisation.id,
},
data: {
organisationGlobalSettings: {
update: {
brandingLogo: brandingLogoValue,
},
},
},
});
});
@@ -0,0 +1,17 @@
import { z } from 'zod';
import { zfd } from 'zod-form-data';
import { zfdBrandingImageFile, zodFormData } from '../../utils/zod-form-data';
export const ZUpdateOrganisationBrandingLogoRequestSchema = zodFormData({
payload: zfd.json(
z.object({
organisationId: z.string(),
}),
),
brandingLogo: zfdBrandingImageFile().optional(),
});
export const ZUpdateOrganisationBrandingLogoResponseSchema = z.void();
export type TUpdateOrganisationBrandingLogoRequest = z.infer<typeof ZUpdateOrganisationBrandingLogoRequestSchema>;
@@ -1,3 +1,4 @@
import { ZNameSchema } from '@documenso/lib/types/name';
import { OrganisationMemberRole } from '@prisma/client'; import { OrganisationMemberRole } from '@prisma/client';
import { z } from 'zod'; import { z } from 'zod';
@@ -14,7 +15,7 @@ import { z } from 'zod';
export const ZUpdateOrganisationGroupRequestSchema = z.object({ export const ZUpdateOrganisationGroupRequestSchema = z.object({
id: z.string(), id: z.string(),
name: z.string().nullable().optional(), name: ZNameSchema.nullable().optional(),
organisationRole: z.nativeEnum(OrganisationMemberRole).optional(), organisationRole: z.nativeEnum(OrganisationMemberRole).optional(),
memberIds: z.array(z.string()).optional(), memberIds: z.array(z.string()).optional(),
}); });
@@ -44,7 +44,6 @@ export const updateOrganisationSettingsRoute = authenticatedProcedure
// Branding related settings. // Branding related settings.
brandingEnabled, brandingEnabled,
brandingLogo,
brandingUrl, brandingUrl,
brandingCompanyDetails, brandingCompanyDetails,
brandingColors, brandingColors,
@@ -174,7 +173,6 @@ export const updateOrganisationSettingsRoute = authenticatedProcedure
// Branding related settings. // Branding related settings.
brandingEnabled, brandingEnabled,
brandingLogo,
brandingUrl, brandingUrl,
brandingCompanyDetails, brandingCompanyDetails,
brandingColors: normalizedBrandingColors === null ? Prisma.DbNull : normalizedBrandingColors, brandingColors: normalizedBrandingColors === null ? Prisma.DbNull : normalizedBrandingColors,
@@ -32,7 +32,6 @@ export const ZUpdateOrganisationSettingsRequestSchema = z.object({
// Branding related settings. // Branding related settings.
brandingEnabled: z.boolean().optional(), brandingEnabled: z.boolean().optional(),
brandingLogo: z.string().optional(),
brandingUrl: z.string().optional(), brandingUrl: z.string().optional(),
brandingCompanyDetails: z.string().optional(), brandingCompanyDetails: z.string().optional(),
brandingColors: ZCssVarsSchema.nullish(), brandingColors: ZCssVarsSchema.nullish(),
@@ -1,4 +1,4 @@
import { ZNameSchema } from '@documenso/lib/constants/auth'; import { ZNameSchema } from '@documenso/lib/types/name';
import { z } from 'zod'; import { z } from 'zod';
export const ZFindUserSecurityAuditLogsSchema = z.object({ export const ZFindUserSecurityAuditLogsSchema = z.object({
@@ -1,5 +1,6 @@
import { ZNameSchema } from '@documenso/lib/types/name';
import { z } from 'zod'; import { z } from 'zod';
import { ZTeamNameSchema, ZTeamUrlSchema } from './schema'; import { ZTeamUrlSchema } from './schema';
// export const createTeamMeta: TrpcOpenApiMeta = { // export const createTeamMeta: TrpcOpenApiMeta = {
// openapi: { // openapi: {
@@ -13,7 +14,7 @@ import { ZTeamNameSchema, ZTeamUrlSchema } from './schema';
export const ZCreateTeamRequestSchema = z.object({ export const ZCreateTeamRequestSchema = z.object({
organisationId: z.string(), organisationId: z.string(),
teamName: ZTeamNameSchema, teamName: ZNameSchema,
teamUrl: ZTeamUrlSchema, teamUrl: ZTeamUrlSchema,
inheritMembers: z inheritMembers: z
.boolean() .boolean()
@@ -25,6 +25,7 @@ import {
ZUpdateTeamEmailMutationSchema, ZUpdateTeamEmailMutationSchema,
} from './schema'; } from './schema';
import { updateTeamRoute } from './update-team'; import { updateTeamRoute } from './update-team';
import { updateTeamBrandingLogoRoute } from './update-team-branding-logo';
import { updateTeamGroupRoute } from './update-team-group'; import { updateTeamGroupRoute } from './update-team-group';
import { updateTeamMemberRoute } from './update-team-member'; import { updateTeamMemberRoute } from './update-team-member';
import { updateTeamSettingsRoute } from './update-team-settings'; import { updateTeamSettingsRoute } from './update-team-settings';
@@ -50,6 +51,7 @@ export const teamRouter = router({
}, },
settings: { settings: {
update: updateTeamSettingsRoute, update: updateTeamSettingsRoute,
updateBrandingLogo: updateTeamBrandingLogoRoute,
}, },
// Old routes (to be migrated) // Old routes (to be migrated)
+1 -10
View File
@@ -1,5 +1,5 @@
import { URL_PATTERN, ZNameSchema } from '@documenso/lib/constants/auth';
import { PROTECTED_TEAM_URLS } from '@documenso/lib/constants/teams'; import { PROTECTED_TEAM_URLS } from '@documenso/lib/constants/teams';
import { ZNameSchema } from '@documenso/lib/types/name';
import { zEmail } from '@documenso/lib/utils/zod'; import { zEmail } from '@documenso/lib/utils/zod';
import { TeamMemberRole } from '@prisma/client'; import { TeamMemberRole } from '@prisma/client';
import { z } from 'zod'; import { z } from 'zod';
@@ -32,15 +32,6 @@ export const ZTeamUrlSchema = z
message: 'This URL is already in use.', message: 'This URL is already in use.',
}); });
export const ZTeamNameSchema = z
.string()
.trim()
.min(3, { message: 'Team name must be at least 3 characters long.' })
.max(30, { message: 'Team name must not exceed 30 characters.' })
.refine((value) => !URL_PATTERN.test(value), {
message: 'Team name cannot contain URLs.',
});
export const ZCreateTeamEmailVerificationMutationSchema = z.object({ export const ZCreateTeamEmailVerificationMutationSchema = z.object({
teamId: z.number(), teamId: z.number(),
name: ZNameSchema, name: ZNameSchema,
@@ -0,0 +1,69 @@
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
import { TEAM_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/teams';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { buildBrandingLogoData } from '@documenso/lib/server-only/branding/store-branding-logo';
import { getOrganisationClaimByTeamId } from '@documenso/lib/server-only/organisation/get-organisation-claims';
import { buildTeamWhereQuery } from '@documenso/lib/utils/teams';
import { prisma } from '@documenso/prisma';
import { authenticatedProcedure } from '../trpc';
import {
ZUpdateTeamBrandingLogoRequestSchema,
ZUpdateTeamBrandingLogoResponseSchema,
} from './update-team-branding-logo.types';
export const updateTeamBrandingLogoRoute = authenticatedProcedure
.input(ZUpdateTeamBrandingLogoRequestSchema)
.output(ZUpdateTeamBrandingLogoResponseSchema)
.mutation(async ({ ctx, input }) => {
const { user } = ctx;
const { payload, brandingLogo } = input;
const { teamId } = payload;
ctx.logger.info({
input: {
teamId,
},
});
const team = await prisma.team.findFirst({
where: buildTeamWhereQuery({
teamId,
userId: user.id,
roles: TEAM_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_TEAM'],
}),
});
if (!team) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'You do not have permission to update this team.',
});
}
// Setting a logo requires the custom-branding entitlement; clearing it is
// always allowed so a downgraded team can still remove its logo.
if (brandingLogo && IS_BILLING_ENABLED()) {
const claim = await getOrganisationClaimByTeamId({ teamId });
if (claim.flags?.allowCustomBranding !== true) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'Your plan does not allow custom branding.',
});
}
}
const brandingLogoValue = brandingLogo ? await buildBrandingLogoData(brandingLogo) : '';
await prisma.team.update({
where: {
id: team.id,
},
data: {
teamGlobalSettings: {
update: {
brandingLogo: brandingLogoValue,
},
},
},
});
});
@@ -0,0 +1,17 @@
import { z } from 'zod';
import { zfd } from 'zod-form-data';
import { zfdBrandingImageFile, zodFormData } from '../../utils/zod-form-data';
export const ZUpdateTeamBrandingLogoRequestSchema = zodFormData({
payload: zfd.json(
z.object({
teamId: z.number(),
}),
),
brandingLogo: zfdBrandingImageFile().optional(),
});
export const ZUpdateTeamBrandingLogoResponseSchema = z.void();
export type TUpdateTeamBrandingLogoRequest = z.infer<typeof ZUpdateTeamBrandingLogoRequestSchema>;
@@ -42,7 +42,6 @@ export const updateTeamSettingsRoute = authenticatedProcedure
// Branding related settings. // Branding related settings.
brandingEnabled, brandingEnabled,
brandingLogo,
brandingUrl, brandingUrl,
brandingCompanyDetails, brandingCompanyDetails,
brandingColors, brandingColors,
@@ -176,7 +175,6 @@ export const updateTeamSettingsRoute = authenticatedProcedure
// Branding related settings. // Branding related settings.
brandingEnabled, brandingEnabled,
brandingLogo,
brandingUrl, brandingUrl,
brandingCompanyDetails, brandingCompanyDetails,
brandingColors: normalizedBrandingColors === null ? Prisma.DbNull : normalizedBrandingColors, brandingColors: normalizedBrandingColors === null ? Prisma.DbNull : normalizedBrandingColors,
@@ -35,7 +35,6 @@ export const ZUpdateTeamSettingsRequestSchema = z.object({
// Branding related settings. // Branding related settings.
brandingEnabled: z.boolean().nullish(), brandingEnabled: z.boolean().nullish(),
brandingLogo: z.string().nullish(),
brandingUrl: z.string().nullish(), brandingUrl: z.string().nullish(),
brandingCompanyDetails: z.string().nullish(), brandingCompanyDetails: z.string().nullish(),
brandingColors: ZCssVarsSchema.nullish(), brandingColors: ZCssVarsSchema.nullish(),
@@ -1,6 +1,7 @@
import { ZNameSchema } from '@documenso/lib/types/name';
import { z } from 'zod'; import { z } from 'zod';
import { ZTeamNameSchema, ZTeamUrlSchema } from './schema'; import { ZTeamUrlSchema } from './schema';
export const MAX_PROFILE_BIO_LENGTH = 256; export const MAX_PROFILE_BIO_LENGTH = 256;
@@ -19,7 +20,7 @@ export const MAX_PROFILE_BIO_LENGTH = 256;
export const ZUpdateTeamRequestSchema = z.object({ export const ZUpdateTeamRequestSchema = z.object({
teamId: z.number(), teamId: z.number(),
data: z.object({ data: z.object({
name: ZTeamNameSchema.optional(), name: ZNameSchema.optional(),
url: ZTeamUrlSchema.optional(), url: ZTeamUrlSchema.optional(),
profileBio: z profileBio: z
.string() .string()
@@ -1,4 +1,5 @@
import { isPrivateUrl } from '@documenso/lib/server-only/webhooks/is-private-url'; import { isPrivateUrl } from '@documenso/lib/server-only/webhooks/is-private-url';
import { URL_PATTERN } from '@documenso/lib/types/name';
import { WebhookTriggerEvents } from '@prisma/client'; import { WebhookTriggerEvents } from '@prisma/client';
import { z } from 'zod'; import { z } from 'zod';
@@ -7,6 +8,13 @@ export const ZWebhookUrlSchema = z
.url() .url()
.refine((url) => !isPrivateUrl(url), { .refine((url) => !isPrivateUrl(url), {
message: 'Webhook URL cannot point to a private or loopback address', message: 'Webhook URL cannot point to a private or loopback address',
})
/*
* Without this, values like "foo: bar" would be valid URLs.
* Keep the same error message as the zod url() validator.
*/
.refine((value) => URL_PATTERN.test(value), {
message: 'Invalid url',
}); });
export const ZCreateWebhookRequestSchema = z.object({ export const ZCreateWebhookRequestSchema = z.object({
+20
View File
@@ -1,4 +1,9 @@
import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app'; import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app';
import {
BRANDING_LOGO_ALLOWED_TYPES,
BRANDING_LOGO_MAX_SIZE_BYTES,
BRANDING_LOGO_MAX_SIZE_MB,
} from '@documenso/lib/constants/branding';
import { megabytesToBytes } from '@documenso/lib/universal/unit-convertions'; import { megabytesToBytes } from '@documenso/lib/universal/unit-convertions';
import type { ZodRawShape } from 'zod'; import type { ZodRawShape } from 'zod';
import z from 'zod'; import z from 'zod';
@@ -17,6 +22,21 @@ export const zfdFile = () => {
}); });
}; };
/**
* A `zfd.file()` schema constrained to branding-logo images: size-limited and
* restricted to a MIME allowlist. Use for server-side branding logo uploads.
*/
export const zfdBrandingImageFile = () => {
return zfd
.file()
.refine((file) => file.size <= BRANDING_LOGO_MAX_SIZE_BYTES, {
message: `File cannot be larger than ${BRANDING_LOGO_MAX_SIZE_MB}MB`,
})
.refine((file) => BRANDING_LOGO_ALLOWED_TYPES.includes(file.type), {
message: 'File must be a JPG, PNG, or WebP image',
});
};
/** /**
* This helper takes the place of the `z.object` at the root of your schema. * This helper takes the place of the `z.object` at the root of your schema.
* It wraps your schema in a `z.preprocess` that extracts all the data out of a `FormData` * It wraps your schema in a `z.preprocess` that extracts all the data out of a `FormData`