Compare commits

..

7 Commits

Author SHA1 Message Date
ephraimduncan 847244ccda test(e2e): assert audit-log page growth instead of exact count 2026-07-02 12:25:36 +00:00
ephraimduncan 92f3b3ffc4 test(e2e): sign v2 envelopes via konva canvas in audit-log spec 2026-07-02 12:01:24 +00:00
ephraimduncan a395c9b57b Merge remote-tracking branch 'origin/main' into pr-2847
# Conflicts:
#	packages/lib/server-only/template/create-document-from-template.ts
2026-07-02 06:48:23 +00:00
ephraimduncan 179902a96f chore: trim verbose audit log backfill migration comment 2026-06-16 11:46:54 +00:00
ephraimduncan 77889270b8 fix: address per-document audit log review feedback
- backfill DocumentMeta.includeAuditLog from the effective team/org
  setting so documents created before the column keep embedding audit
  logs after deploy
- default includeAuditLog in the webhook payload schema so resending
  historical WebhookCall payloads stays backward-compatible
- allow audit-log embedding to be changed after a document is sent
  (drop the server guards and the editor checkbox lock)
- restore the commented change-detection block in updateEnvelope
2026-06-16 11:40:36 +00:00
ephraimduncan ecc99b8e4f fix: clarify audit log PDF setting 2026-05-21 12:09:19 +00:00
ephraimduncan 89c804a635 feat: configure audit log embedding per document 2026-05-21 03:42:41 +00:00
116 changed files with 2236 additions and 4789 deletions
@@ -1,222 +0,0 @@
---
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,8 +76,6 @@ The Enterprise Edition is required when you:
4. Restart your Documenso instance
5. Verify the license is active in the **Admin Panel** under the **Stats** section
See [Apply Your License Key](/docs/self-hosting/configuration/license) for the full walkthrough, including how to enable individual features once licensed.
</Accordion>
</Accordions>
@@ -199,7 +197,7 @@ See [Support](/docs/policies/support) for complete support options.
1. Sign the Enterprise license agreement
2. Receive license key and access credentials
3. Deploy using [self-hosting guides](/docs/self-hosting) or access Documenso Cloud
4. Apply the key — see [Apply Your License Key](/docs/self-hosting/configuration/license) — and configure Enterprise features with support assistance
4. Configure Enterprise features with support assistance
</Step>
<Step>
@@ -240,7 +238,6 @@ See [Support](/docs/policies/support) for complete support options.
## Related
- [Apply Your License Key](/docs/self-hosting/configuration/license) - Step-by-step license activation
- [Community Edition](/docs/policies/community-edition) - AGPL-3.0 open-source license
- [Licenses](/docs/policies/licenses) - Complete licensing overview and FAQ
- [Support](/docs/policies/support) - Support channels and response times
@@ -443,11 +443,11 @@ Telemetry collects only: app version, installation ID, and node ID. No personal
## Enterprise Features
These variables require an active [Enterprise Edition](/docs/policies/enterprise-edition) license. Obtain a license key from [license.documenso.com](https://license.documenso.com) and set it below to unlock enterprise features such as SSO, embed editor, and 21 CFR Part 11 compliance. See [Apply Your License Key](/docs/self-hosting/configuration/license) for step-by-step setup.
These variables require an active [Enterprise Edition](/docs/policies/enterprise-edition) license. Obtain a license key from [license.documenso.com](https://license.documenso.com) and set it below to unlock enterprise features such as SSO, embed editor, and 21 CFR Part 11 compliance.
| Variable | Description |
| ------------------------------------ | ------------------------------------------------ |
| `NEXT_PRIVATE_DOCUMENSO_LICENSE_KEY` | License key for enterprise features — see [Apply Your License Key](/docs/self-hosting/configuration/license) for how to apply it |
| `NEXT_PRIVATE_DOCUMENSO_LICENSE_KEY` | License key for enterprise features |
| `NEXT_PRIVATE_STRIPE_API_KEY` | Stripe API key for billing |
| `NEXT_PRIVATE_STRIPE_WEBHOOK_SECRET` | Stripe webhook secret |
| `NEXT_PRIVATE_SES_ACCESS_KEY_ID` | AWS SES access key for email domain verification |
@@ -1,107 +0,0 @@
---
title: Apply Your License Key
description: Activate your Enterprise license key to unlock enterprise features on your self-hosted instance.
---
import { Accordion, Accordions } from 'fumadocs-ui/components/accordion';
import { Callout } from 'fumadocs-ui/components/callout';
import { Tab, Tabs } from 'fumadocs-ui/components/tabs';
A license key activates the Enterprise features available to your self-hosted instance, such as CSC signing, SSO, embed white-labelling, and 21 CFR Part 11 compliance.
<Callout type="info">
The license key applies to your **whole instance**, not an individual user account. There's one
key per deployment.
</Callout>
## Prerequisites
- An active Enterprise license key — contact [sales](https://documen.so/enterprise) to set up an
Enterprise subscription, then copy your key from [license.documenso.com](https://license.documenso.com).
See [Enterprise Edition](/docs/policies/enterprise-edition) for details.
- A running self-hosted Documenso instance that you're able to restart
## Step 1: Set the environment variable
Set `NEXT_PRIVATE_DOCUMENSO_LICENSE_KEY` to your license key.
<Tabs items={['Docker Compose', 'docker run', '.env']}>
<Tab value="Docker Compose">
Add the variable to your `.env` file (or directly under `environment:` in `compose.yml`):
```bash
NEXT_PRIVATE_DOCUMENSO_LICENSE_KEY=your-license-key-here
```
Then apply it:
```bash
docker compose up -d
```
</Tab>
<Tab value="docker run">
```bash
docker run -d \
--name documenso \
-e NEXT_PRIVATE_DOCUMENSO_LICENSE_KEY=your-license-key-here \
documenso/documenso:latest
```
</Tab>
<Tab value=".env">
If you're running Documenso directly (not in a container), add the variable to your `.env` file:
```bash
NEXT_PRIVATE_DOCUMENSO_LICENSE_KEY=your-license-key-here
```
</Tab>
</Tabs>
## Step 2: Restart the instance
The license key is only read once, at process startup. Setting the variable in a running container or shell has no effect until the process restarts.
```bash
# Docker Compose
docker compose restart documenso
# Docker
docker restart documenso
```
On startup, Documenso validates the key against the Documenso license server and caches the result locally for future startups, so a brief license-server outage won't lock you out.
## What the license enables
A valid license doesn't turn every enterprise feature on everywhere — activation depends on the feature:
- **CSC signing** activates instance-wide automatically once the license is active and CSC transport is configured. See [CSC / QES Signing](/docs/self-hosting/configuration/signing-certificate/csc-qes) for the full setup.
- **SSO, embed white-labelling, 21 CFR Part 11, and similar** are provisioned per organisation. Follow each feature's own guide to configure it once the license is active.
## Troubleshooting
<Accordions type="multiple">
<Accordion title="Enterprise features are still unavailable after applying the key">
- Confirm the key is present in the environment the running process actually reads — `docker
exec` into the container and check `env | grep LICENSE` if unsure.
- Confirm the instance was fully restarted after the variable was set, not just reloaded.
- Re-copy the key to rule out truncation or accidental whitespace.
</Accordion>
<Accordion title="A specific feature still isn't working">
Instance-wide features (like CSC signing) also need their own configuration — an active license
alone isn't enough. Check that feature's guide to confirm the required settings are in place.
Per-organisation features additionally need to be provisioned for the organisation that's using
them.
</Accordion>
</Accordions>
## See Also
- [Environment Variables](/docs/self-hosting/configuration/environment) - Complete configuration reference
- [Enterprise Edition](/docs/policies/enterprise-edition) - What's included and how to purchase a license
- [CSC / QES Signing](/docs/self-hosting/configuration/signing-certificate/csc-qes) - Enable CSC-based signing
@@ -2,7 +2,6 @@
"title": "Configuration",
"pages": [
"environment",
"license",
"database",
"email",
"storage",
@@ -49,7 +49,7 @@ The callback URL is fixed — Documenso derives it from `NEXT_PUBLIC_WEBAPP_URL`
### Enterprise Edition license
CSC mode is gated by the `instanceCscSigning` license flag. Without a valid Enterprise license, the transport refuses to start (`CSC_UNLICENSED`). See [Apply Your License Key](/docs/self-hosting/configuration/license) to activate one.
CSC mode is gated by the `instanceCscSigning` license flag. Without a valid Enterprise license, the transport refuses to start (`CSC_UNLICENSED`).
</Step>
<Step>
@@ -141,7 +141,7 @@ See the [Quick Start guide](/docs/self-hosting/getting-started/quick-start) for
Self-hosted Documenso includes full core functionality under the AGPL-3.0 license. If you need enterprise features such as SSO, embed editor white label, or 21 CFR Part 11 compliance, you can activate them with a license key.
See [Enterprise Edition](/docs/policies/enterprise-edition) for details and [Licenses](/docs/policies/licenses) for a comparison. Already have a key? See [Apply Your License Key](/docs/self-hosting/configuration/license).
See [Enterprise Edition](/docs/policies/enterprise-edition) for details and [Licenses](/docs/policies/licenses) for a comparison.
---
@@ -109,37 +109,6 @@ You can customize fields by adding options after the recipient identifier:
| `maxValue` | Number | Maximum allowed value |
| `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
```
@@ -147,10 +116,6 @@ If an option needs a literal delimiter, escape it with a backslash:
{{number, r1, minValue=0, maxValue=100, value=50}}
{{name, r1, fontSize=14}}
{{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">
@@ -1,4 +1,3 @@
import { ZNameSchema } from '@documenso/lib/types/name';
import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import {
@@ -24,7 +23,7 @@ import { useParams } from 'react-router';
import { z } from 'zod';
const ZCreateFolderFormSchema = z.object({
name: ZNameSchema,
name: z.string().min(1, { message: 'Folder name is required' }),
});
type TCreateFolderFormSchema = z.infer<typeof ZCreateFolderFormSchema>;
@@ -66,7 +65,7 @@ export const FolderCreateDialog = ({ type, trigger, parentFolderId, ...props }:
toast({
description: t`Folder created successfully`,
});
} catch (_err) {
} catch (err) {
toast({
title: t`Failed to create folder`,
description: t`An unknown error occurred while creating the folder.`,
@@ -1,6 +1,5 @@
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { DocumentVisibility } from '@documenso/lib/types/document-visibility';
import { ZNameSchema } from '@documenso/lib/types/name';
import { trpc } from '@documenso/trpc/react';
import type { TFolderWithSubfolders } from '@documenso/trpc/server/folder-router/schema';
import { Button } from '@documenso/ui/primitives/button';
@@ -24,6 +23,8 @@ import { useEffect } from 'react';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { useOptionalCurrentTeam } from '~/providers/team';
export type FolderUpdateDialogProps = {
folder: TFolderWithSubfolders | null;
isOpen: boolean;
@@ -31,7 +32,7 @@ export type FolderUpdateDialogProps = {
} & Omit<DialogPrimitive.DialogProps, 'children'>;
export const ZUpdateFolderFormSchema = z.object({
name: ZNameSchema,
name: z.string().min(1),
visibility: z.nativeEnum(DocumentVisibility).optional(),
});
@@ -39,6 +40,7 @@ export type TUpdateFolderFormSchema = z.infer<typeof ZUpdateFolderFormSchema>;
export const FolderUpdateDialog = ({ folder, isOpen, onOpenChange }: FolderUpdateDialogProps) => {
const { t } = useLingui();
const team = useOptionalCurrentTeam();
const { toast } = useToast();
const { mutateAsync: updateFolder } = trpc.folder.updateFolder.useMutation();
@@ -1,6 +1,5 @@
import { MAXIMUM_PASSKEYS } from '@documenso/lib/constants/auth';
import { AppError } from '@documenso/lib/errors/app-error';
import { ZNameSchema } from '@documenso/lib/types/name';
import { trpc } from '@documenso/trpc/react';
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
@@ -26,13 +25,14 @@ import { useForm } from 'react-hook-form';
import { match } from 'ts-pattern';
import { UAParser } from 'ua-parser-js';
import { z } from 'zod';
export type PasskeyCreateDialogProps = {
trigger?: React.ReactNode;
onSuccess?: () => void;
} & Omit<DialogPrimitive.DialogProps, 'children'>;
const ZCreatePasskeyFormSchema = z.object({
passkeyName: ZNameSchema,
passkeyName: z.string().min(3),
});
type TCreatePasskeyFormSchema = z.infer<typeof ZCreatePasskeyFormSchema>;
@@ -1,5 +1,4 @@
import { trpc } from '@documenso/trpc/react';
import { ZUpdateTeamEmailMutationSchema } from '@documenso/trpc/server/team-router/schema';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
@@ -20,16 +19,16 @@ import type * as DialogPrimitive from '@radix-ui/react-dialog';
import { useEffect, useState } from 'react';
import { useForm } from 'react-hook-form';
import { useRevalidator } from 'react-router';
import type { z } from 'zod';
import { z } from 'zod';
export type TeamEmailUpdateDialogProps = {
teamEmail: TeamEmail;
trigger?: React.ReactNode;
} & Omit<DialogPrimitive.DialogProps, 'children'>;
const ZUpdateTeamEmailFormSchema = ZUpdateTeamEmailMutationSchema.pick({
data: true,
}).shape.data;
const ZUpdateTeamEmailFormSchema = z.object({
name: z.string().trim().min(1, { message: 'Please enter a valid name.' }),
});
type TUpdateTeamEmailFormSchema = z.infer<typeof ZUpdateTeamEmailFormSchema>;
@@ -45,7 +44,6 @@ export const TeamEmailUpdateDialog = ({ teamEmail, trigger, ...props }: TeamEmai
defaultValues: {
name: teamEmail.name,
},
mode: 'onSubmit',
});
const { mutateAsync: updateTeamEmail } = trpc.team.email.update.useMutation();
@@ -1,10 +1,5 @@
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
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 { ZCssVarsSchema } from '@documenso/lib/types/css-vars';
import { cn } from '@documenso/ui/lib/utils';
@@ -28,15 +23,15 @@ import { useCspNonce } from '~/utils/nonce';
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({
brandingEnabled: z.boolean().nullable(),
brandingLogo: z
.instanceof(File)
.refine(
(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')
.refine((file) => file.size <= MAX_FILE_SIZE, 'File size must be less than 5MB')
.refine((file) => ACCEPTED_FILE_TYPES.includes(file.type), 'Only .jpg, .png, and .webp files are accepted')
.nullish(),
brandingUrl: z.string().url().optional().or(z.literal('')),
brandingCompanyDetails: z.string().max(500).optional(),
@@ -250,7 +245,7 @@ export function BrandingPreferencesForm({
<FormControl className="relative">
<Input
type="file"
accept={BRANDING_LOGO_ALLOWED_TYPES.join(',')}
accept={ACCEPTED_FILE_TYPES.join(',')}
disabled={!isBrandingEnabled}
onChange={(e) => {
const file = e.target.files?.[0];
@@ -1,4 +1,3 @@
import { ZNameSchema } from '@documenso/lib/types/name';
import {
Form,
FormControl,
@@ -16,8 +15,8 @@ import { useForm } from 'react-hook-form';
import { z } from 'zod';
const ZEmailTransportFormSchema = z.object({
name: ZNameSchema,
fromName: ZNameSchema,
name: z.string().min(1),
fromName: z.string().min(1),
fromAddress: z.string().email(),
type: z.enum(['SMTP_AUTH', 'SMTP_API', 'RESEND', 'MAILCHANNELS']),
host: z.string().optional(),
+1 -1
View File
@@ -1,5 +1,5 @@
import { useSession } from '@documenso/lib/client-only/providers/session';
import { ZNameSchema } from '@documenso/lib/types/name';
import { ZNameSchema } from '@documenso/lib/constants/auth';
import { trpc } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils';
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 { authClient } from '@documenso/auth/client';
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
import { ZNameSchema } from '@documenso/lib/constants/auth';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { ZNameSchema } from '@documenso/lib/types/name';
import { env } from '@documenso/lib/utils/env';
import { zEmail } from '@documenso/lib/utils/zod';
import { ZPasswordSchema } from '@documenso/trpc/server/auth-router/schema';
@@ -5,7 +5,6 @@ import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import type { OrganisationGlobalSettings, TeamGlobalSettings } from '@prisma/client';
import type { ReactNode } from 'react';
import { DetailsCard, DetailsValue } from '~/components/general/admin-details';
@@ -26,72 +25,38 @@ const emailSettingsKeys = Object.keys(EMAIL_SETTINGS_LABELS) as (keyof TDocument
type AdminGlobalSettingsSectionProps = {
settings: TeamGlobalSettings | OrganisationGlobalSettings | null;
isTeam?: boolean;
/** When viewing a team, the parent organisation settings the team inherits from. */
inheritedSettings?: OrganisationGlobalSettings | null;
};
export const AdminGlobalSettingsSection = ({
settings,
isTeam = false,
inheritedSettings,
}: AdminGlobalSettingsSectionProps) => {
export const AdminGlobalSettingsSection = ({ settings, isTeam = false }: AdminGlobalSettingsSectionProps) => {
const { _ } = useLingui();
const notSetLabel = isTeam ? <Trans>Inherited</Trans> : <Trans>Not set</Trans>;
if (!settings) {
return null;
}
const notSet = <Trans>Not set</Trans>;
const inheritedValue = (value: ReactNode) => {
if (!isTeam || value === null) {
return notSet;
const textValue = (value: string | null | undefined) => {
if (value === null || value === undefined) {
return notSetLabel;
}
return (
<span className="flex items-center gap-1.5">
<span className="text-muted-foreground">
<Trans>Inherited</Trans>:
</span>
<span>{value}</span>
</span>
);
return value;
};
const textValue = (value: string | null | undefined, inherited?: string | null) => {
if (value && value.trim() !== '') {
return value;
const brandingTextValue = (value: string | null | undefined) => {
if (value === null || value === undefined || value.trim() === '') {
return notSetLabel;
}
if (inherited && inherited.trim() !== '') {
return inheritedValue(inherited);
}
return notSet;
return value;
};
const booleanLabel = (value: boolean) => (value ? <Trans>Enabled</Trans> : <Trans>Disabled</Trans>);
const booleanValue = (value: boolean | null | undefined, inherited?: boolean | null) => {
if (value !== null && value !== undefined) {
return booleanLabel(value);
const booleanValue = (value: boolean | null | undefined) => {
if (value === null || value === undefined) {
return notSetLabel;
}
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));
return value ? <Trans>Enabled</Trans> : <Trans>Disabled</Trans>;
};
const parsedEmailSettings = ZDocumentEmailSettingsSchema.safeParse(settings.emailDocumentSettings);
@@ -100,82 +65,70 @@ export const AdminGlobalSettingsSection = ({
<div className="grid grid-cols-1 gap-3 text-sm sm:grid-cols-2 lg:grid-cols-3">
<DetailsCard label={<Trans>Document visibility</Trans>}>
<DetailsValue>
{visibilityValue(settings.documentVisibility, inheritedSettings?.documentVisibility)}
{settings.documentVisibility != null
? _(DOCUMENT_VISIBILITY[settings.documentVisibility].value)
: notSetLabel}
</DetailsValue>
</DetailsCard>
<DetailsCard label={<Trans>Document language</Trans>}>
<DetailsValue>{textValue(settings.documentLanguage, inheritedSettings?.documentLanguage)}</DetailsValue>
<DetailsValue>{textValue(settings.documentLanguage)}</DetailsValue>
</DetailsCard>
<DetailsCard label={<Trans>Document timezone</Trans>}>
<DetailsValue>{textValue(settings.documentTimezone, inheritedSettings?.documentTimezone)}</DetailsValue>
<DetailsValue>{textValue(settings.documentTimezone)}</DetailsValue>
</DetailsCard>
<DetailsCard label={<Trans>Date format</Trans>}>
<DetailsValue>{textValue(settings.documentDateFormat, inheritedSettings?.documentDateFormat)}</DetailsValue>
<DetailsValue>{textValue(settings.documentDateFormat)}</DetailsValue>
</DetailsCard>
<DetailsCard label={<Trans>Include sender details</Trans>}>
<DetailsValue>
{booleanValue(settings.includeSenderDetails, inheritedSettings?.includeSenderDetails)}
</DetailsValue>
<DetailsValue>{booleanValue(settings.includeSenderDetails)}</DetailsValue>
</DetailsCard>
<DetailsCard label={<Trans>Include signing certificate</Trans>}>
<DetailsValue>
{booleanValue(settings.includeSigningCertificate, inheritedSettings?.includeSigningCertificate)}
</DetailsValue>
<DetailsValue>{booleanValue(settings.includeSigningCertificate)}</DetailsValue>
</DetailsCard>
<DetailsCard label={<Trans>Include audit log</Trans>}>
<DetailsValue>{booleanValue(settings.includeAuditLog, inheritedSettings?.includeAuditLog)}</DetailsValue>
<DetailsValue>{booleanValue(settings.includeAuditLog)}</DetailsValue>
</DetailsCard>
<DetailsCard label={<Trans>Delegate document ownership</Trans>}>
<DetailsValue>
{booleanValue(settings.delegateDocumentOwnership, inheritedSettings?.delegateDocumentOwnership)}
</DetailsValue>
<DetailsValue>{booleanValue(settings.delegateDocumentOwnership)}</DetailsValue>
</DetailsCard>
<DetailsCard label={<Trans>Typed signature</Trans>}>
<DetailsValue>
{booleanValue(settings.typedSignatureEnabled, inheritedSettings?.typedSignatureEnabled)}
</DetailsValue>
<DetailsValue>{booleanValue(settings.typedSignatureEnabled)}</DetailsValue>
</DetailsCard>
<DetailsCard label={<Trans>Upload signature</Trans>}>
<DetailsValue>
{booleanValue(settings.uploadSignatureEnabled, inheritedSettings?.uploadSignatureEnabled)}
</DetailsValue>
<DetailsValue>{booleanValue(settings.uploadSignatureEnabled)}</DetailsValue>
</DetailsCard>
<DetailsCard label={<Trans>Draw signature</Trans>}>
<DetailsValue>
{booleanValue(settings.drawSignatureEnabled, inheritedSettings?.drawSignatureEnabled)}
</DetailsValue>
<DetailsValue>{booleanValue(settings.drawSignatureEnabled)}</DetailsValue>
</DetailsCard>
<DetailsCard label={<Trans>Branding</Trans>}>
<DetailsValue>{booleanValue(settings.brandingEnabled, inheritedSettings?.brandingEnabled)}</DetailsValue>
<DetailsValue>{booleanValue(settings.brandingEnabled)}</DetailsValue>
</DetailsCard>
<DetailsCard label={<Trans>Branding logo</Trans>}>
<DetailsValue>{textValue(settings.brandingLogo, inheritedSettings?.brandingLogo)}</DetailsValue>
<DetailsValue>{brandingTextValue(settings.brandingLogo)}</DetailsValue>
</DetailsCard>
<DetailsCard label={<Trans>Branding URL</Trans>}>
<DetailsValue>{textValue(settings.brandingUrl, inheritedSettings?.brandingUrl)}</DetailsValue>
<DetailsValue>{brandingTextValue(settings.brandingUrl)}</DetailsValue>
</DetailsCard>
<DetailsCard label={<Trans>Branding company details</Trans>}>
<DetailsValue>
{textValue(settings.brandingCompanyDetails, inheritedSettings?.brandingCompanyDetails)}
</DetailsValue>
<DetailsValue>{brandingTextValue(settings.brandingCompanyDetails)}</DetailsValue>
</DetailsCard>
<DetailsCard label={<Trans>Email reply-to</Trans>}>
<DetailsValue>{textValue(settings.emailReplyTo, inheritedSettings?.emailReplyTo)}</DetailsValue>
<DetailsValue>{textValue(settings.emailReplyTo)}</DetailsValue>
</DetailsCard>
{isTeam && parsedEmailSettings.success && (
@@ -192,7 +145,7 @@ export const AdminGlobalSettingsSection = ({
)}
<DetailsCard label={<Trans>AI features</Trans>}>
<DetailsValue>{booleanValue(settings.aiFeaturesEnabled, inheritedSettings?.aiFeaturesEnabled)}</DetailsValue>
<DetailsValue>{booleanValue(settings.aiFeaturesEnabled)}</DetailsValue>
</DetailsCard>
</div>
);
@@ -1,7 +1,6 @@
import { authClient } from '@documenso/auth/client';
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
import { AppError } from '@documenso/lib/errors/app-error';
import { ZNameSchema } from '@documenso/lib/types/name';
import { env } from '@documenso/lib/utils/env';
import { zEmail } from '@documenso/lib/utils/zod';
import { ZPasswordSchema } from '@documenso/trpc/server/auth-router/schema';
@@ -20,6 +19,7 @@ import { useRef } from 'react';
import { useForm } from 'react-hook-form';
import { useNavigate } from 'react-router';
import { z } from 'zod';
import { SIGNUP_ERROR_MESSAGES } from '~/components/forms/signup';
export type ClaimAccountProps = {
@@ -30,7 +30,7 @@ export type ClaimAccountProps = {
export const ZClaimAccountFormSchema = z
.object({
name: ZNameSchema,
name: z.string().trim().min(1, { message: msg`Please enter a valid name.`.id }),
email: zEmail().min(1),
password: ZPasswordSchema,
})
@@ -1,4 +1,11 @@
import { FormControl, FormField, FormItem, FormLabel, FormMessage } from '@documenso/ui/primitives/form/form';
import {
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { Trans, useLingui } from '@lingui/react/macro';
import type { ReactNode } from 'react';
@@ -6,13 +13,6 @@ import type { Control, FieldValues, Path } from 'react-hook-form';
import { RateLimitArrayInput } from './rate-limit-array-input';
/**
* The rate-limit editor renders its own per-row inline errors, but a submit
* attempt can still surface array-level Zod issues (e.g. a committed duplicate
* window). Rendering the field's message here guarantees the form never fails
* silently when those errors are not tied to a row the editor is showing.
*/
type ClaimLimitFieldsProps<T extends FieldValues> = {
control: Control<T>;
/** e.g. '' for the claim form, 'claims.' for the org admin form. */
@@ -20,12 +20,6 @@ type ClaimLimitFieldsProps<T extends FieldValues> = {
disabled?: boolean;
};
type LimitGroup = {
title: ReactNode;
quotaKey: string;
rateLimitKey: string;
};
export const ClaimLimitFields = <T extends FieldValues>({
control,
prefix = '',
@@ -36,33 +30,13 @@ export const ClaimLimitFields = <T extends FieldValues>({
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const name = (key: string) => `${prefix}${key}` as Path<T>;
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) => (
const renderQuotaField = (key: string, label: ReactNode, description: ReactNode) => (
<FormField
control={control}
name={name(group.quotaKey)}
name={name(key)}
render={({ field }) => (
<FormItem>
<FormLabel className="text-muted-foreground text-xs">
<Trans>Monthly quota</Trans>
</FormLabel>
<FormLabel>{label}</FormLabel>
<FormControl>
<Input
type="number"
@@ -73,18 +47,20 @@ export const ClaimLimitFields = <T extends FieldValues>({
onChange={(e) => field.onChange(e.target.value === '' ? null : parseInt(e.target.value, 10))}
/>
</FormControl>
<FormDescription>{description}</FormDescription>
<FormMessage />
</FormItem>
)}
/>
);
const renderRateLimitField = (group: LimitGroup) => (
const renderRateLimitField = (key: string, label: ReactNode) => (
<FormField
control={control}
name={name(group.rateLimitKey)}
name={name(key)}
render={({ field }) => (
<FormItem>
<FormLabel>{label}</FormLabel>
<FormControl>
<RateLimitArrayInput value={field.value ?? []} onChange={field.onChange} disabled={disabled} />
</FormControl>
@@ -95,30 +71,27 @@ export const ClaimLimitFields = <T extends FieldValues>({
);
return (
<div className="space-y-3">
<div>
<h3 className="font-semibold text-base">
<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>
<div className="space-y-4 rounded-md border p-4">
<FormLabel>
<Trans>Limits</Trans>
</FormLabel>
<div className="overflow-hidden rounded-lg border">
<div className="grid grid-cols-1 divide-y divide-border md:grid-cols-3 md:divide-x md:divide-y-0">
{limitGroups.map((group) => (
<div key={group.quotaKey} className="space-y-4 p-4">
<h4 className="font-semibold text-sm">{group.title}</h4>
{renderQuotaField(
'documentQuota',
<Trans>Monthly document quota</Trans>,
<Trans>Empty = Unlimited, 0 = Blocked</Trans>,
)}
{renderRateLimitField('documentRateLimits', <Trans>Document rate limits</Trans>)}
{renderQuotaField(group)}
{renderRateLimitField(group)}
</div>
))}
</div>
</div>
{renderQuotaField(
'emailQuota',
<Trans>Monthly email quota</Trans>,
<Trans>Empty = Unlimited, 0 = Blocked</Trans>,
)}
{renderRateLimitField('emailRateLimits', <Trans>Email rate limits</Trans>)}
{renderQuotaField('apiQuota', <Trans>Monthly API quota</Trans>, <Trans>Empty = Unlimited, 0 = Blocked</Trans>)}
{renderRateLimitField('apiRateLimits', <Trans>API rate limits</Trans>)}
</div>
);
};
@@ -42,6 +42,7 @@ import { cn } from '@documenso/ui/lib/utils';
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
import { CardDescription, CardHeader, CardTitle } from '@documenso/ui/primitives/card';
import { Checkbox } from '@documenso/ui/primitives/checkbox';
import { Combobox } from '@documenso/ui/primitives/combobox';
import {
Dialog,
@@ -51,7 +52,15 @@ import {
DialogTitle,
DialogTrigger,
} from '@documenso/ui/primitives/dialog';
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@documenso/ui/primitives/form/form';
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { MultiSelectCombobox } from '@documenso/ui/primitives/multi-select-combobox';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@documenso/ui/primitives/select';
@@ -94,6 +103,7 @@ export const ZAddSettingsFormSchema = z.object({
timezone: ZDocumentMetaTimezoneSchema.default(DEFAULT_DOCUMENT_TIME_ZONE),
dateFormat: ZDocumentMetaDateFormatSchema.default(DEFAULT_DOCUMENT_DATE_FORMAT),
distributionMethod: z.nativeEnum(DocumentDistributionMethod).optional().default(DocumentDistributionMethod.EMAIL),
includeAuditLog: z.boolean().default(false),
redirectUrl: z
.string()
.optional()
@@ -194,6 +204,7 @@ export const EnvelopeEditorSettingsDialog = ({ trigger, ...props }: EnvelopeEdit
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
dateFormat: (envelope.documentMeta.dateFormat ?? DEFAULT_DOCUMENT_DATE_FORMAT) as TDocumentMetaDateFormat,
distributionMethod: envelope.documentMeta.distributionMethod || DocumentDistributionMethod.EMAIL,
includeAuditLog: envelope.documentMeta.includeAuditLog,
redirectUrl: envelope.documentMeta.redirectUrl ?? '',
language: envelope.documentMeta.language ?? 'en',
emailId: envelope.documentMeta.emailId ?? null,
@@ -251,6 +262,7 @@ export const EnvelopeEditorSettingsDialog = ({ trigger, ...props }: EnvelopeEdit
emailReplyTo,
envelopeExpirationPeriod,
reminderSettings,
includeAuditLog,
} = data.meta;
const parsedGlobalAccessAuth = z.array(ZDocumentAccessAuthTypesSchema).safeParse(data.globalAccessAuth);
@@ -280,6 +292,7 @@ export const EnvelopeEditorSettingsDialog = ({ trigger, ...props }: EnvelopeEdit
uploadSignatureEnabled: signatureTypes.includes(DocumentSignatureType.UPLOAD),
envelopeExpirationPeriod,
reminderSettings,
includeAuditLog,
},
});
@@ -924,6 +937,33 @@ export const EnvelopeEditorSettingsDialog = ({ trigger, ...props }: EnvelopeEdit
)}
/>
<FormField
control={form.control}
name="meta.includeAuditLog"
render={({ field }) => (
<FormItem>
<div className="flex items-center">
<FormControl>
<Checkbox
id="include-audit-log"
checked={field.value}
disabled={field.disabled}
onCheckedChange={(checked) => field.onChange(Boolean(checked))}
/>
</FormControl>
<FormLabel className="ml-2 text-muted-foreground text-sm" htmlFor="include-audit-log">
<Trans>Include audit logs in signed PDF</Trans>
</FormLabel>
</div>
<FormDescription>
<Trans>Audit logs can still be downloaded separately from the document logs page.</Trans>
</FormDescription>
</FormItem>
)}
/>
{!isEmbedded && (
<FormField
control={form.control}
@@ -1,38 +1,13 @@
import { currentMonthlyPeriod } from '@documenso/lib/universal/monthly-period';
import {
getQuotaUsagePercent,
isQuotaExceeded,
isQuotaNearing,
normalizeCapacityLimit,
} from '@documenso/lib/universal/quota-usage';
import { cn } from '@documenso/ui/lib/utils';
import type { BadgeProps } from '@documenso/ui/primitives/badge';
import { Badge } from '@documenso/ui/primitives/badge';
import { Progress } from '@documenso/ui/primitives/progress';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@documenso/ui/primitives/select';
import { Trans } from '@lingui/react/macro';
import type { OrganisationClaim, OrganisationMonthlyStat } from '@prisma/client';
import type { LucideIcon } from 'lucide-react';
import { FileIcon, MailIcon, MailOpenIcon, PlugIcon, UsersIcon, UsersRoundIcon } from 'lucide-react';
import type { ReactNode } from 'react';
import { useId, useState } from 'react';
import { useState } from 'react';
import { match } from 'ts-pattern';
import { OrganisationUsageResetButton } from './organisation-usage-reset-button';
type CapacityUsage = {
members: number;
teams: number;
};
type UsageRow = {
counter: 'document' | 'email' | 'api';
label: ReactNode;
icon: LucideIcon;
used: number;
effectiveLimit: number | null;
};
type OrganisationUsagePanelProps = {
organisationId: string;
monthlyStats: Pick<
@@ -40,151 +15,13 @@ type OrganisationUsagePanelProps = {
'period' | 'documentCount' | 'emailCount' | 'apiCount' | 'emailReports'
>[];
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 = ({
organisationId,
monthlyStats,
organisationClaim,
capacityUsage,
}: OrganisationUsagePanelProps) => {
const monthlyUsagePeriodId = useId();
const [selectedPeriod, setSelectedPeriod] = useState<string | undefined>(() => monthlyStats[0]?.period);
const selectedStat = monthlyStats.find((stat) => stat.period === selectedPeriod) ?? monthlyStats[0];
@@ -193,105 +30,86 @@ export const OrganisationUsagePanel = ({
// current period), so only offer the reset action when viewing the current month.
const isCurrentPeriod = selectedStat?.period === currentMonthlyPeriod();
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[] = [
const rows = [
{
counter: 'document',
counter: 'document' as const,
label: <Trans>Documents</Trans>,
icon: FileIcon,
used: selectedStat?.documentCount ?? 0,
effectiveLimit: organisationClaim.documentQuota,
},
{
counter: 'email',
counter: 'email' as const,
label: <Trans>Emails</Trans>,
icon: MailIcon,
used: selectedStat?.emailCount ?? 0,
effectiveLimit: organisationClaim.emailQuota,
},
{
counter: 'api',
counter: 'api' as const,
label: <Trans>API requests</Trans>,
icon: PlugIcon,
used: selectedStat?.apiCount ?? 0,
effectiveLimit: organisationClaim.apiQuota,
},
];
return (
<div className="mt-4 space-y-6">
{capacityRows.length > 0 ? (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-3">
{capacityRows.map((row) => (
<UsageStatCard key={row.key} label={row.label} icon={row.icon} used={row.used} limit={row.limit} />
))}
</div>
) : null}
<div className="space-y-4 rounded-md border p-4">
<div className="flex items-center justify-between gap-2">
<h3 className="font-medium text-sm">
<Trans>Usage for period: {selectedStat?.period || 'N/A'}</Trans>
</h3>
<div className="space-y-3">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<h3 id={monthlyUsagePeriodId} className="font-semibold text-base">
<Trans>Monthly usage</Trans>
</h3>
{monthlyStats.length > 0 && (
<Select value={selectedStat?.period} onValueChange={setSelectedPeriod}>
<SelectTrigger className="w-40">
<SelectValue />
</SelectTrigger>
<SelectContent>
{monthlyStats.map((stat) => (
<SelectItem key={stat.period} value={stat.period}>
{stat.period}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
{monthlyStats.length > 0 ? (
<Select value={selectedStat?.period} onValueChange={setSelectedPeriod}>
<SelectTrigger className="h-9 w-full sm:w-44" aria-labelledby={monthlyUsagePeriodId}>
<SelectValue />
</SelectTrigger>
<SelectContent>
{monthlyStats.map((stat) => (
<SelectItem key={stat.period} value={stat.period}>
{stat.period}
</SelectItem>
))}
</SelectContent>
</Select>
) : null}
</div>
{rows.map((row) => {
const percent =
row.effectiveLimit && row.effectiveLimit > 0
? Math.min(100, Math.round((row.used / row.effectiveLimit) * 100))
: 0;
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-3">
{monthlyRows.map((row) => (
<UsageStatCard
key={row.counter}
label={row.label}
icon={row.icon}
used={row.used}
limit={row.effectiveLimit}
action={
selectedStat && isCurrentPeriod ? (
<OrganisationUsageResetButton organisationId={organisationId} counter={row.counter} />
) : undefined
}
/>
))}
return (
<div key={row.counter} className="space-y-1">
<div className="flex items-center justify-between text-sm">
<span>{row.label}</span>
<span className="text-muted-foreground">
{row.used} /{' '}
{match(row.effectiveLimit)
.with(null, () => <Trans>Unlimited</Trans>)
.with(0, () => <Trans>Blocked</Trans>)
.otherwise(String)}
</span>
</div>
<UsageStatCard
label={<Trans>Reports</Trans>}
icon={MailOpenIcon}
used={selectedStat?.emailReports ?? 0}
limit={null}
countOnly
footnote={<Trans>Sent this period</Trans>}
/>
{row.effectiveLimit && row.effectiveLimit > 0 ? <Progress className="h-2 w-full" value={percent} /> : null}
{selectedStat && isCurrentPeriod && (
<div className="flex w-full justify-end pt-1">
<OrganisationUsageResetButton organisationId={organisationId} counter={row.counter} />
</div>
)}
</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>
@@ -2,7 +2,6 @@ import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { Trans, useLingui } from '@lingui/react/macro';
import { RotateCcwIcon } from 'lucide-react';
import { useRevalidator } from 'react-router';
type OrganisationUsageResetButtonProps = {
@@ -33,7 +32,6 @@ export const OrganisationUsageResetButton = ({ organisationId, counter }: Organi
loading={isPending}
onClick={() => reset({ organisationId, counter })}
>
<RotateCcwIcon className="mr-2 h-3.5 w-3.5" />
<Trans>Reset</Trans>
</Button>
);
@@ -1,9 +1,7 @@
import { RATE_LIMIT_WINDOW_REGEX } from '@documenso/lib/types/subscription';
import { Button } from '@documenso/ui/primitives/button';
import { Input } from '@documenso/ui/primitives/input';
import { Trans, useLingui } from '@lingui/react/macro';
import { Trans } from '@lingui/react/macro';
import { PlusIcon, Trash2Icon } from 'lucide-react';
import { useState } from 'react';
type RateLimitEntryValue = { window: string; max: number };
@@ -13,153 +11,50 @@ type RateLimitArrayInputProps = {
disabled?: boolean;
};
const EMPTY_ENTRY: RateLimitEntryValue = { window: '', max: 0 };
/** A row counts as "started" once either field has input; fully-empty rows are dropped on commit. */
const hasEntryInput = (entry: RateLimitEntryValue) => entry.window.trim() !== '' || entry.max > 0;
/** Keep in-progress rows; drop rows that are completely empty. */
const persistEntries = (entries: RateLimitEntryValue[]) => {
return entries.map((entry) => ({ ...entry, window: entry.window.trim() })).filter(hasEntryInput);
};
export const RateLimitArrayInput = ({ value, onChange, disabled }: RateLimitArrayInputProps) => {
const { 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 entries = value ?? [];
const updateEntry = (index: number, patch: Partial<RateLimitEntryValue>) => {
if (index >= value.length) {
const nextDraftEntry = { ...(draftEntry ?? EMPTY_ENTRY), ...patch };
if (hasEntryInput(nextDraftEntry)) {
onChange(persistEntries([...value, nextDraftEntry]));
setDraftEntry(null);
return;
}
setDraftEntry(nextDraftEntry);
return;
}
const next = value.map((entry, i) => (i === index ? { ...entry, ...patch } : entry));
onChange(persistEntries(next));
const next = entries.map((entry, i) => (i === index ? { ...entry, ...patch } : entry));
onChange(next);
};
const removeEntry = (index: number) => {
if (index >= value.length) {
setDraftEntry(null);
return;
}
const next = value.filter((_, i) => i !== index);
onChange(persistEntries(next));
onChange(entries.filter((_, i) => i !== index));
};
const addEntry = () => {
setDraftEntry(EMPTY_ENTRY);
onChange([...entries, { window: '5m', max: 100 }]);
};
const hasErrors = entries.some((entry, index) => getWindowError(entry, index) || getMaxError(entry));
const isAddDisabled = disabled || value.length === 0 || Boolean(draftEntry) || hasErrors;
return (
<div className="space-y-2">
<div className="flex items-center gap-2 text-muted-foreground text-xs">
<span className="w-20 shrink-0">
<Trans>Window</Trans>
</span>
<span className="flex-1">
<Trans>Max requests</Trans>
</span>
<span className="w-9 shrink-0" aria-hidden="true" />
</div>
{entries.map((entry, index) => (
<div key={index} className="flex items-center gap-2">
<Input
className="w-24"
placeholder="5m"
value={entry.window}
disabled={disabled}
onChange={(e) => updateEntry(index, { window: e.target.value })}
/>
<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>
))}
{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}
>
<Button type="button" variant="secondary" size="sm" disabled={disabled} onClick={addEntry}>
<PlusIcon className="mr-2 h-4 w-4" />
<Trans>Add rate limit window</Trans>
<Trans>Add rate limit</Trans>
</Button>
</div>
);
@@ -1,4 +1,3 @@
import { ZNameSchema } from '@documenso/lib/types/name';
import { trpc } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
@@ -30,7 +29,7 @@ export type SettingsSecurityPasskeyTableActionsProps = {
};
const ZUpdatePasskeySchema = z.object({
name: ZNameSchema,
name: z.string(),
});
type TUpdatePasskeySchema = z.infer<typeof ZUpdatePasskeySchema>;
@@ -8,7 +8,6 @@ import { getHighestOrganisationRoleInGroup } from '@documenso/lib/utils/organisa
import { trpc } from '@documenso/trpc/react';
import type { TGetAdminOrganisationResponse } from '@documenso/trpc/server/admin-router/get-admin-organisation.types';
import { ZUpdateAdminOrganisationRequestSchema } from '@documenso/trpc/server/admin-router/update-admin-organisation.types';
import { cn } from '@documenso/ui/lib/utils';
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@documenso/ui/primitives/accordion';
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
import { Badge } from '@documenso/ui/primitives/badge';
@@ -31,7 +30,7 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { Trans, useLingui } from '@lingui/react/macro';
import { OrganisationMemberRole, SubscriptionStatus } from '@prisma/client';
import { OrganisationMemberRole } from '@prisma/client';
import { ExternalLinkIcon, InfoIcon, Loader } from 'lucide-react';
import { useMemo } from 'react';
import { useForm } from 'react-hook-form';
@@ -43,6 +42,7 @@ import { AdminOrganisationDeleteDialog } from '~/components/dialogs/admin-organi
import { AdminOrganisationMemberDeleteDialog } from '~/components/dialogs/admin-organisation-member-delete-dialog';
import { AdminOrganisationMemberUpdateDialog } from '~/components/dialogs/admin-organisation-member-update-dialog';
import { AdminOrganisationSyncSubscriptionDialog } from '~/components/dialogs/admin-organisation-sync-subscription-dialog';
import { DetailsCard, DetailsValue } from '~/components/general/admin-details';
import { AdminGlobalSettingsSection } from '~/components/general/admin-global-settings-section';
import { ClaimLimitFields } from '~/components/general/claim-limit-fields';
import { GenericErrorLayout } from '~/components/general/generic-error-layout';
@@ -268,32 +268,54 @@ export default function OrganisationGroupSettingsPage({ params, loaderData }: Ro
<GenericOrganisationAdminForm organisation={organisation} />
<SettingsHeader
title={t`Organisation usage`}
subtitle={t`Current usage against organisation limits.`}
className="mt-6"
hideDivider
/>
<div className="mt-6 rounded-lg border p-4">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<p className="font-medium text-sm">
<Trans>Organisation usage</Trans>
</p>
<p className="mt-1 text-muted-foreground text-sm">
<Trans>Current usage against organisation limits.</Trans>
</p>
</div>
</div>
<OrganisationUsagePanel
organisationId={organisation.id}
monthlyStats={organisation.monthlyStats}
organisationClaim={organisation.organisationClaim}
capacityUsage={{
members: organisation.members.length,
teams: organisation.teams.length,
}}
/>
<div className="mt-4 grid grid-cols-1 gap-3 text-sm sm:grid-cols-2">
<DetailsCard label={<Trans>Members</Trans>}>
<DetailsValue>
{organisation.members.length} /{' '}
{organisation.organisationClaim.memberCount === 0
? t`Unlimited`
: organisation.organisationClaim.memberCount}
</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">
<Accordion type="single" collapsible>
<AccordionItem value="global-settings" className="border-b-0">
<AccordionTrigger className="py-0">
<div className="text-left">
<p className="font-semibold text-base">
<p className="font-medium text-sm">
<Trans>Global Settings</Trans>
</p>
<p className="mt-1 text-muted-foreground text-sm">
<p className="mt-1 font-normal text-muted-foreground text-sm">
<Trans>Default settings applied to this organisation.</Trans>
</p>
</div>
@@ -313,15 +335,7 @@ export default function OrganisationGroupSettingsPage({ params, loaderData }: Ro
className="mt-16"
/>
<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"
>
<Alert className="my-6 flex flex-col justify-between p-6 sm:flex-row sm:items-center" variant="neutral">
<div className="mb-4 sm:mb-0">
<AlertTitle>
<Trans>Subscription</Trans>
@@ -329,12 +343,7 @@ export default function OrganisationGroupSettingsPage({ params, loaderData }: Ro
<AlertDescription className="mr-2">
{organisation.subscription ? (
<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>{i18n._(SUBSCRIPTION_STATUS_MAP[organisation.subscription.status])} subscription found</span>
) : (
<span>
<Trans>No subscription found</Trans>
@@ -347,7 +356,6 @@ export default function OrganisationGroupSettingsPage({ params, loaderData }: Ro
<div>
<Button
variant="outline"
className="bg-background"
loading={isCreatingStripeCustomer}
onClick={async () => createStripeCustomer({ organisationId })}
>
@@ -358,7 +366,7 @@ export default function OrganisationGroupSettingsPage({ params, loaderData }: Ro
{organisation.customerId && !organisation.subscription && (
<div>
<Button variant="outline" className="bg-background" asChild>
<Button variant="outline" asChild>
<Link
target="_blank"
to={`https://dashboard.stripe.com/customers/${organisation.customerId}?create=subscription&subscription_default_customer=${organisation.customerId}`}
@@ -375,13 +383,13 @@ export default function OrganisationGroupSettingsPage({ params, loaderData }: Ro
<AdminOrganisationSyncSubscriptionDialog
organisationId={organisationId}
trigger={
<Button variant="outline" className="bg-background">
<Button variant="outline">
<Trans>Sync Stripe subscription</Trans>
</Button>
}
/>
<Button variant="outline" className="bg-background" asChild>
<Button variant="outline" asChild>
<Link
target="_blank"
to={`https://dashboard.stripe.com/subscriptions/${organisation.subscription.planId}`}
@@ -398,27 +406,21 @@ export default function OrganisationGroupSettingsPage({ params, loaderData }: Ro
<div className="mt-16 space-y-10">
<div>
<h3 className="font-semibold text-base">
<label className="font-medium text-sm leading-none">
<Trans>Organisation Members</Trans>
</h3>
<p className="mt-1 text-muted-foreground text-sm">
<Trans>People with access to this organisation.</Trans>
</p>
</label>
<div className="mt-3">
<div className="my-2">
<DataTable columns={organisationMembersColumns} data={organisation.members} />
</div>
</div>
<div>
<h3 className="font-semibold text-base">
<label className="font-medium text-sm leading-none">
<Trans>Organisation Teams</Trans>
</h3>
<p className="mt-1 text-muted-foreground text-sm">
<Trans>Teams that belong to this organisation.</Trans>
</p>
</label>
<div className="mt-3">
<div className="my-2">
<DataTable columns={teamsColumns} data={organisation.teams} />
</div>
</div>
@@ -646,7 +648,7 @@ const OrganisationAdminForm = ({ organisation, licenseFlags }: OrganisationAdmin
<FormLabel className="flex items-center">
<Trans>Inherited subscription claim</Trans>
<Tooltip>
<TooltipTrigger type="button">
<TooltipTrigger>
<InfoIcon className="mx-2 h-4 w-4" />
</TooltipTrigger>
@@ -679,15 +681,10 @@ const OrganisationAdminForm = ({ organisation, licenseFlags }: OrganisationAdmin
</TooltipContent>
</Tooltip>
</FormLabel>
<div className="rounded-lg border bg-muted/40 px-3 py-2.5 text-sm">
{field.value ? (
<span className="font-mono text-foreground">{field.value}</span>
) : (
<span className="text-muted-foreground">
<Trans>No inherited claim</Trans>
</span>
)}
</div>
<FormControl>
<Input disabled {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
@@ -718,113 +715,108 @@ const OrganisationAdminForm = ({ organisation, licenseFlags }: OrganisationAdmin
)}
/>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<FormField
control={form.control}
name="claims.teamCount"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Team Count</Trans>
</FormLabel>
<FormControl>
<Input
type="number"
min={0}
{...field}
onChange={(e) => field.onChange(parseInt(e.target.value, 10) || 0)}
/>
</FormControl>
<FormDescription>
<Trans>Number of teams allowed. 0 = Unlimited</Trans>
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="claims.teamCount"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Team Count</Trans>
</FormLabel>
<FormControl>
<Input
type="number"
min={0}
{...field}
onChange={(e) => field.onChange(parseInt(e.target.value, 10) || 0)}
/>
</FormControl>
<FormDescription>
<Trans>Number of teams allowed. 0 = Unlimited</Trans>
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="claims.memberCount"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Member Count</Trans>
</FormLabel>
<FormControl>
<Input
type="number"
min={0}
{...field}
onChange={(e) => field.onChange(parseInt(e.target.value, 10) || 0)}
/>
</FormControl>
<FormDescription>
<Trans>Number of members allowed. 0 = Unlimited</Trans>
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="claims.memberCount"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Member Count</Trans>
</FormLabel>
<FormControl>
<Input
type="number"
min={0}
{...field}
onChange={(e) => field.onChange(parseInt(e.target.value, 10) || 0)}
/>
</FormControl>
<FormDescription>
<Trans>Number of members allowed. 0 = Unlimited</Trans>
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="claims.envelopeItemCount"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Envelope Item Count</Trans>
</FormLabel>
<FormControl>
<Input
type="number"
min={1}
{...field}
onChange={(e) => field.onChange(parseInt(e.target.value, 10) || 0)}
/>
</FormControl>
<FormDescription>
<Trans>Maximum number of uploaded files per envelope allowed</Trans>
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="claims.envelopeItemCount"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Envelope Item Count</Trans>
</FormLabel>
<FormControl>
<Input
type="number"
min={1}
{...field}
onChange={(e) => field.onChange(parseInt(e.target.value, 10) || 0)}
/>
</FormControl>
<FormDescription>
<Trans>Maximum number of uploaded files per envelope allowed</Trans>
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="claims.recipientCount"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Recipient Count</Trans>
</FormLabel>
<FormControl>
<Input
type="number"
min={0}
{...field}
onChange={(e) => field.onChange(parseInt(e.target.value, 10) || 0)}
/>
</FormControl>
<FormDescription>
<Trans>Maximum number of recipients per document allowed. 0 = Unlimited</Trans>
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name="claims.recipientCount"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Recipient Count</Trans>
</FormLabel>
<FormControl>
<Input
type="number"
min={0}
{...field}
onChange={(e) => field.onChange(parseInt(e.target.value, 10) || 0)}
/>
</FormControl>
<FormDescription>
<Trans>Maximum number of recipients per document allowed. 0 = Unlimited</Trans>
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<div>
<h3 className="font-semibold text-base">
<FormLabel>
<Trans>Feature Flags</Trans>
</h3>
<p className="mt-1 text-muted-foreground text-sm">
<Trans>Capabilities enabled for this organisation.</Trans>
</p>
</FormLabel>
<div className="mt-3 space-y-2 rounded-md border p-4">
<div className="mt-2 space-y-2 rounded-md border p-4">
{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
@@ -287,11 +287,7 @@ export default function AdminTeamPage({ params }: Route.ComponentProps) {
</AccordionTrigger>
<AccordionContent>
<div className="mt-4">
<AdminGlobalSettingsSection
settings={team.teamGlobalSettings}
inheritedSettings={team.organisation.organisationGlobalSettings}
isTeam
/>
<AdminGlobalSettingsSection settings={team.teamGlobalSettings} isTeam />
</div>
</AccordionContent>
</AccordionItem>
@@ -1,6 +1,7 @@
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { useSession } from '@documenso/lib/client-only/providers/session';
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 type { SanitizeBrandingCssWarning } from '@documenso/lib/utils/sanitize-branding-css';
import { trpc } from '@documenso/trpc/react';
@@ -48,29 +49,26 @@ export default function OrganisationSettingsBrandingPage() {
const { mutateAsync: updateOrganisationSettings } = trpc.organisation.settings.update.useMutation();
const { mutateAsync: updateOrganisationBrandingLogo } = trpc.organisation.settings.updateBrandingLogo.useMutation();
const onBrandingPreferencesFormSubmit = async (data: TBrandingPreferencesFormSchema) => {
try {
const { brandingEnabled, brandingLogo, brandingUrl, brandingCompanyDetails, brandingColors, brandingCss } = data;
// Upload (or clear) the logo through the dedicated, server-validated route.
if (brandingLogo instanceof File || brandingLogo === null) {
const formData = new FormData();
let uploadedBrandingLogo: string | undefined;
formData.append('payload', JSON.stringify({ organisationId: organisation.id }));
if (brandingLogo) {
uploadedBrandingLogo = JSON.stringify(await putFile(brandingLogo));
}
if (brandingLogo instanceof File) {
formData.append('brandingLogo', brandingLogo);
}
await updateOrganisationBrandingLogo(formData);
// Empty the branding logo if the user unsets it.
if (brandingLogo === null) {
uploadedBrandingLogo = '';
}
const result = await updateOrganisationSettings({
organisationId: organisation.id,
data: {
brandingEnabled: brandingEnabled ?? undefined,
brandingLogo: uploadedBrandingLogo,
brandingUrl,
brandingCompanyDetails,
brandingColors,
@@ -3,7 +3,6 @@ import { ORGANISATION_MEMBER_ROLE_HIERARCHY } from '@documenso/lib/constants/org
import { EXTENDED_ORGANISATION_MEMBER_ROLE_MAP } from '@documenso/lib/constants/organisations-translations';
import { TEAM_MEMBER_ROLE_MAP } from '@documenso/lib/constants/teams-translations';
import { AppError } from '@documenso/lib/errors/app-error';
import { ZNameSchema } from '@documenso/lib/types/name';
import { trpc } from '@documenso/trpc/react';
import type { TFindOrganisationGroupsResponse } from '@documenso/trpc/server/organisation-router/find-organisation-groups.types';
import { Button } from '@documenso/ui/primitives/button';
@@ -29,6 +28,7 @@ import { useMemo, useState } from 'react';
import { useForm } from 'react-hook-form';
import { Link } from 'react-router';
import { z } from 'zod';
import { OrganisationGroupDeleteDialog } from '~/components/dialogs/organisation-group-delete-dialog';
import { GenericErrorLayout } from '~/components/general/generic-error-layout';
import {
@@ -36,6 +36,7 @@ import {
OrganisationMembersMultiSelectCombobox,
} from '~/components/general/organisation-members-multiselect-combobox';
import { SettingsHeader } from '~/components/general/settings-header';
import type { Route } from './+types/o.$orgUrl.settings.groups.$id';
export default function OrganisationGroupSettingsPage({ params }: Route.ComponentProps) {
@@ -112,7 +113,7 @@ export default function OrganisationGroupSettingsPage({ params }: Route.Componen
}
const ZUpdateOrganisationGroupFormSchema = z.object({
name: ZNameSchema,
name: z.string().min(1, msg`Name is required`.id),
organisationRole: z.nativeEnum(OrganisationMemberRole),
memberIds: z.array(z.string()),
});
@@ -1,5 +1,6 @@
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
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 type { SanitizeBrandingCssWarning } from '@documenso/lib/utils/sanitize-branding-css';
import { trpc } from '@documenso/trpc/react';
@@ -37,7 +38,6 @@ export default function TeamsSettingsPage() {
});
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();
@@ -48,23 +48,22 @@ export default function TeamsSettingsPage() {
try {
const { brandingEnabled, brandingLogo, brandingUrl, brandingCompanyDetails, brandingColors, brandingCss } = data;
// Upload (or clear) the logo through the dedicated, server-validated route.
if (brandingLogo instanceof File || brandingLogo === null) {
const formData = new FormData();
let uploadedBrandingLogo: string | undefined;
formData.append('payload', JSON.stringify({ teamId: team.id }));
if (brandingLogo) {
uploadedBrandingLogo = JSON.stringify(await putFile(brandingLogo));
}
if (brandingLogo instanceof File) {
formData.append('brandingLogo', brandingLogo);
}
await updateTeamBrandingLogo(formData);
// Empty the branding logo if the user unsets it.
if (brandingLogo === null) {
uploadedBrandingLogo = '';
}
const result = await updateTeamSettings({
teamId: team.id,
data: {
brandingEnabled,
brandingLogo: uploadedBrandingLogo,
brandingUrl: brandingUrl || null,
brandingCompanyDetails: brandingCompanyDetails || null,
brandingColors,
+4 -4
View File
@@ -36,8 +36,8 @@
"@lingui/react": "^5.6.0",
"@oslojs/crypto": "^1.0.1",
"@oslojs/encoding": "^1.1.0",
"@react-router/node": "^7.18.1",
"@react-router/serve": "^7.18.1",
"@react-router/node": "^7.12.0",
"@react-router/serve": "^7.12.0",
"@simplewebauthn/browser": "^13.2.2",
"@simplewebauthn/server": "^13.2.2",
"@tanstack/react-query": "5.90.10",
@@ -81,8 +81,8 @@
"@babel/preset-typescript": "^7.28.5",
"@lingui/babel-plugin-lingui-macro": "^5.6.0",
"@lingui/vite-plugin": "^5.6.0",
"@react-router/dev": "^7.18.1",
"@react-router/remix-routes-option-adapter": "^7.18.1",
"@react-router/dev": "^7.12.0",
"@react-router/remix-routes-option-adapter": "^7.12.0",
"@rollup/plugin-babel": "^6.1.0",
"@rollup/plugin-commonjs": "^28.0.9",
"@rollup/plugin-json": "^6.1.0",
+28 -1
View File
@@ -1,8 +1,9 @@
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app';
import { AppError } from '@documenso/lib/errors/app-error';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { verifyEmbeddingPresignToken } from '@documenso/lib/server-only/embedding-presign/verify-embedding-presign-token';
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 { sValidator } from '@hono/standard-validator';
import type { Prisma } from '@prisma/client';
@@ -11,11 +12,14 @@ import { Hono } from 'hono';
import type { HonoEnv } from '../../router';
import { checkEnvelopeFileAccess, handleEnvelopeItemFileRequest, resolveFileUploadUserId } from './files.helpers';
import {
isAllowedUploadContentType,
type TGetPresignedPostUrlResponse,
ZGetEnvelopeItemFileDownloadRequestParamsSchema,
ZGetEnvelopeItemFileRequestParamsSchema,
ZGetEnvelopeItemFileRequestQuerySchema,
ZGetEnvelopeItemFileTokenDownloadRequestParamsSchema,
ZGetEnvelopeItemFileTokenRequestParamsSchema,
ZGetPresignedPostUrlRequestSchema,
ZUploadPdfRequestSchema,
} from './files.types';
import getEnvelopeItemPdfRoute from './routes/get-envelope-item-pdf';
@@ -57,6 +61,29 @@ export const filesRoute = new Hono<HonoEnv>()
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(
'/envelope/:envelopeId/envelopeItem/:envelopeItemId',
sValidator('param', ZGetEnvelopeItemFileRequestParamsSchema),
@@ -13,6 +13,27 @@ export const ZUploadPdfResponseSchema = DocumentDataSchema.pick({
export type TUploadPdfRequest = z.infer<typeof ZUploadPdfRequestSchema>;
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({
envelopeId: z.string().min(1),
envelopeItemId: z.string().min(1),
+1
View File
@@ -105,6 +105,7 @@ app.route('/api/auth', auth);
// Files route.
app.use('/api/files/upload-pdf', fileRateLimitMiddleware);
app.use('/api/files/presigned-post-url', fileRateLimitMiddleware);
app.route('/api/files', filesRoute);
// AI route.
+1264 -2048
View File
File diff suppressed because it is too large Load Diff
@@ -44,6 +44,46 @@ test.describe('File upload endpoint authorization', () => {
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 }) => {
const { user, team } = await seedUser();
const presignToken = await createPresignTokenForUser(user.id, team.id);
@@ -1,37 +0,0 @@
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();
});
});
@@ -1,225 +0,0 @@
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();
});
@@ -0,0 +1,228 @@
import { getDocumentByToken } from '@documenso/lib/server-only/document/get-document-by-token';
import { getEnvelopeItemPdfUrl } from '@documenso/lib/utils/envelope-download';
import { prisma } from '@documenso/prisma';
import { PDF } from '@libpdf/core';
import { expect, type Page, test } from '@playwright/test';
import { DocumentStatus, type Field } from '@prisma/client';
import { apiCreateTestContext, apiSeedDraftDocument, apiSeedPendingDocument } from '../fixtures/api-seeds';
import { apiSignin } from '../fixtures/authentication';
import { signSignaturePad } from '../fixtures/signature';
type TeamDocumentSettings = {
includeAuditLog?: boolean;
includeSigningCertificate?: boolean;
};
const updateTeamDocumentSettings = async (teamId: number, data: TeamDocumentSettings) => {
const teamSettings = await prisma.teamGlobalSettings.findFirstOrThrow({
where: {
team: {
id: teamId,
},
},
});
await prisma.teamGlobalSettings.update({
where: {
id: teamSettings.id,
},
data,
});
};
const getFirstEnvelopeItemSignedPageCount = async (envelopeId: string, token: string) => {
const envelopeItem = await prisma.envelopeItem.findFirstOrThrow({
where: {
envelopeId,
},
});
const documentUrl = getEnvelopeItemPdfUrl({
type: 'download',
envelopeItem,
token,
version: 'signed',
});
const response = await fetch(documentUrl);
expect(response.ok).toBe(true);
const pdfData = await response.arrayBuffer();
const pdfDoc = await PDF.load(new Uint8Array(pdfData));
return pdfDoc.getPageCount();
};
const completeSigning = async ({
page,
signingUrl,
recipientToken,
field,
}: {
page: Page;
signingUrl: string;
recipientToken: string;
field: Pick<Field, 'positionX' | 'positionY' | 'width' | 'height'>;
}) => {
await page.goto(signingUrl);
// V2 envelopes render fields on a Konva canvas — wait for the PDF and canvas.
await expect(page.locator('img[data-page-number]').first()).toBeVisible({ timeout: 30_000 });
const canvas = page.locator('.konva-container canvas').first();
await expect(canvas).toBeVisible({ timeout: 30_000 });
await signSignaturePad(page);
const canvasBox = await canvas.boundingBox();
if (!canvasBox) {
throw new Error('Canvas bounding box not found');
}
const x = (Number(field.positionX) / 100) * canvasBox.width + ((Number(field.width) / 100) * canvasBox.width) / 2;
const y =
(Number(field.positionY) / 100) * canvasBox.height + ((Number(field.height) / 100) * canvasBox.height) / 2;
await canvas.click({ position: { x, y } });
await expect(page.getByText('0 Fields Remaining').first()).toBeVisible({ timeout: 10_000 });
await page.getByRole('button', { name: 'Complete' }).click();
await expect(page.getByRole('heading', { name: 'Are you sure?' })).toBeVisible();
await page.getByRole('button', { name: 'Sign' }).click();
await page.waitForURL(/\/complete$/);
await expect(async () => {
const { status } = await getDocumentByToken({
token: recipientToken,
});
expect(status).toBe(DocumentStatus.COMPLETED);
}).toPass();
};
test.describe('Document audit log embedding', () => {
test('new documents derive audit-log embedding from team settings', async ({ request }) => {
const context = await apiCreateTestContext('e2e-audit-log-default');
await updateTeamDocumentSettings(context.team.id, {
includeAuditLog: true,
});
const { envelope } = await apiSeedDraftDocument(request, {
context,
});
expect(envelope.documentMeta.includeAuditLog).toBe(true);
await updateTeamDocumentSettings(context.team.id, {
includeAuditLog: false,
});
const documentMeta = await prisma.documentMeta.findFirstOrThrow({
where: {
id: envelope.documentMeta.id,
},
});
expect(documentMeta.includeAuditLog).toBe(true);
});
test('meta.includeAuditLog true appends audit-log pages when team default is false', async ({ page, request }) => {
const context = await apiCreateTestContext('e2e-audit-log-override-true');
await updateTeamDocumentSettings(context.team.id, {
includeAuditLog: false,
includeSigningCertificate: false,
});
const { distributeResult, envelope } = await apiSeedPendingDocument(request, {
context,
meta: {
includeAuditLog: true,
},
});
expect(envelope.documentMeta.includeAuditLog).toBe(true);
const signer = distributeResult.recipients[0];
const signatureField = envelope.fields.find((field) => field.recipientId === signer.id);
if (!signatureField) {
throw new Error('Signature field not found');
}
const baselinePageCount = await getFirstEnvelopeItemSignedPageCount(envelope.id, signer.token);
await completeSigning({
page,
signingUrl: signer.signingUrl,
recipientToken: signer.token,
field: signatureField,
});
await expect(async () => {
const signedPageCount = await getFirstEnvelopeItemSignedPageCount(envelope.id, signer.token);
// The audit log may span multiple pages depending on the number of
// audit events — assert growth rather than an exact page count.
expect(signedPageCount).toBeGreaterThan(baselinePageCount);
}).toPass();
});
test('meta.includeAuditLog false skips embedded audit logs when team default is true', async ({ page, request }) => {
const context = await apiCreateTestContext('e2e-audit-log-override-false');
await updateTeamDocumentSettings(context.team.id, {
includeAuditLog: true,
includeSigningCertificate: false,
});
const { distributeResult, envelope, team, user } = await apiSeedPendingDocument(request, {
context,
meta: {
includeAuditLog: false,
},
});
expect(envelope.documentMeta.includeAuditLog).toBe(false);
const signer = distributeResult.recipients[0];
const signatureField = envelope.fields.find((field) => field.recipientId === signer.id);
if (!signatureField) {
throw new Error('Signature field not found');
}
const baselinePageCount = await getFirstEnvelopeItemSignedPageCount(envelope.id, signer.token);
await completeSigning({
page,
signingUrl: signer.signingUrl,
recipientToken: signer.token,
field: signatureField,
});
await expect(async () => {
const signedPageCount = await getFirstEnvelopeItemSignedPageCount(envelope.id, signer.token);
expect(signedPageCount).toBe(baselinePageCount);
}).toPass();
await apiSignin({
page,
email: user.email,
redirectPath: `/t/${team.url}/documents/${envelope.id}/logs`,
});
const downloadPromise = page.waitForEvent('download');
await page.getByRole('button', { name: 'Download Audit Logs' }).click();
const download = await downloadPromise;
expect(download.suggestedFilename()).toContain('Audit Logs.pdf');
});
});
@@ -142,38 +142,3 @@ 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.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/types/name';
import { ZNameSchema } from '@documenso/lib/constants/auth';
import { zEmail } from '@documenso/lib/utils/zod';
import { z } from 'zod';
+15
View File
@@ -1,10 +1,25 @@
import MailChecker from 'mailchecker';
import { z } from 'zod';
import { env } from '../utils/env';
import { NEXT_PUBLIC_WEBAPP_URL } from './app';
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> = {
DOCUMENSO: 'Documenso',
GOOGLE: 'Google',
-10
View File
@@ -9,13 +9,3 @@
* cap so a malicious or runaway payload can't exhaust PostCSS/server memory.
*/
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'];
@@ -206,7 +206,7 @@ export const run = async ({ payload, io }: { payload: TSealDocumentJobDefinition
const usePlaywrightPdf = NEXT_PRIVATE_USE_PLAYWRIGHT_PDF();
const needsCertificate = settings.includeSigningCertificate;
const needsAuditLog = settings.includeAuditLog;
const needsAuditLog = envelope.documentMeta.includeAuditLog;
const newDocumentData: Array<{ oldDocumentDataId: string; newDocumentDataId: string }> = [];
@@ -1,26 +0,0 @@
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);
};
@@ -28,6 +28,7 @@ export type CreateDocumentMetaOptions = {
signingOrder?: DocumentSigningOrder;
allowDictateNextSigner?: boolean;
distributionMethod?: DocumentDistributionMethod;
includeAuditLog?: boolean;
typedSignatureEnabled?: boolean;
uploadSignatureEnabled?: boolean;
drawSignatureEnabled?: boolean;
@@ -50,6 +51,7 @@ export const updateDocumentMeta = async ({
emailReplyTo,
emailSettings,
distributionMethod,
includeAuditLog,
typedSignatureEnabled,
uploadSignatureEnabled,
drawSignatureEnabled,
@@ -129,6 +131,7 @@ export const updateDocumentMeta = async ({
emailReplyTo,
emailSettings,
distributionMethod,
includeAuditLog,
typedSignatureEnabled,
uploadSignatureEnabled,
drawSignatureEnabled,
@@ -57,10 +57,7 @@ export const UNSAFE_createEnvelopeItems = async ({
flattenForm: envelope.type !== 'TEMPLATE',
});
const { cleanedPdf, placeholders } = await extractPdfPlaceholders(normalized, {
envelopeId: envelope.id,
fileName: file.name,
});
const { cleanedPdf, placeholders } = await extractPdfPlaceholders(normalized);
const { documentData } = await putPdfFileServerSide({
name: file.name,
@@ -85,10 +85,7 @@ export const UNSAFE_replaceEnvelopeItemPdf = async ({
flattenForm: envelope.type !== 'TEMPLATE',
});
const { cleanedPdf, placeholders } = await extractPdfPlaceholders(normalized, {
envelopeId: envelope.id,
fileName: data.file.name,
});
const { cleanedPdf, placeholders } = await extractPdfPlaceholders(normalized);
// Upload the new PDF and get a new DocumentData record.
const { documentData: newDocumentData, filePageCount } = await putPdfFileServerSide({
@@ -41,6 +41,7 @@ export const ZEnvelopeForSigningResponse = z.object({
documentMeta: DocumentMetaSchema.pick({
signingOrder: true,
distributionMethod: true,
includeAuditLog: true,
timezone: true,
dateFormat: true,
redirectUrl: true,
@@ -1,15 +1,8 @@
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { type TFieldAndMeta, ZEnvelopeFieldAndMetaSchema } from '@documenso/lib/types/field-meta';
import { logger } from '@documenso/lib/utils/logger';
import { PDF, rgb } from '@libpdf/core';
import type { FieldType, Recipient } from '@prisma/client';
import {
parseFieldMetaFromPlaceholder,
parseFieldTypeFromPlaceholder,
parsePlaceholderData,
parseRawFieldMetaFromPlaceholder,
} from './helpers';
import { parseFieldMetaFromPlaceholder, parseFieldTypeFromPlaceholder } from './helpers';
const PLACEHOLDER_REGEX = /\{\{([^}]+)\}\}/g;
const DEFAULT_FIELD_HEIGHT_PERCENT = 2;
@@ -68,15 +61,7 @@ export type FieldToCreate = TFieldAndMeta & {
height: number;
};
type ExtractPlaceholdersLogContext = {
envelopeId?: string;
fileName?: string;
};
export const extractPlaceholdersFromPDF = async (
pdf: Buffer,
logContext?: ExtractPlaceholdersLogContext,
): Promise<PlaceholderInfo[]> => {
export const extractPlaceholdersFromPDF = async (pdf: Buffer): Promise<PlaceholderInfo[]> => {
const pdfDoc = await PDF.load(new Uint8Array(pdf));
const placeholders: PlaceholderInfo[] = [];
@@ -100,7 +85,7 @@ export const extractPlaceholdersFromPDF = async (
continue;
}
const placeholderData = parsePlaceholderData(innerMatch[1]);
const placeholderData = innerMatch[1].split(',').map((property) => property.trim());
const [fieldTypeString, recipientOrMeta, ...fieldMetaData] = placeholderData;
let fieldType: FieldType;
@@ -124,51 +109,14 @@ export const extractPlaceholdersFromPDF = async (
const recipient = recipientOrMeta;
/*
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 rawFieldMeta = Object.fromEntries(fieldMetaData.map((property) => property.split('=')));
try {
const rawFieldMeta = parseRawFieldMetaFromPlaceholder(fieldMetaData);
const parsedFieldMeta = parseFieldMetaFromPlaceholder(rawFieldMeta, fieldType);
const parsedFieldMeta = parseFieldMetaFromPlaceholder(rawFieldMeta, fieldType);
const parsedFieldAndMeta = ZEnvelopeFieldAndMetaSchema.safeParse({
type: fieldType,
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;
}
const fieldAndMeta: TFieldAndMeta = ZEnvelopeFieldAndMetaSchema.parse({
type: fieldType,
fieldMeta: parsedFieldMeta,
});
/*
LibPDF returns bbox in points with bottom-left origin.
@@ -234,9 +182,8 @@ export const removePlaceholdersFromPDF = async (pdf: Buffer, placeholders?: Plac
*/
export const extractPdfPlaceholders = async (
pdf: Buffer,
logContext?: ExtractPlaceholdersLogContext,
): Promise<{ cleanedPdf: Buffer; placeholders: PlaceholderInfo[] }> => {
const placeholders = await extractPlaceholdersFromPDF(pdf, logContext);
const placeholders = await extractPlaceholdersFromPDF(pdf);
if (placeholders.length === 0) {
return { cleanedPdf: pdf, placeholders: [] };
@@ -1,235 +0,0 @@
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' }],
});
});
});
});
+5 -311
View File
@@ -45,134 +45,6 @@ type RecipientPlaceholderInfo = {
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.
Normalizes the input (uppercase, trim) and validates it's a valid field type.
@@ -200,169 +72,6 @@ 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.
Users should provide properly capitalized property names (e.g., readOnly, fontSize, textAlign).
@@ -382,7 +91,7 @@ export const parseFieldMetaFromPlaceholder = (
const fieldTypeString = String(fieldType).toLowerCase();
const parsedFieldMeta: Record<string, unknown> = {
const parsedFieldMeta: Record<string, boolean | number | string> = {
type: fieldTypeString,
};
@@ -395,39 +104,24 @@ export const parseFieldMetaFromPlaceholder = (
const rawFieldMetaEntries = Object.entries(rawFieldMeta);
for (const [property, value] of rawFieldMetaEntries) {
if (shouldSkipGenericFieldMetaParsing(property, fieldType)) {
continue;
}
const unescapedValue = unescapePlaceholderValue(value);
if (property === 'readOnly' || property === 'required') {
parsedFieldMeta[property] = unescapedValue.toLowerCase() === 'true';
} else if (property === 'validationRule' && fieldType === FieldType.CHECKBOX) {
parsedFieldMeta[property] = parseCheckboxValidationRule(unescapedValue);
parsedFieldMeta[property] = value === 'true';
} else if (
property === 'fontSize' ||
property === 'maxValue' ||
property === 'minValue' ||
property === 'characterLimit' ||
property === 'validationLength'
property === 'characterLimit'
) {
const numValue = Number(unescapedValue);
const numValue = Number(value);
if (!Number.isNaN(numValue)) {
parsedFieldMeta[property] = numValue;
}
} else {
parsedFieldMeta[property] = unescapedValue;
parsedFieldMeta[property] = value;
}
}
match(fieldType)
.with(FieldType.RADIO, () => applyRadioFieldOptions(parsedFieldMeta, rawFieldMeta))
.with(FieldType.CHECKBOX, () => applyCheckboxFieldOptions(parsedFieldMeta, rawFieldMeta))
.with(FieldType.DROPDOWN, () => applyDropdownFieldOptions(parsedFieldMeta, rawFieldMeta))
.otherwise(() => undefined);
return parsedFieldMeta;
};
@@ -1,4 +1,4 @@
import { isQuotaExceeded, isQuotaNearing } from '../../universal/quota-usage';
import { QUOTA_WARNING_THRESHOLD } from './get-quota-alert-kind';
export type QuotaFlags = {
isDocumentQuotaExceeded: boolean;
@@ -22,6 +22,39 @@ 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 => {
return {
isDocumentQuotaExceeded: isQuotaExceeded(quotas.documentQuota, usage?.documentCount ?? 0),
@@ -1,4 +1,4 @@
import { getQuotaWarningCount } from '../../universal/quota-usage';
export const QUOTA_WARNING_THRESHOLD = 0.8;
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
// warning threshold equals the quota itself, the warning can never fire — the
// exhausting request is handled by the quota branch above.
const warningCount = getQuotaWarningCount(quota);
const warningCount = Math.ceil(quota * QUOTA_WARNING_THRESHOLD);
const didCrossWarning = newCount >= warningCount && previousCount < warningCount;
@@ -108,6 +108,7 @@ export type CreateDocumentFromTemplateOptions = {
language?: SupportedLanguageCodes;
distributionMethod?: DocumentDistributionMethod;
allowDictateNextSigner?: boolean;
includeAuditLog?: boolean;
emailSettings?: TDocumentEmailSettings;
typedSignatureEnabled?: boolean;
uploadSignatureEnabled?: boolean;
@@ -534,6 +535,7 @@ export const createDocumentFromTemplate = async ({
dateFormat: override?.dateFormat || template.documentMeta?.dateFormat,
redirectUrl: override?.redirectUrl || template.documentMeta?.redirectUrl,
distributionMethod: override?.distributionMethod || template.documentMeta?.distributionMethod,
includeAuditLog: override?.includeAuditLog ?? template.documentMeta?.includeAuditLog,
emailSettings: override?.emailSettings || template.documentMeta?.emailSettings,
signingOrder: override?.signingOrder || template.documentMeta?.signingOrder,
language: override?.language || template.documentMeta?.language || settings.documentLanguage,
@@ -690,7 +692,7 @@ export const createDocumentFromTemplate = async ({
const date = new Date(selector.value);
if (isNaN(date.getTime())) {
if (Number.isNaN(date.getTime())) {
throw new AppError(AppErrorCode.INVALID_BODY, {
message: `Invalid date value for field ${field.id}: ${selector.value}`,
});
@@ -48,6 +48,7 @@ export const generateSampleWebhookPayload = (event: WebhookTriggerEvents, webhoo
drawSignatureEnabled: true,
language: 'en',
distributionMethod: DocumentDistributionMethod.EMAIL,
includeAuditLog: false,
emailSettings: null,
},
recipients: [
+6
View File
@@ -19,6 +19,7 @@ import { ZDocumentEmailSettingsSchema } from './document-email';
export const ZDocumentMetaSchema = DocumentMetaSchema.pick({
signingOrder: true,
distributionMethod: true,
includeAuditLog: true,
id: true,
subject: true,
message: true,
@@ -93,6 +94,10 @@ export const ZDocumentMetaDistributionMethodSchema = z
.nativeEnum(DocumentDistributionMethod)
.describe('The distribution method to use when sending the document to the recipients.');
export const ZDocumentMetaIncludeAuditLogSchema = z
.boolean()
.describe('Whether to include the audit logs in the sealed document PDF.');
export const ZDocumentMetaTypedSignatureEnabledSchema = z
.boolean()
.describe('Whether to allow recipients to sign using a typed signature.');
@@ -116,6 +121,7 @@ export const ZDocumentMetaCreateSchema = z.object({
timezone: ZDocumentMetaTimezoneSchema.optional(),
dateFormat: ZDocumentMetaDateFormatSchema.optional(),
distributionMethod: ZDocumentMetaDistributionMethodSchema.optional(),
includeAuditLog: ZDocumentMetaIncludeAuditLogSchema.optional(),
signingOrder: z.nativeEnum(DocumentSigningOrder).optional(),
allowDictateNextSigner: z.boolean().optional(),
redirectUrl: ZDocumentMetaRedirectUrlSchema.optional(),
+1
View File
@@ -53,6 +53,7 @@ export const ZDocumentSchema = LegacyDocumentSchema.pick({
documentMeta: DocumentMetaSchema.pick({
signingOrder: true,
distributionMethod: true,
includeAuditLog: true,
id: true,
subject: true,
message: true,
+1
View File
@@ -270,6 +270,7 @@ export const ZEditorEnvelopeSchema = EnvelopeSchema.pick({
documentMeta: DocumentMetaSchema.pick({
signingOrder: true,
distributionMethod: true,
includeAuditLog: true,
id: true,
subject: true,
message: true,
+1
View File
@@ -40,6 +40,7 @@ export const ZEnvelopeSchema = EnvelopeSchema.pick({
documentMeta: DocumentMetaSchema.pick({
signingOrder: true,
distributionMethod: true,
includeAuditLog: true,
id: true,
subject: true,
message: true,
-145
View File
@@ -1,145 +0,0 @@
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
@@ -1,68 +0,0 @@
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>;
+7 -32
View File
@@ -6,39 +6,14 @@ import { z } from 'zod';
*
* Example: "5m", "1h", "1d"
*/
export const RATE_LIMIT_WINDOW_REGEX = /^\d+[smhd]$/;
export const ZRateLimitWindowSchema = z.string().regex(/^\d+[smhd]$/);
const RATE_LIMIT_WINDOW_ERROR_MESSAGE = 'Use a duration with a unit, e.g. 5m, 1h, or 24h';
const RATE_LIMIT_DUPLICATE_WINDOW_ERROR_MESSAGE = 'Use a unique window for each rate limit';
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 const ZRateLimitArraySchema = z.array(
z.object({
window: ZRateLimitWindowSchema,
max: z.number().int().positive(),
}),
);
export type TRateLimitArray = z.infer<typeof ZRateLimitArraySchema>;
+2
View File
@@ -56,6 +56,7 @@ export const ZTemplateSchema = TemplateSchema.pick({
drawSignatureEnabled: true,
allowDictateNextSigner: true,
distributionMethod: true,
includeAuditLog: true,
redirectUrl: true,
language: true,
emailSettings: true,
@@ -149,6 +150,7 @@ export const ZTemplateManySchema = TemplateSchema.pick({
templateMeta: DocumentMetaSchema.pick({
signingOrder: true,
distributionMethod: true,
includeAuditLog: true,
}).nullable(),
directLink: LegacyTemplateDirectLinkSchema.pick({
token: true,
+1
View File
@@ -57,6 +57,7 @@ export const ZWebhookDocumentMetaSchema = z.object({
drawSignatureEnabled: z.boolean(),
language: z.string(),
distributionMethod: z.nativeEnum(DocumentDistributionMethod),
includeAuditLog: z.boolean().default(false),
emailSettings: z.any().nullable(),
});
@@ -1,99 +0,0 @@
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
@@ -1,57 +0,0 @@
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;
};
+71 -1
View File
@@ -1,5 +1,10 @@
import type { TUploadPdfResponse } from '@documenso/remix/server/api/files/files.types';
import { env } from '@documenso/lib/utils/env';
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';
type File = {
@@ -53,3 +58,68 @@ export const putPdfFile = async (file: File, options?: PutFileOptions) => {
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,
};
};
+1
View File
@@ -54,6 +54,7 @@ export const extractDerivedDocumentMeta = (
signingOrder: resolveSigningOrder({ signatureLevel, requested: meta.signingOrder }),
allowDictateNextSigner: meta.allowDictateNextSigner ?? false,
distributionMethod: meta.distributionMethod || DocumentDistributionMethod.EMAIL, // Todo: Make this a setting.
includeAuditLog: meta.includeAuditLog ?? settings.includeAuditLog,
// Signature settings.
typedSignatureEnabled: meta.typedSignatureEnabled ?? settings.typedSignatureEnabled,
-12
View File
@@ -8,15 +8,3 @@ export const loadLogo = async (file: Uint8Array) => {
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();
};
@@ -0,0 +1,12 @@
-- AlterTable
ALTER TABLE "DocumentMeta" ADD COLUMN "includeAuditLog" BOOLEAN NOT NULL DEFAULT false;
-- Backfill includeAuditLog from the effective team/organisation setting
UPDATE "DocumentMeta" AS dm
SET "includeAuditLog" = COALESCE(tgs."includeAuditLog", ogs."includeAuditLog")
FROM "Envelope" e
JOIN "Team" t ON t."id" = e."teamId"
JOIN "TeamGlobalSettings" tgs ON tgs."id" = t."teamGlobalSettingsId"
JOIN "Organisation" o ON o."id" = t."organisationId"
JOIN "OrganisationGlobalSettings" ogs ON ogs."id" = o."organisationGlobalSettingsId"
WHERE e."documentMetaId" = dm."id";
+1
View File
@@ -573,6 +573,7 @@ model DocumentMeta {
language String @default("en")
distributionMethod DocumentDistributionMethod @default(EMAIL)
includeAuditLog Boolean @default(false)
emailSettings Json? /// [DocumentEmailSettings] @zod.custom.use(ZDocumentEmailSettingsSchema)
emailReplyTo String?
@@ -1,10 +1,11 @@
import { ZNameSchema } from '@documenso/lib/types/name';
import { z } from 'zod';
import { ZOrganisationNameSchema } from '../organisation-router/create-organisation.types';
export const ZCreateAdminOrganisationRequestSchema = z.object({
ownerUserId: z.number(),
data: z.object({
name: ZNameSchema,
name: ZOrganisationNameSchema,
}),
});
@@ -1,9 +1,8 @@
import { ZNameSchema } from '@documenso/lib/types/name';
import { ZClaimFlagsSchema, ZRateLimitArraySchema } from '@documenso/lib/types/subscription';
import { z } from 'zod';
export const ZCreateSubscriptionClaimRequestSchema = z.object({
name: ZNameSchema,
name: z.string().min(1),
teamCount: z.number().int().min(0),
memberCount: z.number().int().min(0),
envelopeItemCount: z.number().int().min(1),
@@ -1,4 +1,4 @@
import { ZNameSchema } from '@documenso/lib/types/name';
import { ZNameSchema } from '@documenso/lib/constants/auth';
import { z } from 'zod';
export const ZCreateUserRequestSchema = z.object({
@@ -1,10 +1,9 @@
import { ZEmailTransportConfigSchema } from '@documenso/lib/server-only/email/email-transport-config';
import { ZNameSchema } from '@documenso/lib/types/name';
import { z } from 'zod';
export const ZCreateEmailTransportRequestSchema = z.object({
name: ZNameSchema,
fromName: ZNameSchema,
name: z.string().min(1),
fromName: z.string().min(1),
fromAddress: z.string().email(),
config: ZEmailTransportConfigSchema,
});
@@ -4,7 +4,6 @@ import {
ZSmtpApiConfigSchema,
ZSmtpAuthConfigSchema,
} from '@documenso/lib/server-only/email/email-transport-config';
import { ZNameSchema } from '@documenso/lib/types/name';
import { z } from 'zod';
// Reuses the canonical transport config schemas, but relaxes the secret field so
@@ -22,8 +21,8 @@ const ZUpdateConfigSchema = z.discriminatedUnion('type', [
export const ZUpdateEmailTransportRequestSchema = z.object({
id: z.string(),
data: z.object({
name: ZNameSchema,
fromName: ZNameSchema,
name: z.string().min(1),
fromName: z.string().min(1),
fromAddress: z.string().email(),
config: ZUpdateConfigSchema,
}),
@@ -30,7 +30,6 @@ export const getAdminTeamRoute = adminProcedure
name: true,
url: true,
ownerUserId: true,
organisationGlobalSettings: true,
},
},
teamEmail: true,
@@ -1,6 +1,5 @@
import { OrganisationMemberRoleSchema } from '@documenso/prisma/generated/zod/inputTypeSchemas/OrganisationMemberRoleSchema';
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 OrganisationMemberSchema from '@documenso/prisma/generated/zod/modelSchema/OrganisationMemberSchema';
import OrganisationSchema from '@documenso/prisma/generated/zod/modelSchema/OrganisationSchema';
@@ -20,8 +19,6 @@ export const ZGetAdminTeamResponseSchema = TeamSchema.extend({
name: true,
url: true,
ownerUserId: true,
}).extend({
organisationGlobalSettings: OrganisationGlobalSettingsSchema,
}),
teamEmail: TeamEmailSchema.nullable(),
teamGlobalSettings: TeamGlobalSettingsSchema.nullable(),
@@ -1,13 +1,13 @@
import { ZNameSchema } from '@documenso/lib/types/name';
import { z } from 'zod';
import { ZOrganisationNameSchema } from '../organisation-router/create-organisation.types';
import { ZTeamUrlSchema } from '../team-router/schema';
import { ZCreateSubscriptionClaimRequestSchema } from './create-subscription-claim.types';
export const ZUpdateAdminOrganisationRequestSchema = z.object({
organisationId: z.string(),
data: z.object({
name: ZNameSchema.optional(),
name: ZOrganisationNameSchema.optional(),
url: ZTeamUrlSchema.optional(),
claims: ZCreateSubscriptionClaimRequestSchema.pick({
teamCount: true,
@@ -1,11 +1,10 @@
import { ZNameSchema } from '@documenso/lib/types/name';
import { zEmail } from '@documenso/lib/utils/zod';
import { Role } from '@prisma/client';
import { z } from 'zod';
export const ZUpdateUserRequestSchema = z.object({
id: z.number().min(1),
name: ZNameSchema.nullish(),
name: z.string().nullish(),
email: zEmail().optional(),
roles: z.array(z.nativeEnum(Role)).optional(),
});
@@ -1,9 +1,8 @@
import { ZNameSchema } from '@documenso/lib/types/name';
import { z } from 'zod';
export const ZCreateApiTokenRequestSchema = z.object({
teamId: z.number(),
tokenName: ZNameSchema,
tokenName: z.string().min(3, { message: 'The token name should be 3 characters or longer' }),
expirationDate: z.string().nullable(),
});
@@ -1,9 +1,8 @@
import { ZNameSchema } from '@documenso/lib/types/name';
import { ZRegistrationResponseJSONSchema } from '@documenso/lib/types/webauthn';
import { z } from 'zod';
export const ZCreatePasskeyRequestSchema = z.object({
passkeyName: ZNameSchema,
passkeyName: z.string().trim().min(1),
verificationResponse: ZRegistrationResponseJSONSchema,
});
@@ -1,9 +1,8 @@
import { ZNameSchema } from '@documenso/lib/types/name';
import { z } from 'zod';
export const ZUpdatePasskeyRequestSchema = z.object({
passkeyId: z.string().trim().min(1),
name: ZNameSchema,
name: z.string().trim().min(1),
});
export const ZUpdatePasskeyResponseSchema = z.void();
@@ -37,6 +37,7 @@ export const distributeDocumentRoute = authenticatedProcedure
timezone: meta.timezone,
redirectUrl: meta.redirectUrl,
distributionMethod: meta.distributionMethod,
includeAuditLog: meta.includeAuditLog,
emailSettings: meta.emailSettings ?? undefined,
language: meta.language,
emailId: meta.emailId,
@@ -3,6 +3,7 @@ import { ZDocumentEmailSettingsSchema } from '@documenso/lib/types/document-emai
import {
ZDocumentMetaDateFormatSchema,
ZDocumentMetaDistributionMethodSchema,
ZDocumentMetaIncludeAuditLogSchema,
ZDocumentMetaLanguageSchema,
ZDocumentMetaMessageSchema,
ZDocumentMetaRedirectUrlSchema,
@@ -33,6 +34,7 @@ export const ZDistributeDocumentRequestSchema = z.object({
timezone: ZDocumentMetaTimezoneSchema.optional(),
dateFormat: ZDocumentMetaDateFormatSchema.optional(),
distributionMethod: ZDocumentMetaDistributionMethodSchema.optional(),
includeAuditLog: ZDocumentMetaIncludeAuditLogSchema.optional(),
redirectUrl: ZDocumentMetaRedirectUrlSchema.optional(),
language: ZDocumentMetaLanguageSchema.optional(),
emailId: z.string().nullish(),
@@ -3,6 +3,7 @@ import {
ZDocumentMetaDateFormatSchema,
ZDocumentMetaDistributionMethodSchema,
ZDocumentMetaDrawSignatureEnabledSchema,
ZDocumentMetaIncludeAuditLogSchema,
ZDocumentMetaLanguageSchema,
ZDocumentMetaMessageSchema,
ZDocumentMetaRedirectUrlSchema,
@@ -59,6 +60,7 @@ export const ZCreateEmbeddingDocumentRequestSchema = z.object({
timezone: ZDocumentMetaTimezoneSchema.optional(),
dateFormat: ZDocumentMetaDateFormatSchema.optional(),
distributionMethod: ZDocumentMetaDistributionMethodSchema.optional(),
includeAuditLog: ZDocumentMetaIncludeAuditLogSchema.optional(),
signingOrder: z.nativeEnum(DocumentSigningOrder).optional(),
redirectUrl: ZDocumentMetaRedirectUrlSchema.optional(),
language: ZDocumentMetaLanguageSchema.optional(),
@@ -3,6 +3,7 @@ import {
ZDocumentMetaDateFormatSchema,
ZDocumentMetaDistributionMethodSchema,
ZDocumentMetaDrawSignatureEnabledSchema,
ZDocumentMetaIncludeAuditLogSchema,
ZDocumentMetaLanguageSchema,
ZDocumentMetaMessageSchema,
ZDocumentMetaRedirectUrlSchema,
@@ -56,6 +57,7 @@ export const ZCreateEmbeddingTemplateRequestSchema = z.object({
timezone: ZDocumentMetaTimezoneSchema.optional(),
dateFormat: ZDocumentMetaDateFormatSchema.optional(),
distributionMethod: ZDocumentMetaDistributionMethodSchema.optional(),
includeAuditLog: ZDocumentMetaIncludeAuditLogSchema.optional(),
signingOrder: z.nativeEnum(DocumentSigningOrder).optional(),
redirectUrl: ZDocumentMetaRedirectUrlSchema.optional(),
language: ZDocumentMetaLanguageSchema.optional(),
@@ -21,6 +21,7 @@ export const ZGetMultiSignDocumentResponseSchema = ZDocumentLiteSchema.extend({
documentMeta: DocumentMetaSchema.pick({
signingOrder: true,
distributionMethod: true,
includeAuditLog: true,
id: true,
subject: true,
message: true,
@@ -3,6 +3,7 @@ import {
ZDocumentMetaDateFormatSchema,
ZDocumentMetaDistributionMethodSchema,
ZDocumentMetaDrawSignatureEnabledSchema,
ZDocumentMetaIncludeAuditLogSchema,
ZDocumentMetaLanguageSchema,
ZDocumentMetaMessageSchema,
ZDocumentMetaRedirectUrlSchema,
@@ -60,6 +61,7 @@ export const ZUpdateEmbeddingDocumentRequestSchema = z.object({
timezone: ZDocumentMetaTimezoneSchema.optional(),
dateFormat: ZDocumentMetaDateFormatSchema.optional(),
distributionMethod: ZDocumentMetaDistributionMethodSchema.optional(),
includeAuditLog: ZDocumentMetaIncludeAuditLogSchema.optional(),
signingOrder: z.nativeEnum(DocumentSigningOrder).optional(),
redirectUrl: ZDocumentMetaRedirectUrlSchema.optional(),
language: ZDocumentMetaLanguageSchema.optional(),
@@ -3,6 +3,7 @@ import {
ZDocumentMetaDateFormatSchema,
ZDocumentMetaDistributionMethodSchema,
ZDocumentMetaDrawSignatureEnabledSchema,
ZDocumentMetaIncludeAuditLogSchema,
ZDocumentMetaLanguageSchema,
ZDocumentMetaMessageSchema,
ZDocumentMetaRedirectUrlSchema,
@@ -60,6 +61,7 @@ export const ZUpdateEmbeddingTemplateRequestSchema = z.object({
timezone: ZDocumentMetaTimezoneSchema.optional(),
dateFormat: ZDocumentMetaDateFormatSchema.optional(),
distributionMethod: ZDocumentMetaDistributionMethodSchema.optional(),
includeAuditLog: ZDocumentMetaIncludeAuditLogSchema.optional(),
signingOrder: z.nativeEnum(DocumentSigningOrder).optional(),
redirectUrl: ZDocumentMetaRedirectUrlSchema.optional(),
language: ZDocumentMetaLanguageSchema.optional(),
@@ -1,10 +1,9 @@
import { ZNameSchema } from '@documenso/lib/types/name';
import { zEmail } from '@documenso/lib/utils/zod';
import { z } from 'zod';
export const ZCreateOrganisationEmailRequestSchema = z.object({
emailDomainId: z.string(),
emailName: ZNameSchema,
emailName: z.string().min(1).max(100),
email: zEmail().toLowerCase(),
// This does not need to be validated to be part of the domain.
@@ -124,9 +124,7 @@ export const createEnvelopeRouteCaller = async ({
});
// Todo: Embeds - Might need to add this for client-side embeds in the future.
const { cleanedPdf, placeholders } = await extractPdfPlaceholders(normalized, {
fileName: file.name,
});
const { cleanedPdf, placeholders } = await extractPdfPlaceholders(normalized);
const { documentData } = await putPdfFileServerSide({
name: file.name,
@@ -37,6 +37,7 @@ export const distributeEnvelopeRoute = authenticatedProcedure
timezone: meta.timezone,
redirectUrl: meta.redirectUrl,
distributionMethod: meta.distributionMethod,
includeAuditLog: meta.includeAuditLog,
emailSettings: meta.emailSettings ?? undefined,
language: meta.language,
emailId: meta.emailId,
@@ -23,6 +23,7 @@ export const ZDistributeEnvelopeRequestSchema = z.object({
timezone: true,
dateFormat: true,
distributionMethod: true,
includeAuditLog: true,
redirectUrl: true,
language: true,
emailId: true,
@@ -5,6 +5,7 @@ import {
ZDocumentMetaDateFormatSchema,
ZDocumentMetaDistributionMethodSchema,
ZDocumentMetaDrawSignatureEnabledSchema,
ZDocumentMetaIncludeAuditLogSchema,
ZDocumentMetaLanguageSchema,
ZDocumentMetaMessageSchema,
ZDocumentMetaRedirectUrlSchema,
@@ -89,6 +90,7 @@ export const ZUseEnvelopePayloadSchema = z.object({
dateFormat: ZDocumentMetaDateFormatSchema.optional(),
redirectUrl: ZDocumentMetaRedirectUrlSchema.optional(),
distributionMethod: ZDocumentMetaDistributionMethodSchema.optional(),
includeAuditLog: ZDocumentMetaIncludeAuditLogSchema.optional(),
emailSettings: ZDocumentEmailSettingsSchema.optional(),
language: ZDocumentMetaLanguageSchema.optional(),
typedSignatureEnabled: ZDocumentMetaTypedSignatureEnabledSchema.optional(),
+2 -3
View File
@@ -1,5 +1,4 @@
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 { DocumentVisibility } from '@documenso/prisma/generated/types';
import FolderSchema from '@documenso/prisma/generated/zod/modelSchema/FolderSchema';
@@ -43,7 +42,7 @@ const ZFolderParentIdSchema = z
.describe('The folder ID to place this folder within. Leave empty to place folder at the root level.');
export const ZCreateFolderRequestSchema = z.object({
name: ZNameSchema,
name: z.string(),
parentId: ZFolderParentIdSchema.optional(),
type: ZFolderTypeSchema.optional(),
});
@@ -53,7 +52,7 @@ export const ZCreateFolderResponseSchema = ZFolderSchema;
export const ZUpdateFolderRequestSchema = z.object({
folderId: z.string().describe('The ID of the folder to update'),
data: z.object({
name: ZNameSchema.optional().describe('The name of the folder'),
name: z.string().optional().describe('The name of the folder'),
parentId: ZFolderParentIdSchema.optional().nullable(),
visibility: z.nativeEnum(DocumentVisibility).optional().describe('The visibility of the folder'),
pinned: z.boolean().optional().describe('Whether the folder should be pinned'),
@@ -1,4 +1,3 @@
import { ZNameSchema } from '@documenso/lib/types/name';
import { OrganisationMemberRole } from '@prisma/client';
import { z } from 'zod';
@@ -15,7 +14,7 @@ import { z } from 'zod';
export const ZCreateOrganisationGroupRequestSchema = z.object({
organisationId: z.string(),
organisationRole: z.nativeEnum(OrganisationMemberRole),
name: ZNameSchema,
name: z.string().max(100),
memberIds: z.array(z.string()),
});
@@ -1,4 +1,3 @@
import { ZNameSchema } from '@documenso/lib/types/name';
import { z } from 'zod';
// export const createOrganisationMeta: TrpcOpenApiMeta = {
@@ -11,8 +10,13 @@ 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({
name: ZNameSchema,
name: ZOrganisationNameSchema,
priceId: z.string().optional(),
});
@@ -20,7 +20,6 @@ import { getOrganisationsRoute } from './get-organisations';
import { leaveOrganisationRoute } from './leave-organisation';
import { resendOrganisationMemberInviteRoute } from './resend-organisation-member-invite';
import { updateOrganisationRoute } from './update-organisation';
import { updateOrganisationBrandingLogoRoute } from './update-organisation-branding-logo';
import { updateOrganisationGroupRoute } from './update-organisation-group';
import { updateOrganisationMemberRoute } from './update-organisation-members';
import { updateOrganisationSettingsRoute } from './update-organisation-settings';
@@ -56,7 +55,6 @@ export const organisationRouter = router({
},
settings: {
update: updateOrganisationSettingsRoute,
updateBrandingLogo: updateOrganisationBrandingLogoRoute,
},
internal: {
getOrganisationSession: getOrganisationSessionRoute,
@@ -1,69 +0,0 @@
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,
},
},
},
});
});

Some files were not shown because too many files have changed in this diff Show More