Compare commits

...

12 Commits

Author SHA1 Message Date
Catalin Pit ac51bc72ee Merge branch 'main' into feat/default-reset-button-document-preferences 2026-07-08 10:33:47 +03:00
roshboi 1b1e3d197b Add "Apply Your License Key" self-hosting guide (#3077)
Adds a dedicated self-hosting page documenting how to apply an
Enterprise license key, and cross-links it from the related docs.

New page: self-hosting/configuration/license.mdx — "Apply Your License
Key"
Set NEXT_PRIVATE_DOCUMENSO_LICENSE_KEY (Docker Compose / docker run /
.env tabs)
2026-07-08 16:11:49 +10:00
Lucas Smith a276e18e1f chore: upgrade deps (#3065) 2026-07-08 16:11:00 +10:00
Catalin Pit 6b81699cce chore: fix issues 2026-07-07 10:55:14 +03:00
Catalin Pit c849b8b017 chore: merged main 2026-07-07 10:32:29 +03:00
Catalin Pit 50f272be87 fix: admin organisation limits and usage UI (#3014) 2026-07-02 16:50:11 +10:00
Catalin Pit a55e6d9484 fix: block invisible & control characters and URLs in names (#2978) 2026-07-02 16:20:56 +10:00
David Nguyen d35d13db23 fix: remove presigned branding upload (#3053) 2026-07-02 15:51:19 +10:00
Catalin Pit 13e7084836 Merge branch 'main' into feat/default-reset-button-document-preferences 2026-06-29 10:11:25 +03:00
Catalin Pit d83572dd53 chore: simplify code 2026-06-29 10:07:38 +03:00
Catalin Pit 08e5acd0b2 chore: improve document preference form's default settings and reset functionality 2026-06-26 14:25:26 +03:00
Catalin Pit 1b55b3f7ea feat: add DocumentPreferencesResetDialog component 2026-06-26 14:21:44 +03:00
86 changed files with 4113 additions and 1922 deletions
@@ -76,6 +76,8 @@ The Enterprise Edition is required when you:
4. Restart your Documenso instance 4. Restart your Documenso instance
5. Verify the license is active in the **Admin Panel** under the **Stats** section 5. Verify the license is active in the **Admin Panel** under the **Stats** section
See [Apply Your License Key](/docs/self-hosting/configuration/license) for the full walkthrough, including how to enable individual features once licensed.
</Accordion> </Accordion>
</Accordions> </Accordions>
@@ -197,7 +199,7 @@ See [Support](/docs/policies/support) for complete support options.
1. Sign the Enterprise license agreement 1. Sign the Enterprise license agreement
2. Receive license key and access credentials 2. Receive license key and access credentials
3. Deploy using [self-hosting guides](/docs/self-hosting) or access Documenso Cloud 3. Deploy using [self-hosting guides](/docs/self-hosting) or access Documenso Cloud
4. Configure Enterprise features with support assistance 4. Apply the key — see [Apply Your License Key](/docs/self-hosting/configuration/license) — and configure Enterprise features with support assistance
</Step> </Step>
<Step> <Step>
@@ -238,6 +240,7 @@ See [Support](/docs/policies/support) for complete support options.
## Related ## Related
- [Apply Your License Key](/docs/self-hosting/configuration/license) - Step-by-step license activation
- [Community Edition](/docs/policies/community-edition) - AGPL-3.0 open-source license - [Community Edition](/docs/policies/community-edition) - AGPL-3.0 open-source license
- [Licenses](/docs/policies/licenses) - Complete licensing overview and FAQ - [Licenses](/docs/policies/licenses) - Complete licensing overview and FAQ
- [Support](/docs/policies/support) - Support channels and response times - [Support](/docs/policies/support) - Support channels and response times
@@ -443,11 +443,11 @@ Telemetry collects only: app version, installation ID, and node ID. No personal
## Enterprise Features ## Enterprise Features
These variables require an active [Enterprise Edition](/docs/policies/enterprise-edition) license. Obtain a license key from [license.documenso.com](https://license.documenso.com) and set it below to unlock enterprise features such as SSO, embed editor, and 21 CFR Part 11 compliance. These variables require an active [Enterprise Edition](/docs/policies/enterprise-edition) license. Obtain a license key from [license.documenso.com](https://license.documenso.com) and set it below to unlock enterprise features such as SSO, embed editor, and 21 CFR Part 11 compliance. See [Apply Your License Key](/docs/self-hosting/configuration/license) for step-by-step setup.
| Variable | Description | | Variable | Description |
| ------------------------------------ | ------------------------------------------------ | | ------------------------------------ | ------------------------------------------------ |
| `NEXT_PRIVATE_DOCUMENSO_LICENSE_KEY` | License key for enterprise features | | `NEXT_PRIVATE_DOCUMENSO_LICENSE_KEY` | License key for enterprise features — see [Apply Your License Key](/docs/self-hosting/configuration/license) for how to apply it |
| `NEXT_PRIVATE_STRIPE_API_KEY` | Stripe API key for billing | | `NEXT_PRIVATE_STRIPE_API_KEY` | Stripe API key for billing |
| `NEXT_PRIVATE_STRIPE_WEBHOOK_SECRET` | Stripe webhook secret | | `NEXT_PRIVATE_STRIPE_WEBHOOK_SECRET` | Stripe webhook secret |
| `NEXT_PRIVATE_SES_ACCESS_KEY_ID` | AWS SES access key for email domain verification | | `NEXT_PRIVATE_SES_ACCESS_KEY_ID` | AWS SES access key for email domain verification |
@@ -0,0 +1,107 @@
---
title: Apply Your License Key
description: Activate your Enterprise license key to unlock enterprise features on your self-hosted instance.
---
import { Accordion, Accordions } from 'fumadocs-ui/components/accordion';
import { Callout } from 'fumadocs-ui/components/callout';
import { Tab, Tabs } from 'fumadocs-ui/components/tabs';
A license key activates the Enterprise features available to your self-hosted instance, such as CSC signing, SSO, embed white-labelling, and 21 CFR Part 11 compliance.
<Callout type="info">
The license key applies to your **whole instance**, not an individual user account. There's one
key per deployment.
</Callout>
## Prerequisites
- An active Enterprise license key — contact [sales](https://documen.so/enterprise) to set up an
Enterprise subscription, then copy your key from [license.documenso.com](https://license.documenso.com).
See [Enterprise Edition](/docs/policies/enterprise-edition) for details.
- A running self-hosted Documenso instance that you're able to restart
## Step 1: Set the environment variable
Set `NEXT_PRIVATE_DOCUMENSO_LICENSE_KEY` to your license key.
<Tabs items={['Docker Compose', 'docker run', '.env']}>
<Tab value="Docker Compose">
Add the variable to your `.env` file (or directly under `environment:` in `compose.yml`):
```bash
NEXT_PRIVATE_DOCUMENSO_LICENSE_KEY=your-license-key-here
```
Then apply it:
```bash
docker compose up -d
```
</Tab>
<Tab value="docker run">
```bash
docker run -d \
--name documenso \
-e NEXT_PRIVATE_DOCUMENSO_LICENSE_KEY=your-license-key-here \
documenso/documenso:latest
```
</Tab>
<Tab value=".env">
If you're running Documenso directly (not in a container), add the variable to your `.env` file:
```bash
NEXT_PRIVATE_DOCUMENSO_LICENSE_KEY=your-license-key-here
```
</Tab>
</Tabs>
## Step 2: Restart the instance
The license key is only read once, at process startup. Setting the variable in a running container or shell has no effect until the process restarts.
```bash
# Docker Compose
docker compose restart documenso
# Docker
docker restart documenso
```
On startup, Documenso validates the key against the Documenso license server and caches the result locally for future startups, so a brief license-server outage won't lock you out.
## What the license enables
A valid license doesn't turn every enterprise feature on everywhere — activation depends on the feature:
- **CSC signing** activates instance-wide automatically once the license is active and CSC transport is configured. See [CSC / QES Signing](/docs/self-hosting/configuration/signing-certificate/csc-qes) for the full setup.
- **SSO, embed white-labelling, 21 CFR Part 11, and similar** are provisioned per organisation. Follow each feature's own guide to configure it once the license is active.
## Troubleshooting
<Accordions type="multiple">
<Accordion title="Enterprise features are still unavailable after applying the key">
- Confirm the key is present in the environment the running process actually reads — `docker
exec` into the container and check `env | grep LICENSE` if unsure.
- Confirm the instance was fully restarted after the variable was set, not just reloaded.
- Re-copy the key to rule out truncation or accidental whitespace.
</Accordion>
<Accordion title="A specific feature still isn't working">
Instance-wide features (like CSC signing) also need their own configuration — an active license
alone isn't enough. Check that feature's guide to confirm the required settings are in place.
Per-organisation features additionally need to be provisioned for the organisation that's using
them.
</Accordion>
</Accordions>
## See Also
- [Environment Variables](/docs/self-hosting/configuration/environment) - Complete configuration reference
- [Enterprise Edition](/docs/policies/enterprise-edition) - What's included and how to purchase a license
- [CSC / QES Signing](/docs/self-hosting/configuration/signing-certificate/csc-qes) - Enable CSC-based signing
@@ -2,6 +2,7 @@
"title": "Configuration", "title": "Configuration",
"pages": [ "pages": [
"environment", "environment",
"license",
"database", "database",
"email", "email",
"storage", "storage",
@@ -49,7 +49,7 @@ The callback URL is fixed — Documenso derives it from `NEXT_PUBLIC_WEBAPP_URL`
### Enterprise Edition license ### Enterprise Edition license
CSC mode is gated by the `instanceCscSigning` license flag. Without a valid Enterprise license, the transport refuses to start (`CSC_UNLICENSED`). CSC mode is gated by the `instanceCscSigning` license flag. Without a valid Enterprise license, the transport refuses to start (`CSC_UNLICENSED`). See [Apply Your License Key](/docs/self-hosting/configuration/license) to activate one.
</Step> </Step>
<Step> <Step>
@@ -141,7 +141,7 @@ See the [Quick Start guide](/docs/self-hosting/getting-started/quick-start) for
Self-hosted Documenso includes full core functionality under the AGPL-3.0 license. If you need enterprise features such as SSO, embed editor white label, or 21 CFR Part 11 compliance, you can activate them with a license key. Self-hosted Documenso includes full core functionality under the AGPL-3.0 license. If you need enterprise features such as SSO, embed editor white label, or 21 CFR Part 11 compliance, you can activate them with a license key.
See [Enterprise Edition](/docs/policies/enterprise-edition) for details and [Licenses](/docs/policies/licenses) for a comparison. See [Enterprise Edition](/docs/policies/enterprise-edition) for details and [Licenses](/docs/policies/licenses) for a comparison. Already have a key? See [Apply Your License Key](/docs/self-hosting/configuration/license).
--- ---
@@ -0,0 +1,140 @@
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@documenso/ui/primitives/dialog';
import { Trans } from '@lingui/react/macro';
import { useState } from 'react';
export type DocumentPreferencesResetDialogProps = {
disabled?: boolean;
isSubmitting: boolean;
onReset: () => Promise<void>;
showAiFeatures?: boolean;
showDocumentVisibility?: boolean;
showIncludeSenderDetails?: boolean;
};
export const DocumentPreferencesResetDialog = ({
disabled = false,
isSubmitting,
onReset,
showAiFeatures = false,
showDocumentVisibility = false,
showIncludeSenderDetails = false,
}: DocumentPreferencesResetDialogProps) => {
const [open, setOpen] = useState(false);
const [isResetting, setIsResetting] = useState(false);
const isLoading = isSubmitting || isResetting;
const handleResetToDefaults = async () => {
setIsResetting(true);
try {
await onReset();
setOpen(false);
} finally {
setIsResetting(false);
}
};
return (
<Dialog open={open} onOpenChange={(value) => !isLoading && setOpen(value)}>
<DialogTrigger asChild>
<Button variant="destructive" type="button" size="sm" disabled={disabled || isLoading}>
<Trans>Reset to defaults</Trans>
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>
<Trans>Reset document preferences</Trans>
</DialogTitle>
<DialogDescription>
<Trans>
This will reset all document preferences to their default values and save the changes immediately.
</Trans>
</DialogDescription>
</DialogHeader>
<Alert variant="warning">
<AlertDescription>
<p>
<Trans>Once confirmed, the following will be reset:</Trans>
</p>
<ul className="mt-0.5 list-inside list-disc">
{showDocumentVisibility && (
<li>
<Trans>Default document visibility</Trans>
</li>
)}
<li>
<Trans>Default document language</Trans>
</li>
<li>
<Trans>Default date format</Trans>
</li>
<li>
<Trans>Default time zone</Trans>
</li>
<li>
<Trans>Default signature settings</Trans>
</li>
{showIncludeSenderDetails && (
<li>
<Trans>Send on behalf of team</Trans>
</li>
)}
<li>
<Trans>Include the signing certificate in the document</Trans>
</li>
<li>
<Trans>Include the audit logs in the document</Trans>
</li>
<li>
<Trans>Default recipients</Trans>
</li>
<li>
<Trans>Delegate document ownership</Trans>
</li>
<li>
<Trans>Default envelope expiration</Trans>
</li>
<li>
<Trans>Default signing reminders</Trans>
</li>
{showAiFeatures && (
<li>
<Trans>AI features</Trans>
</li>
)}
</ul>
</AlertDescription>
</Alert>
<DialogFooter>
<DialogClose asChild>
<Button type="button" variant="secondary" disabled={isLoading}>
<Trans>Cancel</Trans>
</Button>
</DialogClose>
<Button type="button" variant="destructive" loading={isLoading} onClick={() => void handleResetToDefaults()}>
<Trans>Reset to defaults</Trans>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
@@ -1,3 +1,4 @@
import { ZNameSchema } from '@documenso/lib/types/name';
import { trpc } from '@documenso/trpc/react'; import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
import { import {
@@ -23,7 +24,7 @@ import { useParams } from 'react-router';
import { z } from 'zod'; import { z } from 'zod';
const ZCreateFolderFormSchema = z.object({ const ZCreateFolderFormSchema = z.object({
name: z.string().min(1, { message: 'Folder name is required' }), name: ZNameSchema,
}); });
type TCreateFolderFormSchema = z.infer<typeof ZCreateFolderFormSchema>; type TCreateFolderFormSchema = z.infer<typeof ZCreateFolderFormSchema>;
@@ -65,7 +66,7 @@ export const FolderCreateDialog = ({ type, trigger, parentFolderId, ...props }:
toast({ toast({
description: t`Folder created successfully`, description: t`Folder created successfully`,
}); });
} catch (err) { } catch (_err) {
toast({ toast({
title: t`Failed to create folder`, title: t`Failed to create folder`,
description: t`An unknown error occurred while creating the folder.`, description: t`An unknown error occurred while creating the folder.`,
@@ -1,5 +1,6 @@
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { DocumentVisibility } from '@documenso/lib/types/document-visibility'; import { DocumentVisibility } from '@documenso/lib/types/document-visibility';
import { ZNameSchema } from '@documenso/lib/types/name';
import { trpc } from '@documenso/trpc/react'; import { trpc } from '@documenso/trpc/react';
import type { TFolderWithSubfolders } from '@documenso/trpc/server/folder-router/schema'; import type { TFolderWithSubfolders } from '@documenso/trpc/server/folder-router/schema';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
@@ -23,8 +24,6 @@ import { useEffect } from 'react';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { z } from 'zod'; import { z } from 'zod';
import { useOptionalCurrentTeam } from '~/providers/team';
export type FolderUpdateDialogProps = { export type FolderUpdateDialogProps = {
folder: TFolderWithSubfolders | null; folder: TFolderWithSubfolders | null;
isOpen: boolean; isOpen: boolean;
@@ -32,7 +31,7 @@ export type FolderUpdateDialogProps = {
} & Omit<DialogPrimitive.DialogProps, 'children'>; } & Omit<DialogPrimitive.DialogProps, 'children'>;
export const ZUpdateFolderFormSchema = z.object({ export const ZUpdateFolderFormSchema = z.object({
name: z.string().min(1), name: ZNameSchema,
visibility: z.nativeEnum(DocumentVisibility).optional(), visibility: z.nativeEnum(DocumentVisibility).optional(),
}); });
@@ -40,7 +39,6 @@ export type TUpdateFolderFormSchema = z.infer<typeof ZUpdateFolderFormSchema>;
export const FolderUpdateDialog = ({ folder, isOpen, onOpenChange }: FolderUpdateDialogProps) => { export const FolderUpdateDialog = ({ folder, isOpen, onOpenChange }: FolderUpdateDialogProps) => {
const { t } = useLingui(); const { t } = useLingui();
const team = useOptionalCurrentTeam();
const { toast } = useToast(); const { toast } = useToast();
const { mutateAsync: updateFolder } = trpc.folder.updateFolder.useMutation(); const { mutateAsync: updateFolder } = trpc.folder.updateFolder.useMutation();
@@ -1,5 +1,6 @@
import { MAXIMUM_PASSKEYS } from '@documenso/lib/constants/auth'; import { MAXIMUM_PASSKEYS } from '@documenso/lib/constants/auth';
import { AppError } from '@documenso/lib/errors/app-error'; import { AppError } from '@documenso/lib/errors/app-error';
import { ZNameSchema } from '@documenso/lib/types/name';
import { trpc } from '@documenso/trpc/react'; import { trpc } from '@documenso/trpc/react';
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert'; import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
@@ -25,14 +26,13 @@ import { useForm } from 'react-hook-form';
import { match } from 'ts-pattern'; import { match } from 'ts-pattern';
import { UAParser } from 'ua-parser-js'; import { UAParser } from 'ua-parser-js';
import { z } from 'zod'; import { z } from 'zod';
export type PasskeyCreateDialogProps = { export type PasskeyCreateDialogProps = {
trigger?: React.ReactNode; trigger?: React.ReactNode;
onSuccess?: () => void; onSuccess?: () => void;
} & Omit<DialogPrimitive.DialogProps, 'children'>; } & Omit<DialogPrimitive.DialogProps, 'children'>;
const ZCreatePasskeyFormSchema = z.object({ const ZCreatePasskeyFormSchema = z.object({
passkeyName: z.string().min(3), passkeyName: ZNameSchema,
}); });
type TCreatePasskeyFormSchema = z.infer<typeof ZCreatePasskeyFormSchema>; type TCreatePasskeyFormSchema = z.infer<typeof ZCreatePasskeyFormSchema>;
@@ -1,4 +1,5 @@
import { trpc } from '@documenso/trpc/react'; import { trpc } from '@documenso/trpc/react';
import { ZUpdateTeamEmailMutationSchema } from '@documenso/trpc/server/team-router/schema';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
import { import {
Dialog, Dialog,
@@ -19,16 +20,16 @@ import type * as DialogPrimitive from '@radix-ui/react-dialog';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { useRevalidator } from 'react-router'; import { useRevalidator } from 'react-router';
import { z } from 'zod'; import type { z } from 'zod';
export type TeamEmailUpdateDialogProps = { export type TeamEmailUpdateDialogProps = {
teamEmail: TeamEmail; teamEmail: TeamEmail;
trigger?: React.ReactNode; trigger?: React.ReactNode;
} & Omit<DialogPrimitive.DialogProps, 'children'>; } & Omit<DialogPrimitive.DialogProps, 'children'>;
const ZUpdateTeamEmailFormSchema = z.object({ const ZUpdateTeamEmailFormSchema = ZUpdateTeamEmailMutationSchema.pick({
name: z.string().trim().min(1, { message: 'Please enter a valid name.' }), data: true,
}); }).shape.data;
type TUpdateTeamEmailFormSchema = z.infer<typeof ZUpdateTeamEmailFormSchema>; type TUpdateTeamEmailFormSchema = z.infer<typeof ZUpdateTeamEmailFormSchema>;
@@ -44,6 +45,7 @@ export const TeamEmailUpdateDialog = ({ teamEmail, trigger, ...props }: TeamEmai
defaultValues: { defaultValues: {
name: teamEmail.name, name: teamEmail.name,
}, },
mode: 'onSubmit',
}); });
const { mutateAsync: updateTeamEmail } = trpc.team.email.update.useMutation(); const { mutateAsync: updateTeamEmail } = trpc.team.email.update.useMutation();
@@ -1,5 +1,10 @@
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation'; import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app'; import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import {
BRANDING_LOGO_ALLOWED_TYPES,
BRANDING_LOGO_MAX_SIZE_BYTES,
BRANDING_LOGO_MAX_SIZE_MB,
} from '@documenso/lib/constants/branding';
import { DEFAULT_BRAND_COLORS, DEFAULT_BRAND_RADIUS } from '@documenso/lib/constants/theme'; import { DEFAULT_BRAND_COLORS, DEFAULT_BRAND_RADIUS } from '@documenso/lib/constants/theme';
import { ZCssVarsSchema } from '@documenso/lib/types/css-vars'; import { ZCssVarsSchema } from '@documenso/lib/types/css-vars';
import { cn } from '@documenso/ui/lib/utils'; import { cn } from '@documenso/ui/lib/utils';
@@ -23,15 +28,15 @@ import { useCspNonce } from '~/utils/nonce';
import { FormStickySaveBar } from './form-sticky-save-bar'; import { FormStickySaveBar } from './form-sticky-save-bar';
const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB
const ACCEPTED_FILE_TYPES = ['image/jpeg', 'image/png', 'image/webp'];
const ZBrandingPreferencesFormSchema = z.object({ const ZBrandingPreferencesFormSchema = z.object({
brandingEnabled: z.boolean().nullable(), brandingEnabled: z.boolean().nullable(),
brandingLogo: z brandingLogo: z
.instanceof(File) .instanceof(File)
.refine((file) => file.size <= MAX_FILE_SIZE, 'File size must be less than 5MB') .refine(
.refine((file) => ACCEPTED_FILE_TYPES.includes(file.type), 'Only .jpg, .png, and .webp files are accepted') (file) => file.size <= BRANDING_LOGO_MAX_SIZE_BYTES,
`File size must be less than ${BRANDING_LOGO_MAX_SIZE_MB}MB`,
)
.refine((file) => BRANDING_LOGO_ALLOWED_TYPES.includes(file.type), 'Only .jpg, .png, and .webp files are accepted')
.nullish(), .nullish(),
brandingUrl: z.string().url().optional().or(z.literal('')), brandingUrl: z.string().url().optional().or(z.literal('')),
brandingCompanyDetails: z.string().max(500).optional(), brandingCompanyDetails: z.string().max(500).optional(),
@@ -245,7 +250,7 @@ export function BrandingPreferencesForm({
<FormControl className="relative"> <FormControl className="relative">
<Input <Input
type="file" type="file"
accept={ACCEPTED_FILE_TYPES.join(',')} accept={BRANDING_LOGO_ALLOWED_TYPES.join(',')}
disabled={!isBrandingEnabled} disabled={!isBrandingEnabled}
onChange={(e) => { onChange={(e) => {
const file = e.target.files?.[0]; const file = e.target.files?.[0];
@@ -11,10 +11,10 @@ import { isValidLanguageCode, SUPPORTED_LANGUAGE_CODES, SUPPORTED_LANGUAGES } fr
import { TIME_ZONES } from '@documenso/lib/constants/time-zones'; import { TIME_ZONES } from '@documenso/lib/constants/time-zones';
import type { TDefaultRecipients } from '@documenso/lib/types/default-recipients'; import type { TDefaultRecipients } from '@documenso/lib/types/default-recipients';
import { ZDefaultRecipientsSchema } from '@documenso/lib/types/default-recipients'; import { ZDefaultRecipientsSchema } from '@documenso/lib/types/default-recipients';
import { type TDocumentMetaDateFormat, ZDocumentMetaTimezoneSchema } from '@documenso/lib/types/document-meta'; import { type TDocumentMetaDateFormat, ZDocumentMetaDateFormatSchema } from '@documenso/lib/types/document-meta';
import { isPersonalLayout } from '@documenso/lib/utils/organisations'; import { generateDefaultOrganisationSettings, isPersonalLayout } from '@documenso/lib/utils/organisations';
import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter'; import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter';
import { extractTeamSignatureSettings } from '@documenso/lib/utils/teams'; import { extractTeamSignatureSettings, generateDefaultTeamSettings } from '@documenso/lib/utils/teams';
import { DocumentSignatureSettingsTooltip } from '@documenso/ui/components/document/document-signature-settings-tooltip'; import { DocumentSignatureSettingsTooltip } from '@documenso/ui/components/document/document-signature-settings-tooltip';
import { ExpirationPeriodPicker } from '@documenso/ui/components/document/expiration-period-picker'; import { ExpirationPeriodPicker } from '@documenso/ui/components/document/expiration-period-picker';
import { ReminderSettingsPicker } from '@documenso/ui/components/document/reminder-settings-picker'; import { ReminderSettingsPicker } from '@documenso/ui/components/document/reminder-settings-picker';
@@ -37,11 +37,11 @@ import { zodResolver } from '@hookform/resolvers/zod';
import { msg, t } from '@lingui/core/macro'; import { msg, t } from '@lingui/core/macro';
import { useLingui } from '@lingui/react'; import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro'; import { Trans } from '@lingui/react/macro';
import type { TeamGlobalSettings } from '@prisma/client'; import { DocumentVisibility, OrganisationType, type RecipientRole, type TeamGlobalSettings } from '@prisma/client';
import { DocumentVisibility, OrganisationType, type RecipientRole } from '@prisma/client';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { z } from 'zod'; import { z } from 'zod';
import { DocumentPreferencesResetDialog } from '~/components/dialogs/document-preferences-reset-dialog';
import { useOptionalCurrentTeam } from '~/providers/team'; import { useOptionalCurrentTeam } from '~/providers/team';
import { DefaultRecipientsMultiSelectCombobox } from '../general/default-recipients-multiselect-combobox'; import { DefaultRecipientsMultiSelectCombobox } from '../general/default-recipients-multiselect-combobox';
@@ -93,6 +93,26 @@ export type DocumentPreferencesFormProps = {
onFormSubmit: (data: TDocumentPreferencesFormSchema) => Promise<void>; onFormSubmit: (data: TDocumentPreferencesFormSchema) => Promise<void>;
}; };
const getDocumentPreferencesFormValues = (settings: SettingsSubset): TDocumentPreferencesFormSchema => {
const parsedDocumentDateFormat = ZDocumentMetaDateFormatSchema.safeParse(settings.documentDateFormat);
return {
documentVisibility: settings.documentVisibility,
documentLanguage: isValidLanguageCode(settings.documentLanguage) ? settings.documentLanguage : null,
documentTimezone: settings.documentTimezone,
documentDateFormat: parsedDocumentDateFormat.success ? parsedDocumentDateFormat.data : null,
includeSenderDetails: settings.includeSenderDetails,
includeSigningCertificate: settings.includeSigningCertificate,
includeAuditLog: settings.includeAuditLog,
signatureTypes: extractTeamSignatureSettings({ ...settings }),
defaultRecipients: settings.defaultRecipients ? ZDefaultRecipientsSchema.parse(settings.defaultRecipients) : null,
delegateDocumentOwnership: settings.delegateDocumentOwnership,
aiFeaturesEnabled: settings.aiFeaturesEnabled,
envelopeExpirationPeriod: settings.envelopeExpirationPeriod ?? null,
reminderSettings: settings.reminderSettings ?? null,
};
};
export const DocumentPreferencesForm = ({ export const DocumentPreferencesForm = ({
settings, settings,
onFormSubmit, onFormSubmit,
@@ -113,7 +133,7 @@ export const DocumentPreferencesForm = ({
documentVisibility: z.nativeEnum(DocumentVisibility).nullable(), documentVisibility: z.nativeEnum(DocumentVisibility).nullable(),
documentLanguage: z.enum(SUPPORTED_LANGUAGE_CODES).nullable(), documentLanguage: z.enum(SUPPORTED_LANGUAGE_CODES).nullable(),
documentTimezone: z.string().nullable(), documentTimezone: z.string().nullable(),
documentDateFormat: ZDocumentMetaTimezoneSchema.nullable(), documentDateFormat: ZDocumentMetaDateFormatSchema.nullable(),
includeSenderDetails: z.boolean().nullable(), includeSenderDetails: z.boolean().nullable(),
includeSigningCertificate: z.boolean().nullable(), includeSigningCertificate: z.boolean().nullable(),
includeAuditLog: z.boolean().nullable(), includeAuditLog: z.boolean().nullable(),
@@ -127,26 +147,27 @@ export const DocumentPreferencesForm = ({
reminderSettings: ZEnvelopeReminderSettings.nullable(), reminderSettings: ZEnvelopeReminderSettings.nullable(),
}); });
const defaultValues = getDocumentPreferencesFormValues(settings);
const defaultSettings = canInherit ? generateDefaultTeamSettings() : generateDefaultOrganisationSettings();
const baseResetValues = getDocumentPreferencesFormValues(defaultSettings);
const resetValues = {
...baseResetValues,
aiFeaturesEnabled: isAiFeaturesConfigured ? baseResetValues.aiFeaturesEnabled : defaultValues.aiFeaturesEnabled,
};
const form = useForm<TDocumentPreferencesFormSchema>({ const form = useForm<TDocumentPreferencesFormSchema>({
defaultValues: { defaultValues,
documentVisibility: settings.documentVisibility,
documentLanguage: isValidLanguageCode(settings.documentLanguage) ? settings.documentLanguage : null,
documentTimezone: settings.documentTimezone,
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
documentDateFormat: settings.documentDateFormat as TDocumentMetaDateFormat | null,
includeSenderDetails: settings.includeSenderDetails,
includeSigningCertificate: settings.includeSigningCertificate,
includeAuditLog: settings.includeAuditLog,
signatureTypes: extractTeamSignatureSettings({ ...settings }),
defaultRecipients: settings.defaultRecipients ? ZDefaultRecipientsSchema.parse(settings.defaultRecipients) : null,
delegateDocumentOwnership: settings.delegateDocumentOwnership,
aiFeaturesEnabled: settings.aiFeaturesEnabled,
envelopeExpirationPeriod: settings.envelopeExpirationPeriod ?? null,
reminderSettings: settings.reminderSettings ?? null,
},
resolver: zodResolver(ZDocumentPreferencesFormSchema), resolver: zodResolver(ZDocumentPreferencesFormSchema),
}); });
const currentValues = form.watch();
const isResetDisabled = !form.formState.isDirty && JSON.stringify(currentValues) === JSON.stringify(resetValues);
const handleResetToDefaults = async () => {
await onFormSubmit(resetValues);
form.reset(resetValues);
};
const handleFormSubmit = form.handleSubmit(async (data) => { const handleFormSubmit = form.handleSubmit(async (data) => {
try { try {
await onFormSubmit(data); await onFormSubmit(data);
@@ -772,6 +793,16 @@ export const DocumentPreferencesForm = ({
isDirty={form.formState.isDirty} isDirty={form.formState.isDirty}
isSubmitting={form.formState.isSubmitting} isSubmitting={form.formState.isSubmitting}
onReset={() => form.reset()} onReset={() => form.reset()}
resetToDefaults={
<DocumentPreferencesResetDialog
disabled={isResetDisabled}
isSubmitting={form.formState.isSubmitting}
onReset={handleResetToDefaults}
showAiFeatures={isAiFeaturesConfigured}
showDocumentVisibility={!isPersonalLayoutMode}
showIncludeSenderDetails={!isPersonalLayoutMode && !isPersonalOrganisation}
/>
}
/> />
</fieldset> </fieldset>
</form> </form>
@@ -1,3 +1,4 @@
import { ZNameSchema } from '@documenso/lib/types/name';
import { import {
Form, Form,
FormControl, FormControl,
@@ -15,8 +16,8 @@ import { useForm } from 'react-hook-form';
import { z } from 'zod'; import { z } from 'zod';
const ZEmailTransportFormSchema = z.object({ const ZEmailTransportFormSchema = z.object({
name: z.string().min(1), name: ZNameSchema,
fromName: z.string().min(1), fromName: ZNameSchema,
fromAddress: z.string().email(), fromAddress: z.string().email(),
type: z.enum(['SMTP_AUTH', 'SMTP_API', 'RESEND', 'MAILCHANNELS']), type: z.enum(['SMTP_AUTH', 'SMTP_API', 'RESEND', 'MAILCHANNELS']),
host: z.string().optional(), host: z.string().optional(),
@@ -3,12 +3,17 @@ import { Button } from '@documenso/ui/primitives/button';
import { Trans, useLingui } from '@lingui/react/macro'; import { Trans, useLingui } from '@lingui/react/macro';
import { AnimatePresence, motion } from 'framer-motion'; import { AnimatePresence, motion } from 'framer-motion';
import { AlertTriangleIcon } from 'lucide-react'; import { AlertTriangleIcon } from 'lucide-react';
import { useEffect, useRef, useState } from 'react'; import { type ReactNode, useEffect, useRef, useState } from 'react';
export type FormStickySaveBarProps = { export type FormStickySaveBarProps = {
isDirty: boolean; isDirty: boolean;
isSubmitting: boolean; isSubmitting: boolean;
onReset: () => void; onReset: () => void;
/**
* Slot for a "reset to defaults" action, rendered after the Save button. Only shown
* while the form is unchanged so it never competes with the Undo/unsaved-changes UI.
*/
resetToDefaults?: ReactNode;
}; };
/** /**
@@ -24,7 +29,7 @@ export type FormStickySaveBarProps = {
* shared-layout morph). A 1px sentinel below it detects the stuck state so we can toggle * shared-layout morph). A 1px sentinel below it detects the stuck state so we can toggle
* the pill chrome. * the pill chrome.
*/ */
export const FormStickySaveBar = ({ isDirty, isSubmitting, onReset }: FormStickySaveBarProps) => { export const FormStickySaveBar = ({ isDirty, isSubmitting, onReset, resetToDefaults }: FormStickySaveBarProps) => {
const { t } = useLingui(); const { t } = useLingui();
const sentinelRef = useRef<HTMLDivElement>(null); const sentinelRef = useRef<HTMLDivElement>(null);
@@ -109,6 +114,8 @@ export const FormStickySaveBar = ({ isDirty, isSubmitting, onReset }: FormSticky
<Button type="submit" className="shrink-0" size="sm" loading={isSubmitting} disabled={!isDirty}> <Button type="submit" className="shrink-0" size="sm" loading={isSubmitting} disabled={!isDirty}>
<Trans>Save changes</Trans> <Trans>Save changes</Trans>
</Button> </Button>
{!isDirty && resetToDefaults}
</div> </div>
</div> </div>
+1 -1
View File
@@ -1,5 +1,5 @@
import { useSession } from '@documenso/lib/client-only/providers/session'; import { useSession } from '@documenso/lib/client-only/providers/session';
import { ZNameSchema } from '@documenso/lib/constants/auth'; import { ZNameSchema } from '@documenso/lib/types/name';
import { trpc } from '@documenso/trpc/react'; import { trpc } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils'; import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
+1 -1
View File
@@ -1,8 +1,8 @@
import communityCardsImage from '@documenso/assets/images/community-cards.png'; import communityCardsImage from '@documenso/assets/images/community-cards.png';
import { authClient } from '@documenso/auth/client'; import { authClient } from '@documenso/auth/client';
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics'; import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
import { ZNameSchema } from '@documenso/lib/constants/auth';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { ZNameSchema } from '@documenso/lib/types/name';
import { env } from '@documenso/lib/utils/env'; import { env } from '@documenso/lib/utils/env';
import { zEmail } from '@documenso/lib/utils/zod'; import { zEmail } from '@documenso/lib/utils/zod';
import { ZPasswordSchema } from '@documenso/trpc/server/auth-router/schema'; import { ZPasswordSchema } from '@documenso/trpc/server/auth-router/schema';
@@ -5,6 +5,7 @@ import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react'; import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro'; import { Trans } from '@lingui/react/macro';
import type { OrganisationGlobalSettings, TeamGlobalSettings } from '@prisma/client'; import type { OrganisationGlobalSettings, TeamGlobalSettings } from '@prisma/client';
import type { ReactNode } from 'react';
import { DetailsCard, DetailsValue } from '~/components/general/admin-details'; import { DetailsCard, DetailsValue } from '~/components/general/admin-details';
@@ -25,38 +26,72 @@ const emailSettingsKeys = Object.keys(EMAIL_SETTINGS_LABELS) as (keyof TDocument
type AdminGlobalSettingsSectionProps = { type AdminGlobalSettingsSectionProps = {
settings: TeamGlobalSettings | OrganisationGlobalSettings | null; settings: TeamGlobalSettings | OrganisationGlobalSettings | null;
isTeam?: boolean; isTeam?: boolean;
/** When viewing a team, the parent organisation settings the team inherits from. */
inheritedSettings?: OrganisationGlobalSettings | null;
}; };
export const AdminGlobalSettingsSection = ({ settings, isTeam = false }: AdminGlobalSettingsSectionProps) => { export const AdminGlobalSettingsSection = ({
settings,
isTeam = false,
inheritedSettings,
}: AdminGlobalSettingsSectionProps) => {
const { _ } = useLingui(); const { _ } = useLingui();
const notSetLabel = isTeam ? <Trans>Inherited</Trans> : <Trans>Not set</Trans>;
if (!settings) { if (!settings) {
return null; return null;
} }
const textValue = (value: string | null | undefined) => { const notSet = <Trans>Not set</Trans>;
if (value === null || value === undefined) {
return notSetLabel; const inheritedValue = (value: ReactNode) => {
if (!isTeam || value === null) {
return notSet;
} }
return value; return (
<span className="flex items-center gap-1.5">
<span className="text-muted-foreground">
<Trans>Inherited</Trans>:
</span>
<span>{value}</span>
</span>
);
}; };
const brandingTextValue = (value: string | null | undefined) => { const textValue = (value: string | null | undefined, inherited?: string | null) => {
if (value === null || value === undefined || value.trim() === '') { if (value && value.trim() !== '') {
return notSetLabel; return value;
} }
return value; if (inherited && inherited.trim() !== '') {
return inheritedValue(inherited);
}
return notSet;
}; };
const booleanValue = (value: boolean | null | undefined) => { const booleanLabel = (value: boolean) => (value ? <Trans>Enabled</Trans> : <Trans>Disabled</Trans>);
if (value === null || value === undefined) {
return notSetLabel; const booleanValue = (value: boolean | null | undefined, inherited?: boolean | null) => {
if (value !== null && value !== undefined) {
return booleanLabel(value);
} }
return value ? <Trans>Enabled</Trans> : <Trans>Disabled</Trans>; return inherited !== null && inherited !== undefined ? inheritedValue(booleanLabel(inherited)) : notSet;
};
const visibilityLabel = (value: string | null | undefined) => {
return value && DOCUMENT_VISIBILITY[value] ? _(DOCUMENT_VISIBILITY[value].value) : null;
};
const visibilityValue = (value: string | null | undefined, inherited?: string | null) => {
const label = visibilityLabel(value);
if (label !== null) {
return label;
}
return inheritedValue(visibilityLabel(inherited));
}; };
const parsedEmailSettings = ZDocumentEmailSettingsSchema.safeParse(settings.emailDocumentSettings); const parsedEmailSettings = ZDocumentEmailSettingsSchema.safeParse(settings.emailDocumentSettings);
@@ -65,70 +100,82 @@ export const AdminGlobalSettingsSection = ({ settings, isTeam = false }: AdminGl
<div className="grid grid-cols-1 gap-3 text-sm sm:grid-cols-2 lg:grid-cols-3"> <div className="grid grid-cols-1 gap-3 text-sm sm:grid-cols-2 lg:grid-cols-3">
<DetailsCard label={<Trans>Document visibility</Trans>}> <DetailsCard label={<Trans>Document visibility</Trans>}>
<DetailsValue> <DetailsValue>
{settings.documentVisibility != null {visibilityValue(settings.documentVisibility, inheritedSettings?.documentVisibility)}
? _(DOCUMENT_VISIBILITY[settings.documentVisibility].value)
: notSetLabel}
</DetailsValue> </DetailsValue>
</DetailsCard> </DetailsCard>
<DetailsCard label={<Trans>Document language</Trans>}> <DetailsCard label={<Trans>Document language</Trans>}>
<DetailsValue>{textValue(settings.documentLanguage)}</DetailsValue> <DetailsValue>{textValue(settings.documentLanguage, inheritedSettings?.documentLanguage)}</DetailsValue>
</DetailsCard> </DetailsCard>
<DetailsCard label={<Trans>Document timezone</Trans>}> <DetailsCard label={<Trans>Document timezone</Trans>}>
<DetailsValue>{textValue(settings.documentTimezone)}</DetailsValue> <DetailsValue>{textValue(settings.documentTimezone, inheritedSettings?.documentTimezone)}</DetailsValue>
</DetailsCard> </DetailsCard>
<DetailsCard label={<Trans>Date format</Trans>}> <DetailsCard label={<Trans>Date format</Trans>}>
<DetailsValue>{textValue(settings.documentDateFormat)}</DetailsValue> <DetailsValue>{textValue(settings.documentDateFormat, inheritedSettings?.documentDateFormat)}</DetailsValue>
</DetailsCard> </DetailsCard>
<DetailsCard label={<Trans>Include sender details</Trans>}> <DetailsCard label={<Trans>Include sender details</Trans>}>
<DetailsValue>{booleanValue(settings.includeSenderDetails)}</DetailsValue> <DetailsValue>
{booleanValue(settings.includeSenderDetails, inheritedSettings?.includeSenderDetails)}
</DetailsValue>
</DetailsCard> </DetailsCard>
<DetailsCard label={<Trans>Include signing certificate</Trans>}> <DetailsCard label={<Trans>Include signing certificate</Trans>}>
<DetailsValue>{booleanValue(settings.includeSigningCertificate)}</DetailsValue> <DetailsValue>
{booleanValue(settings.includeSigningCertificate, inheritedSettings?.includeSigningCertificate)}
</DetailsValue>
</DetailsCard> </DetailsCard>
<DetailsCard label={<Trans>Include audit log</Trans>}> <DetailsCard label={<Trans>Include audit log</Trans>}>
<DetailsValue>{booleanValue(settings.includeAuditLog)}</DetailsValue> <DetailsValue>{booleanValue(settings.includeAuditLog, inheritedSettings?.includeAuditLog)}</DetailsValue>
</DetailsCard> </DetailsCard>
<DetailsCard label={<Trans>Delegate document ownership</Trans>}> <DetailsCard label={<Trans>Delegate document ownership</Trans>}>
<DetailsValue>{booleanValue(settings.delegateDocumentOwnership)}</DetailsValue> <DetailsValue>
{booleanValue(settings.delegateDocumentOwnership, inheritedSettings?.delegateDocumentOwnership)}
</DetailsValue>
</DetailsCard> </DetailsCard>
<DetailsCard label={<Trans>Typed signature</Trans>}> <DetailsCard label={<Trans>Typed signature</Trans>}>
<DetailsValue>{booleanValue(settings.typedSignatureEnabled)}</DetailsValue> <DetailsValue>
{booleanValue(settings.typedSignatureEnabled, inheritedSettings?.typedSignatureEnabled)}
</DetailsValue>
</DetailsCard> </DetailsCard>
<DetailsCard label={<Trans>Upload signature</Trans>}> <DetailsCard label={<Trans>Upload signature</Trans>}>
<DetailsValue>{booleanValue(settings.uploadSignatureEnabled)}</DetailsValue> <DetailsValue>
{booleanValue(settings.uploadSignatureEnabled, inheritedSettings?.uploadSignatureEnabled)}
</DetailsValue>
</DetailsCard> </DetailsCard>
<DetailsCard label={<Trans>Draw signature</Trans>}> <DetailsCard label={<Trans>Draw signature</Trans>}>
<DetailsValue>{booleanValue(settings.drawSignatureEnabled)}</DetailsValue> <DetailsValue>
{booleanValue(settings.drawSignatureEnabled, inheritedSettings?.drawSignatureEnabled)}
</DetailsValue>
</DetailsCard> </DetailsCard>
<DetailsCard label={<Trans>Branding</Trans>}> <DetailsCard label={<Trans>Branding</Trans>}>
<DetailsValue>{booleanValue(settings.brandingEnabled)}</DetailsValue> <DetailsValue>{booleanValue(settings.brandingEnabled, inheritedSettings?.brandingEnabled)}</DetailsValue>
</DetailsCard> </DetailsCard>
<DetailsCard label={<Trans>Branding logo</Trans>}> <DetailsCard label={<Trans>Branding logo</Trans>}>
<DetailsValue>{brandingTextValue(settings.brandingLogo)}</DetailsValue> <DetailsValue>{textValue(settings.brandingLogo, inheritedSettings?.brandingLogo)}</DetailsValue>
</DetailsCard> </DetailsCard>
<DetailsCard label={<Trans>Branding URL</Trans>}> <DetailsCard label={<Trans>Branding URL</Trans>}>
<DetailsValue>{brandingTextValue(settings.brandingUrl)}</DetailsValue> <DetailsValue>{textValue(settings.brandingUrl, inheritedSettings?.brandingUrl)}</DetailsValue>
</DetailsCard> </DetailsCard>
<DetailsCard label={<Trans>Branding company details</Trans>}> <DetailsCard label={<Trans>Branding company details</Trans>}>
<DetailsValue>{brandingTextValue(settings.brandingCompanyDetails)}</DetailsValue> <DetailsValue>
{textValue(settings.brandingCompanyDetails, inheritedSettings?.brandingCompanyDetails)}
</DetailsValue>
</DetailsCard> </DetailsCard>
<DetailsCard label={<Trans>Email reply-to</Trans>}> <DetailsCard label={<Trans>Email reply-to</Trans>}>
<DetailsValue>{textValue(settings.emailReplyTo)}</DetailsValue> <DetailsValue>{textValue(settings.emailReplyTo, inheritedSettings?.emailReplyTo)}</DetailsValue>
</DetailsCard> </DetailsCard>
{isTeam && parsedEmailSettings.success && ( {isTeam && parsedEmailSettings.success && (
@@ -145,7 +192,7 @@ export const AdminGlobalSettingsSection = ({ settings, isTeam = false }: AdminGl
)} )}
<DetailsCard label={<Trans>AI features</Trans>}> <DetailsCard label={<Trans>AI features</Trans>}>
<DetailsValue>{booleanValue(settings.aiFeaturesEnabled)}</DetailsValue> <DetailsValue>{booleanValue(settings.aiFeaturesEnabled, inheritedSettings?.aiFeaturesEnabled)}</DetailsValue>
</DetailsCard> </DetailsCard>
</div> </div>
); );
@@ -1,6 +1,7 @@
import { authClient } from '@documenso/auth/client'; import { authClient } from '@documenso/auth/client';
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics'; import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
import { AppError } from '@documenso/lib/errors/app-error'; import { AppError } from '@documenso/lib/errors/app-error';
import { ZNameSchema } from '@documenso/lib/types/name';
import { env } from '@documenso/lib/utils/env'; import { env } from '@documenso/lib/utils/env';
import { zEmail } from '@documenso/lib/utils/zod'; import { zEmail } from '@documenso/lib/utils/zod';
import { ZPasswordSchema } from '@documenso/trpc/server/auth-router/schema'; import { ZPasswordSchema } from '@documenso/trpc/server/auth-router/schema';
@@ -19,7 +20,6 @@ import { useRef } from 'react';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { useNavigate } from 'react-router'; import { useNavigate } from 'react-router';
import { z } from 'zod'; import { z } from 'zod';
import { SIGNUP_ERROR_MESSAGES } from '~/components/forms/signup'; import { SIGNUP_ERROR_MESSAGES } from '~/components/forms/signup';
export type ClaimAccountProps = { export type ClaimAccountProps = {
@@ -30,7 +30,7 @@ export type ClaimAccountProps = {
export const ZClaimAccountFormSchema = z export const ZClaimAccountFormSchema = z
.object({ .object({
name: z.string().trim().min(1, { message: msg`Please enter a valid name.`.id }), name: ZNameSchema,
email: zEmail().min(1), email: zEmail().min(1),
password: ZPasswordSchema, password: ZPasswordSchema,
}) })
@@ -1,11 +1,4 @@
import { import { FormControl, FormField, FormItem, FormLabel, FormMessage } from '@documenso/ui/primitives/form/form';
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input'; import { Input } from '@documenso/ui/primitives/input';
import { Trans, useLingui } from '@lingui/react/macro'; import { Trans, useLingui } from '@lingui/react/macro';
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
@@ -13,6 +6,13 @@ import type { Control, FieldValues, Path } from 'react-hook-form';
import { RateLimitArrayInput } from './rate-limit-array-input'; import { RateLimitArrayInput } from './rate-limit-array-input';
/**
* The rate-limit editor renders its own per-row inline errors, but a submit
* attempt can still surface array-level Zod issues (e.g. a committed duplicate
* window). Rendering the field's message here guarantees the form never fails
* silently when those errors are not tied to a row the editor is showing.
*/
type ClaimLimitFieldsProps<T extends FieldValues> = { type ClaimLimitFieldsProps<T extends FieldValues> = {
control: Control<T>; control: Control<T>;
/** e.g. '' for the claim form, 'claims.' for the org admin form. */ /** e.g. '' for the claim form, 'claims.' for the org admin form. */
@@ -20,6 +20,12 @@ type ClaimLimitFieldsProps<T extends FieldValues> = {
disabled?: boolean; disabled?: boolean;
}; };
type LimitGroup = {
title: ReactNode;
quotaKey: string;
rateLimitKey: string;
};
export const ClaimLimitFields = <T extends FieldValues>({ export const ClaimLimitFields = <T extends FieldValues>({
control, control,
prefix = '', prefix = '',
@@ -30,13 +36,33 @@ export const ClaimLimitFields = <T extends FieldValues>({
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions // eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const name = (key: string) => `${prefix}${key}` as Path<T>; const name = (key: string) => `${prefix}${key}` as Path<T>;
const renderQuotaField = (key: string, label: ReactNode, description: ReactNode) => ( const limitGroups: LimitGroup[] = [
{
title: <Trans>Documents</Trans>,
quotaKey: 'documentQuota',
rateLimitKey: 'documentRateLimits',
},
{
title: <Trans>Emails</Trans>,
quotaKey: 'emailQuota',
rateLimitKey: 'emailRateLimits',
},
{
title: <Trans>API</Trans>,
quotaKey: 'apiQuota',
rateLimitKey: 'apiRateLimits',
},
];
const renderQuotaField = (group: LimitGroup) => (
<FormField <FormField
control={control} control={control}
name={name(key)} name={name(group.quotaKey)}
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>{label}</FormLabel> <FormLabel className="text-muted-foreground text-xs">
<Trans>Monthly quota</Trans>
</FormLabel>
<FormControl> <FormControl>
<Input <Input
type="number" type="number"
@@ -47,20 +73,18 @@ export const ClaimLimitFields = <T extends FieldValues>({
onChange={(e) => field.onChange(e.target.value === '' ? null : parseInt(e.target.value, 10))} onChange={(e) => field.onChange(e.target.value === '' ? null : parseInt(e.target.value, 10))}
/> />
</FormControl> </FormControl>
<FormDescription>{description}</FormDescription>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
)} )}
/> />
); );
const renderRateLimitField = (key: string, label: ReactNode) => ( const renderRateLimitField = (group: LimitGroup) => (
<FormField <FormField
control={control} control={control}
name={name(key)} name={name(group.rateLimitKey)}
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>{label}</FormLabel>
<FormControl> <FormControl>
<RateLimitArrayInput value={field.value ?? []} onChange={field.onChange} disabled={disabled} /> <RateLimitArrayInput value={field.value ?? []} onChange={field.onChange} disabled={disabled} />
</FormControl> </FormControl>
@@ -71,27 +95,30 @@ export const ClaimLimitFields = <T extends FieldValues>({
); );
return ( return (
<div className="space-y-4 rounded-md border p-4"> <div className="space-y-3">
<FormLabel> <div>
<Trans>Limits</Trans> <h3 className="font-semibold text-base">
</FormLabel> <Trans>Limits</Trans>
</h3>
<p className="mt-1 text-muted-foreground text-sm">
<Trans>
Empty quota means unlimited, 0 blocks the resource. Rate limit windows accept values like 5m, 1h or 24h.
</Trans>
</p>
</div>
{renderQuotaField( <div className="overflow-hidden rounded-lg border">
'documentQuota', <div className="grid grid-cols-1 divide-y divide-border md:grid-cols-3 md:divide-x md:divide-y-0">
<Trans>Monthly document quota</Trans>, {limitGroups.map((group) => (
<Trans>Empty = Unlimited, 0 = Blocked</Trans>, <div key={group.quotaKey} className="space-y-4 p-4">
)} <h4 className="font-semibold text-sm">{group.title}</h4>
{renderRateLimitField('documentRateLimits', <Trans>Document rate limits</Trans>)}
{renderQuotaField( {renderQuotaField(group)}
'emailQuota', {renderRateLimitField(group)}
<Trans>Monthly email quota</Trans>, </div>
<Trans>Empty = Unlimited, 0 = Blocked</Trans>, ))}
)} </div>
{renderRateLimitField('emailRateLimits', <Trans>Email rate limits</Trans>)} </div>
{renderQuotaField('apiQuota', <Trans>Monthly API quota</Trans>, <Trans>Empty = Unlimited, 0 = Blocked</Trans>)}
{renderRateLimitField('apiRateLimits', <Trans>API rate limits</Trans>)}
</div> </div>
); );
}; };
@@ -1,13 +1,38 @@
import { currentMonthlyPeriod } from '@documenso/lib/universal/monthly-period'; import { currentMonthlyPeriod } from '@documenso/lib/universal/monthly-period';
import {
getQuotaUsagePercent,
isQuotaExceeded,
isQuotaNearing,
normalizeCapacityLimit,
} from '@documenso/lib/universal/quota-usage';
import { cn } from '@documenso/ui/lib/utils';
import type { BadgeProps } from '@documenso/ui/primitives/badge';
import { Badge } from '@documenso/ui/primitives/badge';
import { Progress } from '@documenso/ui/primitives/progress'; import { Progress } from '@documenso/ui/primitives/progress';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@documenso/ui/primitives/select'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@documenso/ui/primitives/select';
import { Trans } from '@lingui/react/macro'; import { Trans } from '@lingui/react/macro';
import type { OrganisationClaim, OrganisationMonthlyStat } from '@prisma/client'; import type { OrganisationClaim, OrganisationMonthlyStat } from '@prisma/client';
import { useState } from 'react'; import type { LucideIcon } from 'lucide-react';
import { match } from 'ts-pattern'; import { FileIcon, MailIcon, MailOpenIcon, PlugIcon, UsersIcon, UsersRoundIcon } from 'lucide-react';
import type { ReactNode } from 'react';
import { useId, useState } from 'react';
import { OrganisationUsageResetButton } from './organisation-usage-reset-button'; import { OrganisationUsageResetButton } from './organisation-usage-reset-button';
type CapacityUsage = {
members: number;
teams: number;
};
type UsageRow = {
counter: 'document' | 'email' | 'api';
label: ReactNode;
icon: LucideIcon;
used: number;
effectiveLimit: number | null;
};
type OrganisationUsagePanelProps = { type OrganisationUsagePanelProps = {
organisationId: string; organisationId: string;
monthlyStats: Pick< monthlyStats: Pick<
@@ -15,13 +40,151 @@ type OrganisationUsagePanelProps = {
'period' | 'documentCount' | 'emailCount' | 'apiCount' | 'emailReports' 'period' | 'documentCount' | 'emailCount' | 'apiCount' | 'emailReports'
>[]; >[];
organisationClaim: OrganisationClaim; organisationClaim: OrganisationClaim;
capacityUsage?: CapacityUsage;
};
type UsageCardState = {
status: {
label: ReactNode;
variant: NonNullable<BadgeProps['variant']>;
};
percent: number;
hasFiniteLimit: boolean;
progressClassName: string;
subtext: ReactNode;
};
type UsageCardStateOptions = {
used: number;
limit: number | null | undefined;
footnote?: ReactNode;
};
const getUsageCardState = ({ used, limit, footnote }: UsageCardStateOptions): UsageCardState => {
const percent = getQuotaUsagePercent(used, limit ?? null);
const hasFiniteLimit = Boolean(limit && limit > 0);
if (limit === null || limit === undefined) {
return {
status: { label: <Trans>Unlimited</Trans>, variant: 'neutral' },
percent,
hasFiniteLimit,
progressClassName: '',
subtext: footnote ?? null,
};
}
if (limit === 0) {
return {
status: { label: <Trans>Blocked</Trans>, variant: 'destructive' },
percent,
hasFiniteLimit,
progressClassName: '',
subtext: footnote ?? <Trans>Resource blocked</Trans>,
};
}
if (used > limit) {
return {
status: { label: <Trans>Exceeded</Trans>, variant: 'destructive' },
percent,
hasFiniteLimit,
progressClassName: '[&>div]:bg-destructive',
subtext: footnote ?? null,
};
}
if (isQuotaExceeded(limit, used)) {
return {
status: { label: <Trans>Limit reached</Trans>, variant: 'orange' },
percent,
hasFiniteLimit,
progressClassName: '[&>div]:bg-orange-500 dark:[&>div]:bg-orange-400',
subtext: footnote ?? null,
};
}
if (isQuotaNearing(limit, used)) {
return {
status: { label: <Trans>Near limit</Trans>, variant: 'warning' },
percent,
hasFiniteLimit,
progressClassName: '[&>div]:bg-yellow-500 dark:[&>div]:bg-yellow-400',
subtext: footnote ?? null,
};
}
return {
status: { label: <Trans>Within limit</Trans>, variant: 'default' },
percent,
hasFiniteLimit,
progressClassName: '',
subtext: footnote ?? null,
};
};
type UsageStatCardProps = {
label: ReactNode;
icon: LucideIcon;
used: number;
limit: number | null | undefined;
/** When true the card is a plain counter with no limit, status or progress. */
countOnly?: boolean;
footnote?: ReactNode;
action?: ReactNode;
};
const UsageStatCard = ({ label, icon: Icon, used, limit, countOnly = false, footnote, action }: UsageStatCardProps) => {
const { status, percent, hasFiniteLimit, progressClassName, subtext } = getUsageCardState({ used, limit, footnote });
return (
<div className="flex flex-col rounded-lg border bg-background p-5">
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2 font-medium text-foreground text-sm">
<Icon className="h-4 w-4 text-muted-foreground" />
<span>{label}</span>
</div>
{!countOnly && (
<Badge variant={status.variant} size="small">
{status.label}
</Badge>
)}
</div>
<div className="mt-4 flex flex-1 flex-col">
<div className="flex items-baseline justify-between gap-2">
<div className="flex items-baseline gap-1.5">
<span className="font-semibold text-3xl text-foreground tabular-nums tracking-tight">
{used.toLocaleString()}
</span>
{hasFiniteLimit ? (
<span className="text-base text-muted-foreground tabular-nums">/ {limit?.toLocaleString()}</span>
) : null}
</div>
{hasFiniteLimit ? (
<span className="font-medium text-muted-foreground text-sm tabular-nums">{percent}%</span>
) : null}
</div>
{hasFiniteLimit ? <Progress className={cn('mt-3 h-2', progressClassName)} value={percent} /> : null}
{subtext ? <p className="mt-2 text-muted-foreground text-xs">{subtext}</p> : null}
</div>
{action ? <div className="mt-4 flex justify-end border-t pt-4">{action}</div> : null}
</div>
);
}; };
export const OrganisationUsagePanel = ({ export const OrganisationUsagePanel = ({
organisationId, organisationId,
monthlyStats, monthlyStats,
organisationClaim, organisationClaim,
capacityUsage,
}: OrganisationUsagePanelProps) => { }: OrganisationUsagePanelProps) => {
const monthlyUsagePeriodId = useId();
const [selectedPeriod, setSelectedPeriod] = useState<string | undefined>(() => monthlyStats[0]?.period); const [selectedPeriod, setSelectedPeriod] = useState<string | undefined>(() => monthlyStats[0]?.period);
const selectedStat = monthlyStats.find((stat) => stat.period === selectedPeriod) ?? monthlyStats[0]; const selectedStat = monthlyStats.find((stat) => stat.period === selectedPeriod) ?? monthlyStats[0];
@@ -30,86 +193,105 @@ export const OrganisationUsagePanel = ({
// current period), so only offer the reset action when viewing the current month. // current period), so only offer the reset action when viewing the current month.
const isCurrentPeriod = selectedStat?.period === currentMonthlyPeriod(); const isCurrentPeriod = selectedStat?.period === currentMonthlyPeriod();
const rows = [ const capacityRows = capacityUsage
? [
{
key: 'members',
label: <Trans>Members</Trans>,
icon: UsersIcon,
used: capacityUsage.members,
limit: normalizeCapacityLimit(organisationClaim.memberCount),
},
{
key: 'teams',
label: <Trans>Teams</Trans>,
icon: UsersRoundIcon,
used: capacityUsage.teams,
limit: normalizeCapacityLimit(organisationClaim.teamCount),
},
]
: [];
const monthlyRows: UsageRow[] = [
{ {
counter: 'document' as const, counter: 'document',
label: <Trans>Documents</Trans>, label: <Trans>Documents</Trans>,
icon: FileIcon,
used: selectedStat?.documentCount ?? 0, used: selectedStat?.documentCount ?? 0,
effectiveLimit: organisationClaim.documentQuota, effectiveLimit: organisationClaim.documentQuota,
}, },
{ {
counter: 'email' as const, counter: 'email',
label: <Trans>Emails</Trans>, label: <Trans>Emails</Trans>,
icon: MailIcon,
used: selectedStat?.emailCount ?? 0, used: selectedStat?.emailCount ?? 0,
effectiveLimit: organisationClaim.emailQuota, effectiveLimit: organisationClaim.emailQuota,
}, },
{ {
counter: 'api' as const, counter: 'api',
label: <Trans>API requests</Trans>, label: <Trans>API requests</Trans>,
icon: PlugIcon,
used: selectedStat?.apiCount ?? 0, used: selectedStat?.apiCount ?? 0,
effectiveLimit: organisationClaim.apiQuota, effectiveLimit: organisationClaim.apiQuota,
}, },
]; ];
return ( return (
<div className="space-y-4 rounded-md border p-4"> <div className="mt-4 space-y-6">
<div className="flex items-center justify-between gap-2"> {capacityRows.length > 0 ? (
<h3 className="font-medium text-sm"> <div className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-3">
<Trans>Usage for period: {selectedStat?.period || 'N/A'}</Trans> {capacityRows.map((row) => (
</h3> <UsageStatCard key={row.key} label={row.label} icon={row.icon} used={row.used} limit={row.limit} />
))}
</div>
) : null}
{monthlyStats.length > 0 && ( <div className="space-y-3">
<Select value={selectedStat?.period} onValueChange={setSelectedPeriod}> <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<SelectTrigger className="w-40"> <h3 id={monthlyUsagePeriodId} className="font-semibold text-base">
<SelectValue /> <Trans>Monthly usage</Trans>
</SelectTrigger> </h3>
<SelectContent>
{monthlyStats.map((stat) => (
<SelectItem key={stat.period} value={stat.period}>
{stat.period}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
{rows.map((row) => { {monthlyStats.length > 0 ? (
const percent = <Select value={selectedStat?.period} onValueChange={setSelectedPeriod}>
row.effectiveLimit && row.effectiveLimit > 0 <SelectTrigger className="h-9 w-full sm:w-44" aria-labelledby={monthlyUsagePeriodId}>
? Math.min(100, Math.round((row.used / row.effectiveLimit) * 100)) <SelectValue />
: 0; </SelectTrigger>
<SelectContent>
{monthlyStats.map((stat) => (
<SelectItem key={stat.period} value={stat.period}>
{stat.period}
</SelectItem>
))}
</SelectContent>
</Select>
) : null}
</div>
return ( <div className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-3">
<div key={row.counter} className="space-y-1"> {monthlyRows.map((row) => (
<div className="flex items-center justify-between text-sm"> <UsageStatCard
<span>{row.label}</span> key={row.counter}
<span className="text-muted-foreground"> label={row.label}
{row.used} /{' '} icon={row.icon}
{match(row.effectiveLimit) used={row.used}
.with(null, () => <Trans>Unlimited</Trans>) limit={row.effectiveLimit}
.with(0, () => <Trans>Blocked</Trans>) action={
.otherwise(String)} selectedStat && isCurrentPeriod ? (
</span> <OrganisationUsageResetButton organisationId={organisationId} counter={row.counter} />
</div> ) : undefined
}
/>
))}
{row.effectiveLimit && row.effectiveLimit > 0 ? <Progress className="h-2 w-full" value={percent} /> : null} <UsageStatCard
label={<Trans>Reports</Trans>}
{selectedStat && isCurrentPeriod && ( icon={MailOpenIcon}
<div className="flex w-full justify-end pt-1"> used={selectedStat?.emailReports ?? 0}
<OrganisationUsageResetButton organisationId={organisationId} counter={row.counter} /> limit={null}
</div> countOnly
)} footnote={<Trans>Sent this period</Trans>}
</div> />
);
})}
<div className="space-y-1">
<div className="flex items-center justify-between text-sm">
<span>
<Trans>Reports</Trans>
</span>
<span className="text-muted-foreground">{selectedStat?.emailReports ?? 0}</span>
</div> </div>
</div> </div>
</div> </div>
@@ -2,6 +2,7 @@ import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
import { useToast } from '@documenso/ui/primitives/use-toast'; import { useToast } from '@documenso/ui/primitives/use-toast';
import { Trans, useLingui } from '@lingui/react/macro'; import { Trans, useLingui } from '@lingui/react/macro';
import { RotateCcwIcon } from 'lucide-react';
import { useRevalidator } from 'react-router'; import { useRevalidator } from 'react-router';
type OrganisationUsageResetButtonProps = { type OrganisationUsageResetButtonProps = {
@@ -32,6 +33,7 @@ export const OrganisationUsageResetButton = ({ organisationId, counter }: Organi
loading={isPending} loading={isPending}
onClick={() => reset({ organisationId, counter })} onClick={() => reset({ organisationId, counter })}
> >
<RotateCcwIcon className="mr-2 h-3.5 w-3.5" />
<Trans>Reset</Trans> <Trans>Reset</Trans>
</Button> </Button>
); );
@@ -1,7 +1,9 @@
import { RATE_LIMIT_WINDOW_REGEX } from '@documenso/lib/types/subscription';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
import { Input } from '@documenso/ui/primitives/input'; import { Input } from '@documenso/ui/primitives/input';
import { Trans } from '@lingui/react/macro'; import { Trans, useLingui } from '@lingui/react/macro';
import { PlusIcon, Trash2Icon } from 'lucide-react'; import { PlusIcon, Trash2Icon } from 'lucide-react';
import { useState } from 'react';
type RateLimitEntryValue = { window: string; max: number }; type RateLimitEntryValue = { window: string; max: number };
@@ -11,50 +13,153 @@ type RateLimitArrayInputProps = {
disabled?: boolean; disabled?: boolean;
}; };
const EMPTY_ENTRY: RateLimitEntryValue = { window: '', max: 0 };
/** A row counts as "started" once either field has input; fully-empty rows are dropped on commit. */
const hasEntryInput = (entry: RateLimitEntryValue) => entry.window.trim() !== '' || entry.max > 0;
/** Keep in-progress rows; drop rows that are completely empty. */
const persistEntries = (entries: RateLimitEntryValue[]) => {
return entries.map((entry) => ({ ...entry, window: entry.window.trim() })).filter(hasEntryInput);
};
export const RateLimitArrayInput = ({ value, onChange, disabled }: RateLimitArrayInputProps) => { export const RateLimitArrayInput = ({ value, onChange, disabled }: RateLimitArrayInputProps) => {
const entries = value ?? []; const { t } = useLingui();
const [draftEntry, setDraftEntry] = useState<RateLimitEntryValue | null>(null);
const entries = draftEntry ? [...value, draftEntry] : value.length ? value : [EMPTY_ENTRY];
const getWindowError = (entry: RateLimitEntryValue, index: number) => {
const window = entry.window.trim();
if (!hasEntryInput(entry)) {
return null;
}
if (window === '') {
return t`Enter a window, e.g. 5m`;
}
if (!RATE_LIMIT_WINDOW_REGEX.test(window)) {
return t`Use a duration with a unit, e.g. 5m, 1h, or 24h`;
}
const isDuplicateWindow = entries.some((otherEntry, otherIndex) => {
return otherIndex !== index && otherEntry.window.trim() === window;
});
return isDuplicateWindow ? t`Use a unique window for each rate limit` : null;
};
const getMaxError = (entry: RateLimitEntryValue) => {
if (!hasEntryInput(entry)) {
return null;
}
return entry.max > 0 ? null : t`Enter a max request count greater than 0`;
};
const updateEntry = (index: number, patch: Partial<RateLimitEntryValue>) => { const updateEntry = (index: number, patch: Partial<RateLimitEntryValue>) => {
const next = entries.map((entry, i) => (i === index ? { ...entry, ...patch } : entry)); if (index >= value.length) {
onChange(next); const nextDraftEntry = { ...(draftEntry ?? EMPTY_ENTRY), ...patch };
if (hasEntryInput(nextDraftEntry)) {
onChange(persistEntries([...value, nextDraftEntry]));
setDraftEntry(null);
return;
}
setDraftEntry(nextDraftEntry);
return;
}
const next = value.map((entry, i) => (i === index ? { ...entry, ...patch } : entry));
onChange(persistEntries(next));
}; };
const removeEntry = (index: number) => { const removeEntry = (index: number) => {
onChange(entries.filter((_, i) => i !== index)); if (index >= value.length) {
setDraftEntry(null);
return;
}
const next = value.filter((_, i) => i !== index);
onChange(persistEntries(next));
}; };
const addEntry = () => { const addEntry = () => {
onChange([...entries, { window: '5m', max: 100 }]); setDraftEntry(EMPTY_ENTRY);
}; };
const hasErrors = entries.some((entry, index) => getWindowError(entry, index) || getMaxError(entry));
const isAddDisabled = disabled || value.length === 0 || Boolean(draftEntry) || hasErrors;
return ( return (
<div className="space-y-2"> <div className="space-y-2">
{entries.map((entry, index) => ( <div className="flex items-center gap-2 text-muted-foreground text-xs">
<div key={index} className="flex items-center gap-2"> <span className="w-20 shrink-0">
<Input <Trans>Window</Trans>
className="w-24" </span>
placeholder="5m" <span className="flex-1">
value={entry.window} <Trans>Max requests</Trans>
disabled={disabled} </span>
onChange={(e) => updateEntry(index, { window: e.target.value })} <span className="w-9 shrink-0" aria-hidden="true" />
/> </div>
<Input
className="w-32"
type="number"
min={1}
value={entry.max}
disabled={disabled}
onChange={(e) => updateEntry(index, { max: parseInt(e.target.value, 10) || 0 })}
/>
<Button type="button" variant="ghost" size="sm" disabled={disabled} onClick={() => removeEntry(index)}>
<Trash2Icon className="h-4 w-4" />
</Button>
</div>
))}
<Button type="button" variant="secondary" size="sm" disabled={disabled} onClick={addEntry}> {entries.map((entry, index) => {
const windowError = getWindowError(entry, index);
const maxError = getMaxError(entry);
return (
<div key={index} className="space-y-1">
<div className="flex items-center gap-2">
<Input
className="w-20 shrink-0"
placeholder="5m"
value={entry.window}
disabled={disabled}
aria-invalid={Boolean(windowError)}
onChange={(e) => updateEntry(index, { window: e.target.value })}
/>
<Input
className="flex-1"
type="number"
min={1}
placeholder="100"
value={entry.max || ''}
disabled={disabled}
aria-invalid={Boolean(maxError)}
onChange={(e) => updateEntry(index, { max: parseInt(e.target.value, 10) || 0 })}
/>
<Button
type="button"
variant="ghost"
size="sm"
className="h-9 w-9 shrink-0 p-0 text-muted-foreground hover:text-foreground"
disabled={disabled}
aria-label={t`Remove rate limit`}
onClick={() => removeEntry(index)}
>
<Trash2Icon className="h-4 w-4" />
</Button>
</div>
{windowError ? <p className="text-destructive text-xs">{windowError}</p> : null}
{maxError ? <p className="text-destructive text-xs">{maxError}</p> : null}
</div>
);
})}
<Button
type="button"
variant="outline"
size="sm"
className="w-full border-dashed"
disabled={isAddDisabled}
onClick={addEntry}
>
<PlusIcon className="mr-2 h-4 w-4" /> <PlusIcon className="mr-2 h-4 w-4" />
<Trans>Add rate limit</Trans> <Trans>Add rate limit window</Trans>
</Button> </Button>
</div> </div>
); );
@@ -1,3 +1,4 @@
import { ZNameSchema } from '@documenso/lib/types/name';
import { trpc } from '@documenso/trpc/react'; import { trpc } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils'; import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
@@ -29,7 +30,7 @@ export type SettingsSecurityPasskeyTableActionsProps = {
}; };
const ZUpdatePasskeySchema = z.object({ const ZUpdatePasskeySchema = z.object({
name: z.string(), name: ZNameSchema,
}); });
type TUpdatePasskeySchema = z.infer<typeof ZUpdatePasskeySchema>; type TUpdatePasskeySchema = z.infer<typeof ZUpdatePasskeySchema>;
@@ -8,6 +8,7 @@ import { getHighestOrganisationRoleInGroup } from '@documenso/lib/utils/organisa
import { trpc } from '@documenso/trpc/react'; import { trpc } from '@documenso/trpc/react';
import type { TGetAdminOrganisationResponse } from '@documenso/trpc/server/admin-router/get-admin-organisation.types'; import type { TGetAdminOrganisationResponse } from '@documenso/trpc/server/admin-router/get-admin-organisation.types';
import { ZUpdateAdminOrganisationRequestSchema } from '@documenso/trpc/server/admin-router/update-admin-organisation.types'; import { ZUpdateAdminOrganisationRequestSchema } from '@documenso/trpc/server/admin-router/update-admin-organisation.types';
import { cn } from '@documenso/ui/lib/utils';
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@documenso/ui/primitives/accordion'; import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@documenso/ui/primitives/accordion';
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert'; import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
import { Badge } from '@documenso/ui/primitives/badge'; import { Badge } from '@documenso/ui/primitives/badge';
@@ -30,7 +31,7 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro'; import { msg } from '@lingui/core/macro';
import { Trans, useLingui } from '@lingui/react/macro'; import { Trans, useLingui } from '@lingui/react/macro';
import { OrganisationMemberRole } from '@prisma/client'; import { OrganisationMemberRole, SubscriptionStatus } from '@prisma/client';
import { ExternalLinkIcon, InfoIcon, Loader } from 'lucide-react'; import { ExternalLinkIcon, InfoIcon, Loader } from 'lucide-react';
import { useMemo } from 'react'; import { useMemo } from 'react';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
@@ -42,7 +43,6 @@ import { AdminOrganisationDeleteDialog } from '~/components/dialogs/admin-organi
import { AdminOrganisationMemberDeleteDialog } from '~/components/dialogs/admin-organisation-member-delete-dialog'; import { AdminOrganisationMemberDeleteDialog } from '~/components/dialogs/admin-organisation-member-delete-dialog';
import { AdminOrganisationMemberUpdateDialog } from '~/components/dialogs/admin-organisation-member-update-dialog'; import { AdminOrganisationMemberUpdateDialog } from '~/components/dialogs/admin-organisation-member-update-dialog';
import { AdminOrganisationSyncSubscriptionDialog } from '~/components/dialogs/admin-organisation-sync-subscription-dialog'; import { AdminOrganisationSyncSubscriptionDialog } from '~/components/dialogs/admin-organisation-sync-subscription-dialog';
import { DetailsCard, DetailsValue } from '~/components/general/admin-details';
import { AdminGlobalSettingsSection } from '~/components/general/admin-global-settings-section'; import { AdminGlobalSettingsSection } from '~/components/general/admin-global-settings-section';
import { ClaimLimitFields } from '~/components/general/claim-limit-fields'; import { ClaimLimitFields } from '~/components/general/claim-limit-fields';
import { GenericErrorLayout } from '~/components/general/generic-error-layout'; import { GenericErrorLayout } from '~/components/general/generic-error-layout';
@@ -268,54 +268,32 @@ export default function OrganisationGroupSettingsPage({ params, loaderData }: Ro
<GenericOrganisationAdminForm organisation={organisation} /> <GenericOrganisationAdminForm organisation={organisation} />
<div className="mt-6 rounded-lg border p-4"> <SettingsHeader
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"> title={t`Organisation usage`}
<div> subtitle={t`Current usage against organisation limits.`}
<p className="font-medium text-sm"> className="mt-6"
<Trans>Organisation usage</Trans> hideDivider
</p> />
<p className="mt-1 text-muted-foreground text-sm">
<Trans>Current usage against organisation limits.</Trans>
</p>
</div>
</div>
<div className="mt-4 grid grid-cols-1 gap-3 text-sm sm:grid-cols-2"> <OrganisationUsagePanel
<DetailsCard label={<Trans>Members</Trans>}> organisationId={organisation.id}
<DetailsValue> monthlyStats={organisation.monthlyStats}
{organisation.members.length} /{' '} organisationClaim={organisation.organisationClaim}
{organisation.organisationClaim.memberCount === 0 capacityUsage={{
? t`Unlimited` members: organisation.members.length,
: organisation.organisationClaim.memberCount} teams: organisation.teams.length,
</DetailsValue> }}
</DetailsCard> />
<DetailsCard label={<Trans>Teams</Trans>}>
<DetailsValue>
{organisation.teams.length} /{' '}
{organisation.organisationClaim.teamCount === 0 ? t`Unlimited` : organisation.organisationClaim.teamCount}
</DetailsValue>
</DetailsCard>
</div>
<div className="mt-4">
<OrganisationUsagePanel
organisationId={organisation.id}
monthlyStats={organisation.monthlyStats}
organisationClaim={organisation.organisationClaim}
/>
</div>
</div>
<div className="mt-6 rounded-lg border p-4"> <div className="mt-6 rounded-lg border p-4">
<Accordion type="single" collapsible> <Accordion type="single" collapsible>
<AccordionItem value="global-settings" className="border-b-0"> <AccordionItem value="global-settings" className="border-b-0">
<AccordionTrigger className="py-0"> <AccordionTrigger className="py-0">
<div className="text-left"> <div className="text-left">
<p className="font-medium text-sm"> <p className="font-semibold text-base">
<Trans>Global Settings</Trans> <Trans>Global Settings</Trans>
</p> </p>
<p className="mt-1 font-normal text-muted-foreground text-sm"> <p className="mt-1 text-muted-foreground text-sm">
<Trans>Default settings applied to this organisation.</Trans> <Trans>Default settings applied to this organisation.</Trans>
</p> </p>
</div> </div>
@@ -335,7 +313,15 @@ export default function OrganisationGroupSettingsPage({ params, loaderData }: Ro
className="mt-16" className="mt-16"
/> />
<Alert className="my-6 flex flex-col justify-between p-6 sm:flex-row sm:items-center" variant="neutral"> <Alert
className={cn(
'my-6 flex flex-col justify-between p-6 sm:flex-row sm:items-center',
organisation.subscription?.status === SubscriptionStatus.ACTIVE &&
'border border-green-600/20 bg-green-50 dark:border-green-500/20 dark:bg-green-500/10',
organisation.subscription?.status === SubscriptionStatus.INACTIVE && 'opacity-60',
)}
variant="neutral"
>
<div className="mb-4 sm:mb-0"> <div className="mb-4 sm:mb-0">
<AlertTitle> <AlertTitle>
<Trans>Subscription</Trans> <Trans>Subscription</Trans>
@@ -343,7 +329,12 @@ export default function OrganisationGroupSettingsPage({ params, loaderData }: Ro
<AlertDescription className="mr-2"> <AlertDescription className="mr-2">
{organisation.subscription ? ( {organisation.subscription ? (
<span>{i18n._(SUBSCRIPTION_STATUS_MAP[organisation.subscription.status])} subscription found</span> <span className="flex items-center gap-2">
{organisation.subscription.status === SubscriptionStatus.ACTIVE && (
<span className="h-2 w-2 shrink-0 rounded-full bg-green-600 dark:bg-green-400" aria-hidden="true" />
)}
<span>{i18n._(SUBSCRIPTION_STATUS_MAP[organisation.subscription.status])} subscription found</span>
</span>
) : ( ) : (
<span> <span>
<Trans>No subscription found</Trans> <Trans>No subscription found</Trans>
@@ -356,6 +347,7 @@ export default function OrganisationGroupSettingsPage({ params, loaderData }: Ro
<div> <div>
<Button <Button
variant="outline" variant="outline"
className="bg-background"
loading={isCreatingStripeCustomer} loading={isCreatingStripeCustomer}
onClick={async () => createStripeCustomer({ organisationId })} onClick={async () => createStripeCustomer({ organisationId })}
> >
@@ -366,7 +358,7 @@ export default function OrganisationGroupSettingsPage({ params, loaderData }: Ro
{organisation.customerId && !organisation.subscription && ( {organisation.customerId && !organisation.subscription && (
<div> <div>
<Button variant="outline" asChild> <Button variant="outline" className="bg-background" asChild>
<Link <Link
target="_blank" target="_blank"
to={`https://dashboard.stripe.com/customers/${organisation.customerId}?create=subscription&subscription_default_customer=${organisation.customerId}`} to={`https://dashboard.stripe.com/customers/${organisation.customerId}?create=subscription&subscription_default_customer=${organisation.customerId}`}
@@ -383,13 +375,13 @@ export default function OrganisationGroupSettingsPage({ params, loaderData }: Ro
<AdminOrganisationSyncSubscriptionDialog <AdminOrganisationSyncSubscriptionDialog
organisationId={organisationId} organisationId={organisationId}
trigger={ trigger={
<Button variant="outline"> <Button variant="outline" className="bg-background">
<Trans>Sync Stripe subscription</Trans> <Trans>Sync Stripe subscription</Trans>
</Button> </Button>
} }
/> />
<Button variant="outline" asChild> <Button variant="outline" className="bg-background" asChild>
<Link <Link
target="_blank" target="_blank"
to={`https://dashboard.stripe.com/subscriptions/${organisation.subscription.planId}`} to={`https://dashboard.stripe.com/subscriptions/${organisation.subscription.planId}`}
@@ -406,21 +398,27 @@ export default function OrganisationGroupSettingsPage({ params, loaderData }: Ro
<div className="mt-16 space-y-10"> <div className="mt-16 space-y-10">
<div> <div>
<label className="font-medium text-sm leading-none"> <h3 className="font-semibold text-base">
<Trans>Organisation Members</Trans> <Trans>Organisation Members</Trans>
</label> </h3>
<p className="mt-1 text-muted-foreground text-sm">
<Trans>People with access to this organisation.</Trans>
</p>
<div className="my-2"> <div className="mt-3">
<DataTable columns={organisationMembersColumns} data={organisation.members} /> <DataTable columns={organisationMembersColumns} data={organisation.members} />
</div> </div>
</div> </div>
<div> <div>
<label className="font-medium text-sm leading-none"> <h3 className="font-semibold text-base">
<Trans>Organisation Teams</Trans> <Trans>Organisation Teams</Trans>
</label> </h3>
<p className="mt-1 text-muted-foreground text-sm">
<Trans>Teams that belong to this organisation.</Trans>
</p>
<div className="my-2"> <div className="mt-3">
<DataTable columns={teamsColumns} data={organisation.teams} /> <DataTable columns={teamsColumns} data={organisation.teams} />
</div> </div>
</div> </div>
@@ -648,7 +646,7 @@ const OrganisationAdminForm = ({ organisation, licenseFlags }: OrganisationAdmin
<FormLabel className="flex items-center"> <FormLabel className="flex items-center">
<Trans>Inherited subscription claim</Trans> <Trans>Inherited subscription claim</Trans>
<Tooltip> <Tooltip>
<TooltipTrigger> <TooltipTrigger type="button">
<InfoIcon className="mx-2 h-4 w-4" /> <InfoIcon className="mx-2 h-4 w-4" />
</TooltipTrigger> </TooltipTrigger>
@@ -681,10 +679,15 @@ const OrganisationAdminForm = ({ organisation, licenseFlags }: OrganisationAdmin
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
</FormLabel> </FormLabel>
<FormControl> <div className="rounded-lg border bg-muted/40 px-3 py-2.5 text-sm">
<Input disabled {...field} /> {field.value ? (
</FormControl> <span className="font-mono text-foreground">{field.value}</span>
<FormMessage /> ) : (
<span className="text-muted-foreground">
<Trans>No inherited claim</Trans>
</span>
)}
</div>
</FormItem> </FormItem>
)} )}
/> />
@@ -715,108 +718,113 @@ const OrganisationAdminForm = ({ organisation, licenseFlags }: OrganisationAdmin
)} )}
/> />
<FormField <div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
control={form.control} <FormField
name="claims.teamCount" control={form.control}
render={({ field }) => ( name="claims.teamCount"
<FormItem> render={({ field }) => (
<FormLabel> <FormItem>
<Trans>Team Count</Trans> <FormLabel>
</FormLabel> <Trans>Team Count</Trans>
<FormControl> </FormLabel>
<Input <FormControl>
type="number" <Input
min={0} type="number"
{...field} min={0}
onChange={(e) => field.onChange(parseInt(e.target.value, 10) || 0)} {...field}
/> onChange={(e) => field.onChange(parseInt(e.target.value, 10) || 0)}
</FormControl> />
<FormDescription> </FormControl>
<Trans>Number of teams allowed. 0 = Unlimited</Trans> <FormDescription>
</FormDescription> <Trans>Number of teams allowed. 0 = Unlimited</Trans>
<FormMessage /> </FormDescription>
</FormItem> <FormMessage />
)} </FormItem>
/> )}
/>
<FormField <FormField
control={form.control} control={form.control}
name="claims.memberCount" name="claims.memberCount"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel> <FormLabel>
<Trans>Member Count</Trans> <Trans>Member Count</Trans>
</FormLabel> </FormLabel>
<FormControl> <FormControl>
<Input <Input
type="number" type="number"
min={0} min={0}
{...field} {...field}
onChange={(e) => field.onChange(parseInt(e.target.value, 10) || 0)} onChange={(e) => field.onChange(parseInt(e.target.value, 10) || 0)}
/> />
</FormControl> </FormControl>
<FormDescription> <FormDescription>
<Trans>Number of members allowed. 0 = Unlimited</Trans> <Trans>Number of members allowed. 0 = Unlimited</Trans>
</FormDescription> </FormDescription>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
)} )}
/> />
<FormField <FormField
control={form.control} control={form.control}
name="claims.envelopeItemCount" name="claims.envelopeItemCount"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel> <FormLabel>
<Trans>Envelope Item Count</Trans> <Trans>Envelope Item Count</Trans>
</FormLabel> </FormLabel>
<FormControl> <FormControl>
<Input <Input
type="number" type="number"
min={1} min={1}
{...field} {...field}
onChange={(e) => field.onChange(parseInt(e.target.value, 10) || 0)} onChange={(e) => field.onChange(parseInt(e.target.value, 10) || 0)}
/> />
</FormControl> </FormControl>
<FormDescription> <FormDescription>
<Trans>Maximum number of uploaded files per envelope allowed</Trans> <Trans>Maximum number of uploaded files per envelope allowed</Trans>
</FormDescription> </FormDescription>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
)} )}
/> />
<FormField <FormField
control={form.control} control={form.control}
name="claims.recipientCount" name="claims.recipientCount"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel> <FormLabel>
<Trans>Recipient Count</Trans> <Trans>Recipient Count</Trans>
</FormLabel> </FormLabel>
<FormControl> <FormControl>
<Input <Input
type="number" type="number"
min={0} min={0}
{...field} {...field}
onChange={(e) => field.onChange(parseInt(e.target.value, 10) || 0)} onChange={(e) => field.onChange(parseInt(e.target.value, 10) || 0)}
/> />
</FormControl> </FormControl>
<FormDescription> <FormDescription>
<Trans>Maximum number of recipients per document allowed. 0 = Unlimited</Trans> <Trans>Maximum number of recipients per document allowed. 0 = Unlimited</Trans>
</FormDescription> </FormDescription>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
)} )}
/> />
</div>
<div> <div>
<FormLabel> <h3 className="font-semibold text-base">
<Trans>Feature Flags</Trans> <Trans>Feature Flags</Trans>
</FormLabel> </h3>
<p className="mt-1 text-muted-foreground text-sm">
<Trans>Capabilities enabled for this organisation.</Trans>
</p>
<div className="mt-2 space-y-2 rounded-md border p-4"> <div className="mt-3 space-y-2 rounded-md border p-4">
{Object.values(SUBSCRIPTION_CLAIM_FEATURE_FLAGS).map(({ key, label, isEnterprise }) => { {Object.values(SUBSCRIPTION_CLAIM_FEATURE_FLAGS).map(({ key, label, isEnterprise }) => {
const isRestrictedFeature = isEnterprise && !licenseFlags?.[key as keyof TLicenseClaim]; // eslint-disable-line @typescript-eslint/consistent-type-assertions const isRestrictedFeature = isEnterprise && !licenseFlags?.[key as keyof TLicenseClaim]; // eslint-disable-line @typescript-eslint/consistent-type-assertions
@@ -287,7 +287,11 @@ export default function AdminTeamPage({ params }: Route.ComponentProps) {
</AccordionTrigger> </AccordionTrigger>
<AccordionContent> <AccordionContent>
<div className="mt-4"> <div className="mt-4">
<AdminGlobalSettingsSection settings={team.teamGlobalSettings} isTeam /> <AdminGlobalSettingsSection
settings={team.teamGlobalSettings}
inheritedSettings={team.organisation.organisationGlobalSettings}
isTeam
/>
</div> </div>
</AccordionContent> </AccordionContent>
</AccordionItem> </AccordionItem>
@@ -1,7 +1,6 @@
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation'; import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { useSession } from '@documenso/lib/client-only/providers/session'; import { useSession } from '@documenso/lib/client-only/providers/session';
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app'; import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
import { putFile } from '@documenso/lib/universal/upload/put-file';
import { canExecuteOrganisationAction, isPersonalLayout } from '@documenso/lib/utils/organisations'; import { canExecuteOrganisationAction, isPersonalLayout } from '@documenso/lib/utils/organisations';
import type { SanitizeBrandingCssWarning } from '@documenso/lib/utils/sanitize-branding-css'; import type { SanitizeBrandingCssWarning } from '@documenso/lib/utils/sanitize-branding-css';
import { trpc } from '@documenso/trpc/react'; import { trpc } from '@documenso/trpc/react';
@@ -49,26 +48,29 @@ export default function OrganisationSettingsBrandingPage() {
const { mutateAsync: updateOrganisationSettings } = trpc.organisation.settings.update.useMutation(); const { mutateAsync: updateOrganisationSettings } = trpc.organisation.settings.update.useMutation();
const { mutateAsync: updateOrganisationBrandingLogo } = trpc.organisation.settings.updateBrandingLogo.useMutation();
const onBrandingPreferencesFormSubmit = async (data: TBrandingPreferencesFormSchema) => { const onBrandingPreferencesFormSubmit = async (data: TBrandingPreferencesFormSchema) => {
try { try {
const { brandingEnabled, brandingLogo, brandingUrl, brandingCompanyDetails, brandingColors, brandingCss } = data; const { brandingEnabled, brandingLogo, brandingUrl, brandingCompanyDetails, brandingColors, brandingCss } = data;
let uploadedBrandingLogo: string | undefined; // Upload (or clear) the logo through the dedicated, server-validated route.
if (brandingLogo instanceof File || brandingLogo === null) {
const formData = new FormData();
if (brandingLogo) { formData.append('payload', JSON.stringify({ organisationId: organisation.id }));
uploadedBrandingLogo = JSON.stringify(await putFile(brandingLogo));
}
// Empty the branding logo if the user unsets it. if (brandingLogo instanceof File) {
if (brandingLogo === null) { formData.append('brandingLogo', brandingLogo);
uploadedBrandingLogo = ''; }
await updateOrganisationBrandingLogo(formData);
} }
const result = await updateOrganisationSettings({ const result = await updateOrganisationSettings({
organisationId: organisation.id, organisationId: organisation.id,
data: { data: {
brandingEnabled: brandingEnabled ?? undefined, brandingEnabled: brandingEnabled ?? undefined,
brandingLogo: uploadedBrandingLogo,
brandingUrl, brandingUrl,
brandingCompanyDetails, brandingCompanyDetails,
brandingColors, brandingColors,
@@ -88,7 +88,7 @@ export default function OrganisationSettingsDocumentPage() {
typedSignatureEnabled: signatureTypes.includes(DocumentSignatureType.TYPE), typedSignatureEnabled: signatureTypes.includes(DocumentSignatureType.TYPE),
uploadSignatureEnabled: signatureTypes.includes(DocumentSignatureType.UPLOAD), uploadSignatureEnabled: signatureTypes.includes(DocumentSignatureType.UPLOAD),
drawSignatureEnabled: signatureTypes.includes(DocumentSignatureType.DRAW), drawSignatureEnabled: signatureTypes.includes(DocumentSignatureType.DRAW),
delegateDocumentOwnership: delegateDocumentOwnership, delegateDocumentOwnership,
aiFeaturesEnabled, aiFeaturesEnabled,
envelopeExpirationPeriod: envelopeExpirationPeriod ?? undefined, envelopeExpirationPeriod: envelopeExpirationPeriod ?? undefined,
reminderSettings: reminderSettings ?? undefined, reminderSettings: reminderSettings ?? undefined,
@@ -3,6 +3,7 @@ import { ORGANISATION_MEMBER_ROLE_HIERARCHY } from '@documenso/lib/constants/org
import { EXTENDED_ORGANISATION_MEMBER_ROLE_MAP } from '@documenso/lib/constants/organisations-translations'; import { EXTENDED_ORGANISATION_MEMBER_ROLE_MAP } from '@documenso/lib/constants/organisations-translations';
import { TEAM_MEMBER_ROLE_MAP } from '@documenso/lib/constants/teams-translations'; import { TEAM_MEMBER_ROLE_MAP } from '@documenso/lib/constants/teams-translations';
import { AppError } from '@documenso/lib/errors/app-error'; import { AppError } from '@documenso/lib/errors/app-error';
import { ZNameSchema } from '@documenso/lib/types/name';
import { trpc } from '@documenso/trpc/react'; import { trpc } from '@documenso/trpc/react';
import type { TFindOrganisationGroupsResponse } from '@documenso/trpc/server/organisation-router/find-organisation-groups.types'; import type { TFindOrganisationGroupsResponse } from '@documenso/trpc/server/organisation-router/find-organisation-groups.types';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
@@ -28,7 +29,6 @@ import { useMemo, useState } from 'react';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { Link } from 'react-router'; import { Link } from 'react-router';
import { z } from 'zod'; import { z } from 'zod';
import { OrganisationGroupDeleteDialog } from '~/components/dialogs/organisation-group-delete-dialog'; import { OrganisationGroupDeleteDialog } from '~/components/dialogs/organisation-group-delete-dialog';
import { GenericErrorLayout } from '~/components/general/generic-error-layout'; import { GenericErrorLayout } from '~/components/general/generic-error-layout';
import { import {
@@ -36,7 +36,6 @@ import {
OrganisationMembersMultiSelectCombobox, OrganisationMembersMultiSelectCombobox,
} from '~/components/general/organisation-members-multiselect-combobox'; } from '~/components/general/organisation-members-multiselect-combobox';
import { SettingsHeader } from '~/components/general/settings-header'; import { SettingsHeader } from '~/components/general/settings-header';
import type { Route } from './+types/o.$orgUrl.settings.groups.$id'; import type { Route } from './+types/o.$orgUrl.settings.groups.$id';
export default function OrganisationGroupSettingsPage({ params }: Route.ComponentProps) { export default function OrganisationGroupSettingsPage({ params }: Route.ComponentProps) {
@@ -113,7 +112,7 @@ export default function OrganisationGroupSettingsPage({ params }: Route.Componen
} }
const ZUpdateOrganisationGroupFormSchema = z.object({ const ZUpdateOrganisationGroupFormSchema = z.object({
name: z.string().min(1, msg`Name is required`.id), name: ZNameSchema,
organisationRole: z.nativeEnum(OrganisationMemberRole), organisationRole: z.nativeEnum(OrganisationMemberRole),
memberIds: z.array(z.string()), memberIds: z.array(z.string()),
}); });
@@ -1,6 +1,5 @@
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation'; import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app'; import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
import { putFile } from '@documenso/lib/universal/upload/put-file';
import { canExecuteOrganisationAction } from '@documenso/lib/utils/organisations'; import { canExecuteOrganisationAction } from '@documenso/lib/utils/organisations';
import type { SanitizeBrandingCssWarning } from '@documenso/lib/utils/sanitize-branding-css'; import type { SanitizeBrandingCssWarning } from '@documenso/lib/utils/sanitize-branding-css';
import { trpc } from '@documenso/trpc/react'; import { trpc } from '@documenso/trpc/react';
@@ -38,6 +37,7 @@ export default function TeamsSettingsPage() {
}); });
const { mutateAsync: updateTeamSettings } = trpc.team.settings.update.useMutation(); const { mutateAsync: updateTeamSettings } = trpc.team.settings.update.useMutation();
const { mutateAsync: updateTeamBrandingLogo } = trpc.team.settings.updateBrandingLogo.useMutation();
const canConfigureBranding = organisation.organisationClaim.flags.allowCustomBranding || !IS_BILLING_ENABLED(); const canConfigureBranding = organisation.organisationClaim.flags.allowCustomBranding || !IS_BILLING_ENABLED();
@@ -48,22 +48,23 @@ export default function TeamsSettingsPage() {
try { try {
const { brandingEnabled, brandingLogo, brandingUrl, brandingCompanyDetails, brandingColors, brandingCss } = data; const { brandingEnabled, brandingLogo, brandingUrl, brandingCompanyDetails, brandingColors, brandingCss } = data;
let uploadedBrandingLogo: string | undefined; // Upload (or clear) the logo through the dedicated, server-validated route.
if (brandingLogo instanceof File || brandingLogo === null) {
const formData = new FormData();
if (brandingLogo) { formData.append('payload', JSON.stringify({ teamId: team.id }));
uploadedBrandingLogo = JSON.stringify(await putFile(brandingLogo));
}
// Empty the branding logo if the user unsets it. if (brandingLogo instanceof File) {
if (brandingLogo === null) { formData.append('brandingLogo', brandingLogo);
uploadedBrandingLogo = ''; }
await updateTeamBrandingLogo(formData);
} }
const result = await updateTeamSettings({ const result = await updateTeamSettings({
teamId: team.id, teamId: team.id,
data: { data: {
brandingEnabled, brandingEnabled,
brandingLogo: uploadedBrandingLogo,
brandingUrl: brandingUrl || null, brandingUrl: brandingUrl || null,
brandingCompanyDetails: brandingCompanyDetails || null, brandingCompanyDetails: brandingCompanyDetails || null,
brandingColors, brandingColors,
@@ -82,7 +82,7 @@ export default function TeamsSettingsPage() {
uploadSignatureEnabled: signatureTypes.includes(DocumentSignatureType.UPLOAD), uploadSignatureEnabled: signatureTypes.includes(DocumentSignatureType.UPLOAD),
drawSignatureEnabled: signatureTypes.includes(DocumentSignatureType.DRAW), drawSignatureEnabled: signatureTypes.includes(DocumentSignatureType.DRAW),
}), }),
delegateDocumentOwnership: delegateDocumentOwnership, delegateDocumentOwnership,
}, },
}); });
+4 -4
View File
@@ -36,8 +36,8 @@
"@lingui/react": "^5.6.0", "@lingui/react": "^5.6.0",
"@oslojs/crypto": "^1.0.1", "@oslojs/crypto": "^1.0.1",
"@oslojs/encoding": "^1.1.0", "@oslojs/encoding": "^1.1.0",
"@react-router/node": "^7.12.0", "@react-router/node": "^7.18.1",
"@react-router/serve": "^7.12.0", "@react-router/serve": "^7.18.1",
"@simplewebauthn/browser": "^13.2.2", "@simplewebauthn/browser": "^13.2.2",
"@simplewebauthn/server": "^13.2.2", "@simplewebauthn/server": "^13.2.2",
"@tanstack/react-query": "5.90.10", "@tanstack/react-query": "5.90.10",
@@ -81,8 +81,8 @@
"@babel/preset-typescript": "^7.28.5", "@babel/preset-typescript": "^7.28.5",
"@lingui/babel-plugin-lingui-macro": "^5.6.0", "@lingui/babel-plugin-lingui-macro": "^5.6.0",
"@lingui/vite-plugin": "^5.6.0", "@lingui/vite-plugin": "^5.6.0",
"@react-router/dev": "^7.12.0", "@react-router/dev": "^7.18.1",
"@react-router/remix-routes-option-adapter": "^7.12.0", "@react-router/remix-routes-option-adapter": "^7.18.1",
"@rollup/plugin-babel": "^6.1.0", "@rollup/plugin-babel": "^6.1.0",
"@rollup/plugin-commonjs": "^28.0.9", "@rollup/plugin-commonjs": "^28.0.9",
"@rollup/plugin-json": "^6.1.0", "@rollup/plugin-json": "^6.1.0",
+1 -28
View File
@@ -1,9 +1,8 @@
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session'; import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app'; import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; import { AppError } from '@documenso/lib/errors/app-error';
import { verifyEmbeddingPresignToken } from '@documenso/lib/server-only/embedding-presign/verify-embedding-presign-token'; import { verifyEmbeddingPresignToken } from '@documenso/lib/server-only/embedding-presign/verify-embedding-presign-token';
import { putNormalizedPdfFileServerSide } from '@documenso/lib/universal/upload/put-file.server'; import { putNormalizedPdfFileServerSide } from '@documenso/lib/universal/upload/put-file.server';
import { getPresignPostUrl } from '@documenso/lib/universal/upload/server-actions';
import { prisma } from '@documenso/prisma'; import { prisma } from '@documenso/prisma';
import { sValidator } from '@hono/standard-validator'; import { sValidator } from '@hono/standard-validator';
import type { Prisma } from '@prisma/client'; import type { Prisma } from '@prisma/client';
@@ -12,14 +11,11 @@ import { Hono } from 'hono';
import type { HonoEnv } from '../../router'; import type { HonoEnv } from '../../router';
import { checkEnvelopeFileAccess, handleEnvelopeItemFileRequest, resolveFileUploadUserId } from './files.helpers'; import { checkEnvelopeFileAccess, handleEnvelopeItemFileRequest, resolveFileUploadUserId } from './files.helpers';
import { import {
isAllowedUploadContentType,
type TGetPresignedPostUrlResponse,
ZGetEnvelopeItemFileDownloadRequestParamsSchema, ZGetEnvelopeItemFileDownloadRequestParamsSchema,
ZGetEnvelopeItemFileRequestParamsSchema, ZGetEnvelopeItemFileRequestParamsSchema,
ZGetEnvelopeItemFileRequestQuerySchema, ZGetEnvelopeItemFileRequestQuerySchema,
ZGetEnvelopeItemFileTokenDownloadRequestParamsSchema, ZGetEnvelopeItemFileTokenDownloadRequestParamsSchema,
ZGetEnvelopeItemFileTokenRequestParamsSchema, ZGetEnvelopeItemFileTokenRequestParamsSchema,
ZGetPresignedPostUrlRequestSchema,
ZUploadPdfRequestSchema, ZUploadPdfRequestSchema,
} from './files.types'; } from './files.types';
import getEnvelopeItemPdfRoute from './routes/get-envelope-item-pdf'; import getEnvelopeItemPdfRoute from './routes/get-envelope-item-pdf';
@@ -61,29 +57,6 @@ export const filesRoute = new Hono<HonoEnv>()
return c.json({ error: 'Upload failed' }, 500); return c.json({ error: 'Upload failed' }, 500);
} }
}) })
.post('/presigned-post-url', sValidator('json', ZGetPresignedPostUrlRequestSchema), async (c) => {
const userId = await resolveFileUploadUserId(c);
if (!userId) {
return c.json({ error: 'Unauthorized' }, 401);
}
const { fileName, contentType } = c.req.valid('json');
if (!isAllowedUploadContentType(contentType)) {
return c.json({ error: 'Unsupported content type' }, 400);
}
try {
const { key, url } = await getPresignPostUrl(fileName, contentType, userId);
return c.json({ key, url } satisfies TGetPresignedPostUrlResponse);
} catch (err) {
console.error(err);
throw new AppError(AppErrorCode.UNKNOWN_ERROR);
}
})
.get( .get(
'/envelope/:envelopeId/envelopeItem/:envelopeItemId', '/envelope/:envelopeId/envelopeItem/:envelopeItemId',
sValidator('param', ZGetEnvelopeItemFileRequestParamsSchema), sValidator('param', ZGetEnvelopeItemFileRequestParamsSchema),
@@ -13,27 +13,6 @@ export const ZUploadPdfResponseSchema = DocumentDataSchema.pick({
export type TUploadPdfRequest = z.infer<typeof ZUploadPdfRequestSchema>; export type TUploadPdfRequest = z.infer<typeof ZUploadPdfRequestSchema>;
export type TUploadPdfResponse = z.infer<typeof ZUploadPdfResponseSchema>; export type TUploadPdfResponse = z.infer<typeof ZUploadPdfResponseSchema>;
export const ALLOWED_UPLOAD_CONTENT_TYPES = ['application/pdf', 'image/jpeg', 'image/png', 'image/webp'] as const;
export const isAllowedUploadContentType = (contentType: string): boolean => {
const normalizedContentType = contentType.split(';').at(0)?.trim().toLowerCase();
return ALLOWED_UPLOAD_CONTENT_TYPES.some((allowed) => allowed === normalizedContentType);
};
export const ZGetPresignedPostUrlRequestSchema = z.object({
fileName: z.string().min(1),
contentType: z.string().min(1),
});
export const ZGetPresignedPostUrlResponseSchema = z.object({
key: z.string().min(1),
url: z.string().min(1),
});
export type TGetPresignedPostUrlRequest = z.infer<typeof ZGetPresignedPostUrlRequestSchema>;
export type TGetPresignedPostUrlResponse = z.infer<typeof ZGetPresignedPostUrlResponseSchema>;
export const ZGetEnvelopeItemFileRequestParamsSchema = z.object({ export const ZGetEnvelopeItemFileRequestParamsSchema = z.object({
envelopeId: z.string().min(1), envelopeId: z.string().min(1),
envelopeItemId: z.string().min(1), envelopeItemId: z.string().min(1),
-1
View File
@@ -105,7 +105,6 @@ app.route('/api/auth', auth);
// Files route. // Files route.
app.use('/api/files/upload-pdf', fileRateLimitMiddleware); app.use('/api/files/upload-pdf', fileRateLimitMiddleware);
app.use('/api/files/presigned-post-url', fileRateLimitMiddleware);
app.route('/api/files', filesRoute); app.route('/api/files', filesRoute);
// AI route. // AI route.
+2048 -1264
View File
File diff suppressed because it is too large Load Diff
@@ -44,46 +44,6 @@ test.describe('File upload endpoint authorization', () => {
expect(res.status()).toBe(401); expect(res.status()).toBe(401);
}); });
test('rejects an unauthenticated presigned-post-url request', async ({ request }) => {
const res = await request.post(`${WEBAPP_BASE_URL}/api/files/presigned-post-url`, {
headers: { 'Content-Type': 'application/json' },
data: { fileName: 'test.pdf', contentType: 'application/pdf' },
});
expect(res.ok()).toBeFalsy();
expect(res.status()).toBe(401);
});
test('rejects a presigned-post-url request with an invalid presign token', async ({ request }) => {
const res = await request.post(`${WEBAPP_BASE_URL}/api/files/presigned-post-url`, {
headers: {
'Content-Type': 'application/json',
Authorization: 'Bearer not-a-real-token',
},
data: { fileName: 'test.pdf', contentType: 'application/pdf' },
});
expect(res.ok()).toBeFalsy();
expect(res.status()).toBe(401);
});
test('rejects a presigned-post-url request with a disallowed content type', async ({ request }) => {
const { user, team } = await seedUser();
const presignToken = await createPresignTokenForUser(user.id, team.id);
const res = await request.post(`${WEBAPP_BASE_URL}/api/files/presigned-post-url`, {
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${presignToken}`,
},
data: { fileName: 'malware.exe', contentType: 'application/x-msdownload' },
});
// Authenticated, but the content type is not on the allow-list.
expect(res.ok()).toBeFalsy();
expect(res.status()).toBe(400);
});
test('allows an upload-pdf request authorized by a valid presign token', async ({ request }) => { test('allows an upload-pdf request authorized by a valid presign token', async ({ request }) => {
const { user, team } = await seedUser(); const { user, team } = await seedUser();
const presignToken = await createPresignTokenForUser(user.id, team.id); const presignToken = await createPresignTokenForUser(user.id, team.id);
@@ -0,0 +1,37 @@
import { optimiseBrandingLogo } from '@documenso/lib/utils/images/logo';
import { expect, test } from '@playwright/test';
import sharp from 'sharp';
const makePng = async (width = 1200, height = 1200) =>
sharp({
create: { width, height, channels: 3, background: { r: 10, g: 20, b: 30 } },
})
.png()
.toBuffer();
test.describe('optimiseBrandingLogo', () => {
test('re-encodes a valid image to a PNG buffer', async () => {
const input = await makePng();
const output = await optimiseBrandingLogo(input);
const metadata = await sharp(output).metadata();
expect(metadata.format).toBe('png');
});
test('bounds the image to a maximum of 512px on its largest side', async () => {
const input = await makePng(2000, 1000);
const output = await optimiseBrandingLogo(input);
const metadata = await sharp(output).metadata();
expect(metadata.width).toBeLessThanOrEqual(512);
expect(metadata.height).toBeLessThanOrEqual(512);
});
test('rejects input that is not a valid image', async () => {
await expect(optimiseBrandingLogo(Buffer.from('this is not an image'))).rejects.toThrow();
});
});
@@ -0,0 +1,225 @@
import fs from 'node:fs';
import path from 'node:path';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { prisma } from '@documenso/prisma';
import { seedUser } from '@documenso/prisma/seed/users';
import { expect, type Page, test } from '@playwright/test';
import { apiSignin } from './fixtures/authentication';
test.describe.configure({ mode: 'parallel' });
const LOGO_PATH = path.join(__dirname, '../../assets/logo.png');
type MultipartFile = { name: string; mimeType: string; buffer: Buffer };
const enableBrandingAndUpload = async (page: Page) => {
// Enable custom branding so the file input is no longer disabled.
await page.getByTestId('enable-branding').click();
await page.getByRole('option', { name: 'Yes' }).click();
// Upload the logo file through the real multipart route.
await page.locator('input[type="file"]').setInputFiles(LOGO_PATH);
await page.getByRole('button', { name: 'Save changes' }).first().click();
await expect(page.getByText('Your branding preferences have been updated').first()).toBeVisible();
};
/**
* POST a logo straight to the dedicated multipart tRPC route using the
* authenticated browser cookies. This bypasses the client-side form validation,
* which is the only way to exercise the server-side image validation /
* sanitisation (`zfdBrandingImageFile` + `optimiseBrandingLogo`) and the entitlement gate.
*/
const postOrganisationBrandingLogo = async (page: Page, organisationId: string, file: MultipartFile | null) => {
const multipart: Record<string, string | MultipartFile> = {
payload: JSON.stringify({ organisationId }),
};
if (file) {
multipart.brandingLogo = file;
}
return await page
.context()
.request.post(`${NEXT_PUBLIC_WEBAPP_URL()}/api/trpc/organisation.settings.updateBrandingLogo`, { multipart });
};
/**
* Grant the organisation the custom-branding entitlement. The positive branding
* flows require it whenever billing is enabled; with billing disabled the gate is
* bypassed, so this keeps these tests valid in both modes.
*/
const grantCustomBranding = async (organisationClaimId: string) => {
await prisma.organisationClaim.update({
where: { id: organisationClaimId },
data: { flags: { allowLegacyEnvelopes: true, allowCustomBranding: true } },
});
};
test('[BRANDING_LOGO]: uploads an organisation branding logo via the dedicated route', async ({ page }) => {
const { user, organisation } = await seedUser({ isPersonalOrganisation: false });
await grantCustomBranding(organisation.organisationClaim.id);
await apiSignin({
page,
email: user.email,
redirectPath: `/o/${organisation.url}/settings/branding`,
});
await enableBrandingAndUpload(page);
const settings = await prisma.organisationGlobalSettings.findUniqueOrThrow({
where: { id: organisation.organisationGlobalSettingsId },
});
expect(settings.brandingLogo).toBeTruthy();
const parsed = JSON.parse(settings.brandingLogo);
expect(parsed).toHaveProperty('type');
expect(parsed).toHaveProperty('data');
});
test('[BRANDING_LOGO]: uploads a team branding logo via the dedicated route', async ({ page }) => {
const { user, team, organisation } = await seedUser({ isPersonalOrganisation: false });
await grantCustomBranding(organisation.organisationClaim.id);
await apiSignin({
page,
email: user.email,
redirectPath: `/t/${team.url}/settings/branding`,
});
await enableBrandingAndUpload(page);
// TeamGlobalSettings has no `teamId` column (the FK lives on Team), so read it
// through the team relation.
const teamWithSettings = await prisma.team.findUniqueOrThrow({
where: { id: team.id },
include: { teamGlobalSettings: true },
});
expect(teamWithSettings.teamGlobalSettings?.brandingLogo).toBeTruthy();
const parsed = JSON.parse(teamWithSettings.teamGlobalSettings?.brandingLogo ?? '');
expect(parsed).toHaveProperty('type');
expect(parsed).toHaveProperty('data');
});
test('[BRANDING_LOGO]: clears the organisation branding logo when the user removes it', async ({ page }) => {
const { user, organisation } = await seedUser({ isPersonalOrganisation: false });
await grantCustomBranding(organisation.organisationClaim.id);
await apiSignin({
page,
email: user.email,
redirectPath: `/o/${organisation.url}/settings/branding`,
});
await enableBrandingAndUpload(page);
// Confirm the logo was stored before we clear it.
const settings = await prisma.organisationGlobalSettings.findUniqueOrThrow({
where: { id: organisation.organisationGlobalSettingsId },
});
expect(settings.brandingLogo).toBeTruthy();
// Remove the logo and save again.
await page.getByRole('button', { name: 'Remove' }).click();
await page.getByRole('button', { name: 'Save changes' }).first().click();
// Clearing the logo persists an empty string via the dedicated route.
await expect
.poll(async () => {
const updated = await prisma.organisationGlobalSettings.findUniqueOrThrow({
where: { id: organisation.organisationGlobalSettingsId },
});
return updated.brandingLogo;
})
.toBe('');
});
test('[BRANDING_LOGO]: validates and sanitises the logo on the server', async ({ page }) => {
const { user, organisation } = await seedUser({ isPersonalOrganisation: false });
await grantCustomBranding(organisation.organisationClaim.id);
await apiSignin({
page,
email: user.email,
redirectPath: `/o/${organisation.url}/settings/branding`,
});
// Positive control: a genuine PNG is accepted and stored. This also proves the
// direct multipart request shape matches what the route expects.
const validResponse = await postOrganisationBrandingLogo(page, organisation.id, {
name: 'logo.png',
mimeType: 'image/png',
buffer: fs.readFileSync(LOGO_PATH),
});
expect(validResponse.ok()).toBeTruthy();
const afterValid = await prisma.organisationGlobalSettings.findUniqueOrThrow({
where: { id: organisation.organisationGlobalSettingsId },
});
expect(afterValid.brandingLogo).toBeTruthy();
// Bytes that pass the MIME/size allowlist but are not a real image must be
// rejected by the server (the `sharp` re-encode) without changing stored state.
const invalidResponse = await postOrganisationBrandingLogo(page, organisation.id, {
name: 'fake.png',
mimeType: 'image/png',
buffer: Buffer.from('this is definitely not a valid png'),
});
expect(invalidResponse.ok()).toBeFalsy();
expect(invalidResponse.status()).toBeGreaterThanOrEqual(400);
expect(invalidResponse.status()).toBeLessThan(500);
const afterInvalid = await prisma.organisationGlobalSettings.findUniqueOrThrow({
where: { id: organisation.organisationGlobalSettingsId },
});
// The previously stored, valid logo is left untouched by the rejected upload.
expect(afterInvalid.brandingLogo).toBe(afterValid.brandingLogo);
});
test('[BRANDING_LOGO]: rejects setting a logo without the custom-branding entitlement', async ({ page }) => {
// The entitlement is only enforced when billing is enabled; with billing off
// the check is intentionally skipped server-side, so this can't be exercised.
test.skip(
process.env.NEXT_PUBLIC_FEATURE_BILLING_ENABLED !== 'true',
'Entitlement is only enforced when billing is enabled.',
);
// Seeded organisations have no `allowCustomBranding` claim flag.
const { user, organisation } = await seedUser({ isPersonalOrganisation: false });
await apiSignin({
page,
email: user.email,
redirectPath: `/o/${organisation.url}/settings/branding`,
});
const response = await postOrganisationBrandingLogo(page, organisation.id, {
name: 'logo.png',
mimeType: 'image/png',
buffer: fs.readFileSync(LOGO_PATH),
});
expect(response.ok()).toBeFalsy();
const settings = await prisma.organisationGlobalSettings.findUniqueOrThrow({
where: { id: organisation.organisationGlobalSettingsId },
});
expect(settings.brandingLogo).toBeFalsy();
});
@@ -142,3 +142,38 @@ test('[SIGNING_BRANDING]: embedded signing does not render custom logo Brand Web
await expect(page.locator(`a[href="${BRANDING_URL}"]`)).toHaveCount(0); await expect(page.locator(`a[href="${BRANDING_URL}"]`)).toHaveCount(0);
await expect(page.getByRole('link', { name: `${team.name}'s Logo` })).toHaveCount(0); await expect(page.getByRole('link', { name: `${team.name}'s Logo` })).toHaveCount(0);
}); });
test('[SIGNING_BRANDING]: custom logo renders when branding is enabled and is hidden when disabled', async ({
page,
}) => {
const { user, team, organisation } = await seedUser();
await enableOrganisationBranding({
organisationGlobalSettingsId: organisation.organisationGlobalSettingsId,
});
const { recipients } = await seedPendingDocumentWithFullFields({
owner: user,
teamId: team.id,
recipients: ['enabled-disabled-branding-signer@test.documenso.com'],
fields: [FieldType.SIGNATURE],
updateDocumentOptions: { internalVersion: 2 },
});
// Branding enabled → the custom logo is rendered on the signing page.
await page.goto(`/sign/${recipients[0].token}`);
await expectPlainBrandingLogo(page, `${team.name}'s Logo`);
// Disable branding while keeping the stored logo (the team inherits this).
await prisma.organisationGlobalSettings.update({
where: { id: organisation.organisationGlobalSettingsId },
data: { brandingEnabled: false },
});
// Branding disabled → the custom logo is gone and the Documenso fallback
// (an internal link to "/") is shown instead.
await page.goto(`/sign/${recipients[0].token}`);
await expect(page.getByRole('img', { name: `${team.name}'s Logo` })).toHaveCount(0);
await expect(page.locator('a[href="/"]').first()).toBeVisible();
});
+1 -1
View File
@@ -1,4 +1,4 @@
import { ZNameSchema } from '@documenso/lib/constants/auth'; import { ZNameSchema } from '@documenso/lib/types/name';
import { zEmail } from '@documenso/lib/utils/zod'; import { zEmail } from '@documenso/lib/utils/zod';
import { z } from 'zod'; import { z } from 'zod';
-15
View File
@@ -1,25 +1,10 @@
import MailChecker from 'mailchecker'; import MailChecker from 'mailchecker';
import { z } from 'zod';
import { env } from '../utils/env'; import { env } from '../utils/env';
import { NEXT_PUBLIC_WEBAPP_URL } from './app'; import { NEXT_PUBLIC_WEBAPP_URL } from './app';
export const SALT_ROUNDS = 12; export const SALT_ROUNDS = 12;
export const URL_PATTERN = /https?:\/\/|www\./i;
/**
* Shared name schema that disallows URLs to prevent phishing via email rendering.
*/
export const ZNameSchema = z
.string()
.trim()
.min(3, { message: 'Please enter a valid name.' })
.max(255, { message: 'Name cannot be more than 255 characters.' })
.refine((value) => !URL_PATTERN.test(value), {
message: 'Name cannot contain URLs.',
});
export const IDENTITY_PROVIDER_NAME: Record<string, string> = { export const IDENTITY_PROVIDER_NAME: Record<string, string> = {
DOCUMENSO: 'Documenso', DOCUMENSO: 'Documenso',
GOOGLE: 'Google', GOOGLE: 'Google',
+10
View File
@@ -9,3 +9,13 @@
* cap so a malicious or runaway payload can't exhaust PostCSS/server memory. * cap so a malicious or runaway payload can't exhaust PostCSS/server memory.
*/ */
export const BRANDING_CSS_MAX_LENGTH = 256 * 1024; export const BRANDING_CSS_MAX_LENGTH = 256 * 1024;
/**
* Branding logo upload constraints. Enforced server-side at the TRPC request
* boundary (`zfdBrandingImageFile`) and reused by the client form for matching UX.
*/
export const BRANDING_LOGO_MAX_SIZE_MB = 5;
export const BRANDING_LOGO_MAX_SIZE_BYTES = BRANDING_LOGO_MAX_SIZE_MB * 1024 * 1024;
export const BRANDING_LOGO_ALLOWED_TYPES: string[] = ['image/jpeg', 'image/png', 'image/webp'];
@@ -0,0 +1,26 @@
import { AppError, AppErrorCode } from '../../errors/app-error';
import { putFileServerSide } from '../../universal/upload/put-file.server';
import { optimiseBrandingLogo } from '../../utils/images/logo';
/**
* Validate, sanitise and store an uploaded branding logo. Returns the
* `JSON.stringify({ type, data })` reference persisted in the `brandingLogo`
* column (the same format the serving endpoints already expect).
*/
export const buildBrandingLogoData = async (file: File): Promise<string> => {
const buffer = Buffer.from(await file.arrayBuffer());
const optimised = await optimiseBrandingLogo(buffer).catch(() => {
throw new AppError(AppErrorCode.INVALID_BODY, {
message: 'The branding logo must be a valid image file.',
});
});
const documentData = await putFileServerSide({
name: 'branding-logo.png',
type: 'image/png',
arrayBuffer: async () => Promise.resolve(optimised),
});
return JSON.stringify(documentData);
};
@@ -1,4 +1,4 @@
import { QUOTA_WARNING_THRESHOLD } from './get-quota-alert-kind'; import { isQuotaExceeded, isQuotaNearing } from '../../universal/quota-usage';
export type QuotaFlags = { export type QuotaFlags = {
isDocumentQuotaExceeded: boolean; isDocumentQuotaExceeded: boolean;
@@ -22,39 +22,6 @@ type ComputeQuotaFlagsOptions = {
}; };
}; };
/**
* A quota of `null` means unlimited (never exceeded). A quota of `0` means
* blocked (always exceeded). Otherwise usage `>=` quota is exceeded.
*/
const isQuotaExceeded = (quota: number | null, usage: number): boolean => {
if (quota === null) {
return false;
}
if (quota === 0) {
return true;
}
return usage >= quota;
};
/**
* A counter is "nearing" its quota once usage reaches the warning threshold
* (80% of the quota, rounded up) but has not yet been exceeded. Nearing and
* exceeded are mutually exclusive per counter.
*/
const isQuotaNearing = (quota: number | null, usage: number): boolean => {
if (quota === null || quota === 0) {
return false;
}
if (isQuotaExceeded(quota, usage)) {
return false;
}
return usage >= Math.ceil(quota * QUOTA_WARNING_THRESHOLD);
};
export const computeQuotaFlags = ({ quotas, usage }: ComputeQuotaFlagsOptions): QuotaFlags => { export const computeQuotaFlags = ({ quotas, usage }: ComputeQuotaFlagsOptions): QuotaFlags => {
return { return {
isDocumentQuotaExceeded: isQuotaExceeded(quotas.documentQuota, usage?.documentCount ?? 0), isDocumentQuotaExceeded: isQuotaExceeded(quotas.documentQuota, usage?.documentCount ?? 0),
@@ -1,4 +1,4 @@
export const QUOTA_WARNING_THRESHOLD = 0.8; import { getQuotaWarningCount } from '../../universal/quota-usage';
export type QuotaAlertKind = 'quota' | 'quotaNearing'; export type QuotaAlertKind = 'quota' | 'quotaNearing';
@@ -32,7 +32,7 @@ export const getQuotaAlertKind = (opts: GetQuotaAlertKindOptions): QuotaAlertKin
// From here newCount < quota, so for tiny quotas (1-4) where the rounded-up // From here newCount < quota, so for tiny quotas (1-4) where the rounded-up
// warning threshold equals the quota itself, the warning can never fire — the // warning threshold equals the quota itself, the warning can never fire — the
// exhausting request is handled by the quota branch above. // exhausting request is handled by the quota branch above.
const warningCount = Math.ceil(quota * QUOTA_WARNING_THRESHOLD); const warningCount = getQuotaWarningCount(quota);
const didCrossWarning = newCount >= warningCount && previousCount < warningCount; const didCrossWarning = newCount >= warningCount && previousCount < warningCount;
+145
View File
@@ -0,0 +1,145 @@
import { describe, expect, it } from 'vitest';
import { ZNameSchema } from './name';
describe('ZNameSchema', () => {
describe('valid names', () => {
it('accepts a normal name', () => {
expect(ZNameSchema.safeParse('Example User')).toEqual({
success: true,
data: 'Example User',
});
});
it('accepts international characters', () => {
expect(ZNameSchema.safeParse('Døcumensø Üser')).toEqual({
success: true,
data: 'Døcumensø Üser',
});
});
it('trims surrounding whitespace', () => {
expect(ZNameSchema.safeParse(' Documenso User ')).toEqual({
success: true,
data: 'Documenso User',
});
});
it('accepts names at the minimum length', () => {
expect(ZNameSchema.safeParse('DU')).toEqual({
success: true,
data: 'DU',
});
});
it('accepts names at the maximum length', () => {
const name =
'DocumensoUser DocumensoUser DocumensoUser DocumensoUser DocumensoUser DocumensoUser DocumensoUser Do';
expect(name.length).toBe(100);
expect(ZNameSchema.safeParse(name)).toEqual({
success: true,
data: name,
});
});
});
describe('length validation', () => {
it('rejects names shorter than 2 characters', () => {
expect(ZNameSchema.safeParse('D')).toMatchObject({
success: false,
error: {
issues: [{ message: 'Please enter a valid name.' }],
},
});
});
it('rejects names longer than 100 characters', () => {
const name =
'DocumensoUser DocumensoUser DocumensoUser DocumensoUser DocumensoUser DocumensoUser DocumensoUser Doc';
expect(name.length).toBe(101);
expect(ZNameSchema.safeParse(name)).toMatchObject({
success: false,
error: {
issues: [{ message: 'Name cannot be more than 100 characters.' }],
},
});
});
it('rejects whitespace-only input after trim', () => {
expect(ZNameSchema.safeParse(' ')).toMatchObject({
success: false,
});
});
});
describe('URL validation', () => {
it.each([
'https://example.com',
'http://example.com',
'HTTPS://EXAMPLE.COM',
'Northwind www.example.com',
'www.example.com',
])('rejects URLs in names: %s', (value) => {
expect(ZNameSchema.safeParse(value)).toMatchObject({
success: false,
error: {
issues: expect.arrayContaining([expect.objectContaining({ message: 'Name cannot contain URLs.' })]),
},
});
});
});
describe('invalid character validation', () => {
it.each([
['NUL character', 'Acme\u0000Corp'],
['zero-width space', 'Acme\u200bCorp'],
['bidi override', 'Acme\u202eCorp'],
['byte order mark', 'Acme\ufeffCorp'],
['lone surrogate', 'Acme\ud800Corp'],
['tag character', `Acme${String.fromCodePoint(0xe0041)}Corp`],
['noncharacter', 'Acme\ufffeCorp'],
['private use character', 'Acme\ue000Corp'],
['Hangul filler', 'Acme\u3164Corp'],
['braille blank', 'Acme\u2800Corp'],
['combining grapheme joiner', 'Acme\u034fCorp'],
])('rejects names containing a %s', (_label, value) => {
expect(ZNameSchema.safeParse(value)).toMatchObject({
success: false,
error: {
issues: expect.arrayContaining([expect.objectContaining({ message: 'Name contains invalid characters.' })]),
},
});
});
it.each([
['fixed form', String.raw`Acme\u200bCorp`],
['uppercase U', String.raw`Acme\U200BCorp`],
['braced form', String.raw`Acme\u{200b}Corp`],
['braced form with leading zeros', String.raw`Acme\u{0000200b}Corp`],
['lone surrogate', String.raw`Acme\ud800Corp`],
])('rejects literal \\u escape sequences stored as text (%s)', (_label, value) => {
expect(ZNameSchema.safeParse(value)).toMatchObject({
success: false,
error: {
issues: expect.arrayContaining([expect.objectContaining({ message: 'Name contains invalid characters.' })]),
},
});
});
it.each([
['escape of a valid code point', String.raw`Acme\u0041Corp`],
['braced escape of a valid astral code point', String.raw`Acme\u{1F600}Corp`],
['braced escape beyond the Unicode range', String.raw`Acme\u{FFFFFFF}Corp`],
['incomplete escape sequence', String.raw`Acme\u00 Corp`],
['unterminated braced escape', String.raw`Acme\u{200bCorp`],
['astral characters such as emoji', 'Acme 😀 Corp'],
['emoji with a variation selector', 'I ❤️ Docs'],
])('accepts %s', (_label, value) => {
expect(ZNameSchema.safeParse(value)).toMatchObject({
success: true,
});
});
});
});
+68
View File
@@ -0,0 +1,68 @@
import { z } from 'zod';
export const URL_PATTERN = /https?:\/\/|www\./i;
/**
* Characters that render as empty/invisible or break text layout:
*
* - `\p{C}` - control, format, lone surrogate, private use and
* unassigned code points (NUL, zero-width spaces, bidi
* overrides, BOM, tag characters, noncharacters).
* - `\p{Zl}\p{Zp}` - line and paragraph separators.
* - `\u{034F}` - combining grapheme joiner (invisible). Kept outside the
* character class because it is a combining mark, which
* lint rules reject inside classes.
* - remaining - letters that render as blank (Hangul fillers, braille blank).
*
* The `\p{...}` classes are maintained by the Unicode database, so newly
* assigned characters in these categories are covered automatically.
*/
const INVALID_CHARACTER_REGEX = /[\p{C}\p{Zl}\p{Zp}\u{115F}\u{1160}\u{2800}\u{3164}\u{FFA0}]|\u{034F}/u;
const hasInvalidCharacter = (value: string) => INVALID_CHARACTER_REGEX.test(value);
/**
* Matches literal `\uXXXX` and `\u{XXXX}` escape sequences stored verbatim as
* text (e.g. the 6 characters `\`, `u`, `2`, `0`, `0`, `b`), which can still
* break rendering downstream if anything decodes them.
*/
const ESCAPE_SEQUENCE_PATTERN = /\\u(?:([0-9a-f]{4})|\{([0-9a-f]+)\})/gi;
const hasInvalidEscapeSequence = (value: string) => {
for (const [, fixedHex, bracedHex] of value.matchAll(ESCAPE_SEQUENCE_PATTERN)) {
const codePoint = parseInt(fixedHex ?? bracedHex, 16);
if (codePoint > 0x10ffff) {
continue;
}
// Decode the escape and run it through the same character policy as the
// unescaped check, so the two can never drift apart.
if (hasInvalidCharacter(String.fromCodePoint(codePoint))) {
return true;
}
}
return false;
};
export const hasInvalidTextCharacters = (value: string) =>
hasInvalidCharacter(value) || hasInvalidEscapeSequence(value);
/**
* Shared name schema that disallows URLs to prevent phishing via email rendering,
* and invisible/control characters that render as empty or break the UI.
*/
export const ZNameSchema = z
.string()
.trim()
.min(2, { message: 'Please enter a valid name.' })
.max(100, { message: 'Name cannot be more than 100 characters.' })
.refine((value) => !URL_PATTERN.test(value), {
message: 'Name cannot contain URLs.',
})
.refine((value) => !hasInvalidTextCharacters(value), {
message: 'Name contains invalid characters.',
});
export type TName = z.infer<typeof ZNameSchema>;
+32 -7
View File
@@ -6,14 +6,39 @@ import { z } from 'zod';
* *
* Example: "5m", "1h", "1d" * Example: "5m", "1h", "1d"
*/ */
export const ZRateLimitWindowSchema = z.string().regex(/^\d+[smhd]$/); export const RATE_LIMIT_WINDOW_REGEX = /^\d+[smhd]$/;
export const ZRateLimitArraySchema = z.array( const RATE_LIMIT_WINDOW_ERROR_MESSAGE = 'Use a duration with a unit, e.g. 5m, 1h, or 24h';
z.object({ const RATE_LIMIT_DUPLICATE_WINDOW_ERROR_MESSAGE = 'Use a unique window for each rate limit';
window: ZRateLimitWindowSchema,
max: z.number().int().positive(), export const ZRateLimitWindowSchema = z.string().trim().regex(RATE_LIMIT_WINDOW_REGEX, {
}), message: RATE_LIMIT_WINDOW_ERROR_MESSAGE,
); });
export const ZRateLimitArraySchema = z
.array(
z.object({
window: ZRateLimitWindowSchema,
max: z.number().int().positive(),
}),
)
.superRefine((entries, ctx) => {
const windows = new Set<string>();
entries.forEach((entry, index) => {
const window = entry.window.trim();
if (windows.has(window)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: RATE_LIMIT_DUPLICATE_WINDOW_ERROR_MESSAGE,
path: [index, 'window'],
});
}
windows.add(window);
});
});
export type TRateLimitArray = z.infer<typeof ZRateLimitArraySchema>; export type TRateLimitArray = z.infer<typeof ZRateLimitArraySchema>;
@@ -0,0 +1,99 @@
import { describe, expect, it } from 'vitest';
import {
getQuotaUsagePercent,
getQuotaWarningCount,
isQuotaExceeded,
isQuotaNearing,
normalizeCapacityLimit,
} from './quota-usage';
describe('isQuotaExceeded', () => {
it('treats null quota as unlimited (never exceeded)', () => {
expect(isQuotaExceeded(null, 0)).toBe(false);
expect(isQuotaExceeded(null, 1_000_000)).toBe(false);
});
it('treats a zero quota as blocked (always exceeded)', () => {
expect(isQuotaExceeded(0, 0)).toBe(true);
expect(isQuotaExceeded(0, 5)).toBe(true);
});
it('is exceeded once usage reaches the quota (>= boundary)', () => {
expect(isQuotaExceeded(10, 9)).toBe(false);
expect(isQuotaExceeded(10, 10)).toBe(true);
expect(isQuotaExceeded(10, 11)).toBe(true);
});
});
describe('getQuotaWarningCount', () => {
it('rounds the 80% threshold up', () => {
expect(getQuotaWarningCount(10)).toBe(8);
expect(getQuotaWarningCount(100)).toBe(80);
// 5 * 0.8 = 4 exactly.
expect(getQuotaWarningCount(5)).toBe(4);
// 3 * 0.8 = 2.4 -> 3, so the warning count equals the quota itself.
expect(getQuotaWarningCount(3)).toBe(3);
});
});
describe('isQuotaNearing', () => {
it('is never nearing for unlimited or blocked quotas', () => {
expect(isQuotaNearing(null, 5)).toBe(false);
expect(isQuotaNearing(0, 5)).toBe(false);
});
it('is nearing from the warning threshold up to (but not including) the quota', () => {
expect(isQuotaNearing(10, 7)).toBe(false);
expect(isQuotaNearing(10, 8)).toBe(true);
expect(isQuotaNearing(10, 9)).toBe(true);
});
it('is not nearing once exceeded (nearing and exceeded are mutually exclusive)', () => {
expect(isQuotaNearing(10, 10)).toBe(false);
expect(isQuotaNearing(10, 11)).toBe(false);
});
it('can never fire for tiny quotas where the warning count equals the quota', () => {
// getQuotaWarningCount(3) === 3, so usage >= 3 is already exceeded.
expect(isQuotaNearing(3, 2)).toBe(false);
expect(isQuotaNearing(3, 3)).toBe(false);
});
it('agrees with the warning-count helper at the boundary', () => {
const quota = 250;
const warningCount = getQuotaWarningCount(quota);
expect(isQuotaNearing(quota, warningCount - 1)).toBe(false);
expect(isQuotaNearing(quota, warningCount)).toBe(true);
});
});
describe('getQuotaUsagePercent', () => {
it('returns 0 for unlimited or non-positive quotas', () => {
expect(getQuotaUsagePercent(5, null)).toBe(0);
expect(getQuotaUsagePercent(5, 0)).toBe(0);
expect(getQuotaUsagePercent(5, -10)).toBe(0);
});
it('rounds the percentage to the nearest integer', () => {
expect(getQuotaUsagePercent(1, 3)).toBe(33);
expect(getQuotaUsagePercent(2, 3)).toBe(67);
expect(getQuotaUsagePercent(50, 100)).toBe(50);
});
it('clamps the percentage to 100 when usage exceeds the quota', () => {
expect(getQuotaUsagePercent(150, 100)).toBe(100);
});
});
describe('normalizeCapacityLimit', () => {
it('maps 0 (unlimited for capacity limits) to null', () => {
expect(normalizeCapacityLimit(0)).toBeNull();
});
it('passes positive limits through unchanged', () => {
expect(normalizeCapacityLimit(1)).toBe(1);
expect(normalizeCapacityLimit(25)).toBe(25);
});
});
+57
View File
@@ -0,0 +1,57 @@
export const QUOTA_WARNING_THRESHOLD = 0.8;
/**
* Monthly quotas: `null` = unlimited, `0` = blocked. Usage `>=` quota is exceeded.
*/
export const isQuotaExceeded = (quota: number | null, usage: number): boolean => {
if (quota === null) {
return false;
}
if (quota === 0) {
return true;
}
return usage >= quota;
};
/**
* The usage count at which a positive quota starts "nearing" (80% rounded up).
* The single source for the warning threshold math so the UI panel, quota flags,
* and the per-request alert path can't drift apart.
*/
export const getQuotaWarningCount = (quota: number): number => {
return Math.ceil(quota * QUOTA_WARNING_THRESHOLD);
};
/**
* Nearing once usage reaches the warning threshold (80% rounded up) but is not exceeded.
*/
export const isQuotaNearing = (quota: number | null, usage: number): boolean => {
if (quota === null || quota === 0) {
return false;
}
if (isQuotaExceeded(quota, usage)) {
return false;
}
return usage >= getQuotaWarningCount(quota);
};
export const getQuotaUsagePercent = (usage: number, quota: number | null): number => {
if (quota === null || quota <= 0) {
return 0;
}
return Math.min(100, Math.round((usage / quota) * 100));
};
/** Member/team capacity limits use `0` for unlimited. */
export const normalizeCapacityLimit = (limit: number): number | null => {
if (limit === 0) {
return null;
}
return limit;
};
+1 -71
View File
@@ -1,10 +1,5 @@
import { env } from '@documenso/lib/utils/env'; import type { TUploadPdfResponse } from '@documenso/remix/server/api/files/files.types';
import type { TGetPresignedPostUrlResponse, TUploadPdfResponse } from '@documenso/remix/server/api/files/files.types';
import { DocumentDataType } from '@prisma/client';
import { base64 } from '@scure/base';
import { match } from 'ts-pattern';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
import { AppError } from '../../errors/app-error'; import { AppError } from '../../errors/app-error';
type File = { type File = {
@@ -58,68 +53,3 @@ export const putPdfFile = async (file: File, options?: PutFileOptions) => {
return result; return result;
}; };
/**
* Uploads a file to the appropriate storage location.
*/
export const putFile = async (file: File, options?: PutFileOptions) => {
const NEXT_PUBLIC_UPLOAD_TRANSPORT = env('NEXT_PUBLIC_UPLOAD_TRANSPORT');
return await match(NEXT_PUBLIC_UPLOAD_TRANSPORT)
.with('s3', async () => putFileInObjectStorage(file, {}, options))
.with('azure-blob', async () => putFileInObjectStorage(file, { 'x-ms-blob-type': 'BlockBlob' }, options))
.otherwise(async () => putFileInDatabase(file));
};
const putFileInDatabase = async (file: File) => {
const contents = await file.arrayBuffer();
const binaryData = new Uint8Array(contents);
const asciiData = base64.encode(binaryData);
return {
type: DocumentDataType.BYTES_64,
data: asciiData,
};
};
const putFileInObjectStorage = async (file: File, extraHeaders: Record<string, string>, options?: PutFileOptions) => {
const getPresignedUrlResponse = await fetch(`${NEXT_PUBLIC_WEBAPP_URL()}/api/files/presigned-post-url`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...buildUploadAuthHeaders(options),
},
body: JSON.stringify({
fileName: file.name,
contentType: file.type,
}),
});
if (!getPresignedUrlResponse.ok) {
throw new Error(`Failed to get presigned post url, failed with status code ${getPresignedUrlResponse.status}`);
}
const { url, key }: TGetPresignedPostUrlResponse = await getPresignedUrlResponse.json();
const body = await file.arrayBuffer();
const response = await fetch(url, {
method: 'PUT',
headers: {
'Content-Type': 'application/octet-stream',
...extraHeaders,
},
body,
});
if (!response.ok) {
throw new Error(`Failed to upload file "${file.name}", failed with status code ${response.status}`);
}
return {
type: DocumentDataType.S3_PATH,
data: key,
};
};
+12
View File
@@ -8,3 +8,15 @@ export const loadLogo = async (file: Uint8Array) => {
content, content,
}; };
}; };
/**
* Validate and sanitise an uploaded branding logo. Re-encoding through `sharp`
* proves the bytes are a real raster image and strips any embedded payloads.
* Throws if the input cannot be parsed as an image.
*/
export const optimiseBrandingLogo = async (input: Buffer | Uint8Array): Promise<Buffer> => {
return await sharp(input)
.resize(512, 512, { fit: 'inside', withoutEnlargement: true })
.png({ quality: 80 })
.toBuffer();
};
@@ -1,11 +1,10 @@
import { ZNameSchema } from '@documenso/lib/types/name';
import { z } from 'zod'; import { z } from 'zod';
import { ZOrganisationNameSchema } from '../organisation-router/create-organisation.types';
export const ZCreateAdminOrganisationRequestSchema = z.object({ export const ZCreateAdminOrganisationRequestSchema = z.object({
ownerUserId: z.number(), ownerUserId: z.number(),
data: z.object({ data: z.object({
name: ZOrganisationNameSchema, name: ZNameSchema,
}), }),
}); });
@@ -1,8 +1,9 @@
import { ZNameSchema } from '@documenso/lib/types/name';
import { ZClaimFlagsSchema, ZRateLimitArraySchema } from '@documenso/lib/types/subscription'; import { ZClaimFlagsSchema, ZRateLimitArraySchema } from '@documenso/lib/types/subscription';
import { z } from 'zod'; import { z } from 'zod';
export const ZCreateSubscriptionClaimRequestSchema = z.object({ export const ZCreateSubscriptionClaimRequestSchema = z.object({
name: z.string().min(1), name: ZNameSchema,
teamCount: z.number().int().min(0), teamCount: z.number().int().min(0),
memberCount: z.number().int().min(0), memberCount: z.number().int().min(0),
envelopeItemCount: z.number().int().min(1), envelopeItemCount: z.number().int().min(1),
@@ -1,4 +1,4 @@
import { ZNameSchema } from '@documenso/lib/constants/auth'; import { ZNameSchema } from '@documenso/lib/types/name';
import { z } from 'zod'; import { z } from 'zod';
export const ZCreateUserRequestSchema = z.object({ export const ZCreateUserRequestSchema = z.object({
@@ -1,9 +1,10 @@
import { ZEmailTransportConfigSchema } from '@documenso/lib/server-only/email/email-transport-config'; import { ZEmailTransportConfigSchema } from '@documenso/lib/server-only/email/email-transport-config';
import { ZNameSchema } from '@documenso/lib/types/name';
import { z } from 'zod'; import { z } from 'zod';
export const ZCreateEmailTransportRequestSchema = z.object({ export const ZCreateEmailTransportRequestSchema = z.object({
name: z.string().min(1), name: ZNameSchema,
fromName: z.string().min(1), fromName: ZNameSchema,
fromAddress: z.string().email(), fromAddress: z.string().email(),
config: ZEmailTransportConfigSchema, config: ZEmailTransportConfigSchema,
}); });
@@ -4,6 +4,7 @@ import {
ZSmtpApiConfigSchema, ZSmtpApiConfigSchema,
ZSmtpAuthConfigSchema, ZSmtpAuthConfigSchema,
} from '@documenso/lib/server-only/email/email-transport-config'; } from '@documenso/lib/server-only/email/email-transport-config';
import { ZNameSchema } from '@documenso/lib/types/name';
import { z } from 'zod'; import { z } from 'zod';
// Reuses the canonical transport config schemas, but relaxes the secret field so // Reuses the canonical transport config schemas, but relaxes the secret field so
@@ -21,8 +22,8 @@ const ZUpdateConfigSchema = z.discriminatedUnion('type', [
export const ZUpdateEmailTransportRequestSchema = z.object({ export const ZUpdateEmailTransportRequestSchema = z.object({
id: z.string(), id: z.string(),
data: z.object({ data: z.object({
name: z.string().min(1), name: ZNameSchema,
fromName: z.string().min(1), fromName: ZNameSchema,
fromAddress: z.string().email(), fromAddress: z.string().email(),
config: ZUpdateConfigSchema, config: ZUpdateConfigSchema,
}), }),
@@ -30,6 +30,7 @@ export const getAdminTeamRoute = adminProcedure
name: true, name: true,
url: true, url: true,
ownerUserId: true, ownerUserId: true,
organisationGlobalSettings: true,
}, },
}, },
teamEmail: true, teamEmail: true,
@@ -1,5 +1,6 @@
import { OrganisationMemberRoleSchema } from '@documenso/prisma/generated/zod/inputTypeSchemas/OrganisationMemberRoleSchema'; import { OrganisationMemberRoleSchema } from '@documenso/prisma/generated/zod/inputTypeSchemas/OrganisationMemberRoleSchema';
import { TeamMemberRoleSchema } from '@documenso/prisma/generated/zod/inputTypeSchemas/TeamMemberRoleSchema'; import { TeamMemberRoleSchema } from '@documenso/prisma/generated/zod/inputTypeSchemas/TeamMemberRoleSchema';
import OrganisationGlobalSettingsSchema from '@documenso/prisma/generated/zod/modelSchema/OrganisationGlobalSettingsSchema';
import OrganisationMemberInviteSchema from '@documenso/prisma/generated/zod/modelSchema/OrganisationMemberInviteSchema'; import OrganisationMemberInviteSchema from '@documenso/prisma/generated/zod/modelSchema/OrganisationMemberInviteSchema';
import OrganisationMemberSchema from '@documenso/prisma/generated/zod/modelSchema/OrganisationMemberSchema'; import OrganisationMemberSchema from '@documenso/prisma/generated/zod/modelSchema/OrganisationMemberSchema';
import OrganisationSchema from '@documenso/prisma/generated/zod/modelSchema/OrganisationSchema'; import OrganisationSchema from '@documenso/prisma/generated/zod/modelSchema/OrganisationSchema';
@@ -19,6 +20,8 @@ export const ZGetAdminTeamResponseSchema = TeamSchema.extend({
name: true, name: true,
url: true, url: true,
ownerUserId: true, ownerUserId: true,
}).extend({
organisationGlobalSettings: OrganisationGlobalSettingsSchema,
}), }),
teamEmail: TeamEmailSchema.nullable(), teamEmail: TeamEmailSchema.nullable(),
teamGlobalSettings: TeamGlobalSettingsSchema.nullable(), teamGlobalSettings: TeamGlobalSettingsSchema.nullable(),
@@ -1,13 +1,13 @@
import { ZNameSchema } from '@documenso/lib/types/name';
import { z } from 'zod'; import { z } from 'zod';
import { ZOrganisationNameSchema } from '../organisation-router/create-organisation.types';
import { ZTeamUrlSchema } from '../team-router/schema'; import { ZTeamUrlSchema } from '../team-router/schema';
import { ZCreateSubscriptionClaimRequestSchema } from './create-subscription-claim.types'; import { ZCreateSubscriptionClaimRequestSchema } from './create-subscription-claim.types';
export const ZUpdateAdminOrganisationRequestSchema = z.object({ export const ZUpdateAdminOrganisationRequestSchema = z.object({
organisationId: z.string(), organisationId: z.string(),
data: z.object({ data: z.object({
name: ZOrganisationNameSchema.optional(), name: ZNameSchema.optional(),
url: ZTeamUrlSchema.optional(), url: ZTeamUrlSchema.optional(),
claims: ZCreateSubscriptionClaimRequestSchema.pick({ claims: ZCreateSubscriptionClaimRequestSchema.pick({
teamCount: true, teamCount: true,
@@ -1,10 +1,11 @@
import { ZNameSchema } from '@documenso/lib/types/name';
import { zEmail } from '@documenso/lib/utils/zod'; import { zEmail } from '@documenso/lib/utils/zod';
import { Role } from '@prisma/client'; import { Role } from '@prisma/client';
import { z } from 'zod'; import { z } from 'zod';
export const ZUpdateUserRequestSchema = z.object({ export const ZUpdateUserRequestSchema = z.object({
id: z.number().min(1), id: z.number().min(1),
name: z.string().nullish(), name: ZNameSchema.nullish(),
email: zEmail().optional(), email: zEmail().optional(),
roles: z.array(z.nativeEnum(Role)).optional(), roles: z.array(z.nativeEnum(Role)).optional(),
}); });
@@ -1,8 +1,9 @@
import { ZNameSchema } from '@documenso/lib/types/name';
import { z } from 'zod'; import { z } from 'zod';
export const ZCreateApiTokenRequestSchema = z.object({ export const ZCreateApiTokenRequestSchema = z.object({
teamId: z.number(), teamId: z.number(),
tokenName: z.string().min(3, { message: 'The token name should be 3 characters or longer' }), tokenName: ZNameSchema,
expirationDate: z.string().nullable(), expirationDate: z.string().nullable(),
}); });
@@ -1,8 +1,9 @@
import { ZNameSchema } from '@documenso/lib/types/name';
import { ZRegistrationResponseJSONSchema } from '@documenso/lib/types/webauthn'; import { ZRegistrationResponseJSONSchema } from '@documenso/lib/types/webauthn';
import { z } from 'zod'; import { z } from 'zod';
export const ZCreatePasskeyRequestSchema = z.object({ export const ZCreatePasskeyRequestSchema = z.object({
passkeyName: z.string().trim().min(1), passkeyName: ZNameSchema,
verificationResponse: ZRegistrationResponseJSONSchema, verificationResponse: ZRegistrationResponseJSONSchema,
}); });
@@ -1,8 +1,9 @@
import { ZNameSchema } from '@documenso/lib/types/name';
import { z } from 'zod'; import { z } from 'zod';
export const ZUpdatePasskeyRequestSchema = z.object({ export const ZUpdatePasskeyRequestSchema = z.object({
passkeyId: z.string().trim().min(1), passkeyId: z.string().trim().min(1),
name: z.string().trim().min(1), name: ZNameSchema,
}); });
export const ZUpdatePasskeyResponseSchema = z.void(); export const ZUpdatePasskeyResponseSchema = z.void();
@@ -1,9 +1,10 @@
import { ZNameSchema } from '@documenso/lib/types/name';
import { zEmail } from '@documenso/lib/utils/zod'; import { zEmail } from '@documenso/lib/utils/zod';
import { z } from 'zod'; import { z } from 'zod';
export const ZCreateOrganisationEmailRequestSchema = z.object({ export const ZCreateOrganisationEmailRequestSchema = z.object({
emailDomainId: z.string(), emailDomainId: z.string(),
emailName: z.string().min(1).max(100), emailName: ZNameSchema,
email: zEmail().toLowerCase(), email: zEmail().toLowerCase(),
// This does not need to be validated to be part of the domain. // This does not need to be validated to be part of the domain.
+3 -2
View File
@@ -1,4 +1,5 @@
import { ZFolderTypeSchema } from '@documenso/lib/types/folder-type'; import { ZFolderTypeSchema } from '@documenso/lib/types/folder-type';
import { ZNameSchema } from '@documenso/lib/types/name';
import { ZFindResultResponse, ZFindSearchParamsSchema } from '@documenso/lib/types/search-params'; import { ZFindResultResponse, ZFindSearchParamsSchema } from '@documenso/lib/types/search-params';
import { DocumentVisibility } from '@documenso/prisma/generated/types'; import { DocumentVisibility } from '@documenso/prisma/generated/types';
import FolderSchema from '@documenso/prisma/generated/zod/modelSchema/FolderSchema'; import FolderSchema from '@documenso/prisma/generated/zod/modelSchema/FolderSchema';
@@ -42,7 +43,7 @@ const ZFolderParentIdSchema = z
.describe('The folder ID to place this folder within. Leave empty to place folder at the root level.'); .describe('The folder ID to place this folder within. Leave empty to place folder at the root level.');
export const ZCreateFolderRequestSchema = z.object({ export const ZCreateFolderRequestSchema = z.object({
name: z.string(), name: ZNameSchema,
parentId: ZFolderParentIdSchema.optional(), parentId: ZFolderParentIdSchema.optional(),
type: ZFolderTypeSchema.optional(), type: ZFolderTypeSchema.optional(),
}); });
@@ -52,7 +53,7 @@ export const ZCreateFolderResponseSchema = ZFolderSchema;
export const ZUpdateFolderRequestSchema = z.object({ export const ZUpdateFolderRequestSchema = z.object({
folderId: z.string().describe('The ID of the folder to update'), folderId: z.string().describe('The ID of the folder to update'),
data: z.object({ data: z.object({
name: z.string().optional().describe('The name of the folder'), name: ZNameSchema.optional().describe('The name of the folder'),
parentId: ZFolderParentIdSchema.optional().nullable(), parentId: ZFolderParentIdSchema.optional().nullable(),
visibility: z.nativeEnum(DocumentVisibility).optional().describe('The visibility of the folder'), visibility: z.nativeEnum(DocumentVisibility).optional().describe('The visibility of the folder'),
pinned: z.boolean().optional().describe('Whether the folder should be pinned'), pinned: z.boolean().optional().describe('Whether the folder should be pinned'),
@@ -1,3 +1,4 @@
import { ZNameSchema } from '@documenso/lib/types/name';
import { OrganisationMemberRole } from '@prisma/client'; import { OrganisationMemberRole } from '@prisma/client';
import { z } from 'zod'; import { z } from 'zod';
@@ -14,7 +15,7 @@ import { z } from 'zod';
export const ZCreateOrganisationGroupRequestSchema = z.object({ export const ZCreateOrganisationGroupRequestSchema = z.object({
organisationId: z.string(), organisationId: z.string(),
organisationRole: z.nativeEnum(OrganisationMemberRole), organisationRole: z.nativeEnum(OrganisationMemberRole),
name: z.string().max(100), name: ZNameSchema,
memberIds: z.array(z.string()), memberIds: z.array(z.string()),
}); });
@@ -1,3 +1,4 @@
import { ZNameSchema } from '@documenso/lib/types/name';
import { z } from 'zod'; import { z } from 'zod';
// export const createOrganisationMeta: TrpcOpenApiMeta = { // export const createOrganisationMeta: TrpcOpenApiMeta = {
@@ -10,13 +11,8 @@ import { z } from 'zod';
// }, // },
// }; // };
export const ZOrganisationNameSchema = z
.string()
.min(3, { message: 'Minimum 3 characters' })
.max(50, { message: 'Maximum 50 characters' });
export const ZCreateOrganisationRequestSchema = z.object({ export const ZCreateOrganisationRequestSchema = z.object({
name: ZOrganisationNameSchema, name: ZNameSchema,
priceId: z.string().optional(), priceId: z.string().optional(),
}); });
@@ -20,6 +20,7 @@ import { getOrganisationsRoute } from './get-organisations';
import { leaveOrganisationRoute } from './leave-organisation'; import { leaveOrganisationRoute } from './leave-organisation';
import { resendOrganisationMemberInviteRoute } from './resend-organisation-member-invite'; import { resendOrganisationMemberInviteRoute } from './resend-organisation-member-invite';
import { updateOrganisationRoute } from './update-organisation'; import { updateOrganisationRoute } from './update-organisation';
import { updateOrganisationBrandingLogoRoute } from './update-organisation-branding-logo';
import { updateOrganisationGroupRoute } from './update-organisation-group'; import { updateOrganisationGroupRoute } from './update-organisation-group';
import { updateOrganisationMemberRoute } from './update-organisation-members'; import { updateOrganisationMemberRoute } from './update-organisation-members';
import { updateOrganisationSettingsRoute } from './update-organisation-settings'; import { updateOrganisationSettingsRoute } from './update-organisation-settings';
@@ -55,6 +56,7 @@ export const organisationRouter = router({
}, },
settings: { settings: {
update: updateOrganisationSettingsRoute, update: updateOrganisationSettingsRoute,
updateBrandingLogo: updateOrganisationBrandingLogoRoute,
}, },
internal: { internal: {
getOrganisationSession: getOrganisationSessionRoute, getOrganisationSession: getOrganisationSessionRoute,
@@ -0,0 +1,69 @@
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
import { ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/organisations';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { buildBrandingLogoData } from '@documenso/lib/server-only/branding/store-branding-logo';
import { getOrganisationClaim } from '@documenso/lib/server-only/organisation/get-organisation-claims';
import { buildOrganisationWhereQuery } from '@documenso/lib/utils/organisations';
import { prisma } from '@documenso/prisma';
import { authenticatedProcedure } from '../trpc';
import {
ZUpdateOrganisationBrandingLogoRequestSchema,
ZUpdateOrganisationBrandingLogoResponseSchema,
} from './update-organisation-branding-logo.types';
export const updateOrganisationBrandingLogoRoute = authenticatedProcedure
.input(ZUpdateOrganisationBrandingLogoRequestSchema)
.output(ZUpdateOrganisationBrandingLogoResponseSchema)
.mutation(async ({ ctx, input }) => {
const { user } = ctx;
const { payload, brandingLogo } = input;
const { organisationId } = payload;
ctx.logger.info({
input: {
organisationId,
},
});
const organisation = await prisma.organisation.findFirst({
where: buildOrganisationWhereQuery({
organisationId,
userId: user.id,
roles: ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_ORGANISATION'],
}),
});
if (!organisation) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'You do not have permission to update this organisation.',
});
}
// Setting a logo requires the custom-branding entitlement; clearing it is
// always allowed so a downgraded organisation can still remove its logo.
if (brandingLogo && IS_BILLING_ENABLED()) {
const claim = await getOrganisationClaim({ organisationId });
if (claim.flags?.allowCustomBranding !== true) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'Your plan does not allow custom branding.',
});
}
}
const brandingLogoValue = brandingLogo ? await buildBrandingLogoData(brandingLogo) : '';
await prisma.organisation.update({
where: {
id: organisation.id,
},
data: {
organisationGlobalSettings: {
update: {
brandingLogo: brandingLogoValue,
},
},
},
});
});
@@ -0,0 +1,17 @@
import { z } from 'zod';
import { zfd } from 'zod-form-data';
import { zfdBrandingImageFile, zodFormData } from '../../utils/zod-form-data';
export const ZUpdateOrganisationBrandingLogoRequestSchema = zodFormData({
payload: zfd.json(
z.object({
organisationId: z.string(),
}),
),
brandingLogo: zfdBrandingImageFile().optional(),
});
export const ZUpdateOrganisationBrandingLogoResponseSchema = z.void();
export type TUpdateOrganisationBrandingLogoRequest = z.infer<typeof ZUpdateOrganisationBrandingLogoRequestSchema>;
@@ -1,3 +1,4 @@
import { ZNameSchema } from '@documenso/lib/types/name';
import { OrganisationMemberRole } from '@prisma/client'; import { OrganisationMemberRole } from '@prisma/client';
import { z } from 'zod'; import { z } from 'zod';
@@ -14,7 +15,7 @@ import { z } from 'zod';
export const ZUpdateOrganisationGroupRequestSchema = z.object({ export const ZUpdateOrganisationGroupRequestSchema = z.object({
id: z.string(), id: z.string(),
name: z.string().nullable().optional(), name: ZNameSchema.nullable().optional(),
organisationRole: z.nativeEnum(OrganisationMemberRole).optional(), organisationRole: z.nativeEnum(OrganisationMemberRole).optional(),
memberIds: z.array(z.string()).optional(), memberIds: z.array(z.string()).optional(),
}); });
@@ -44,7 +44,6 @@ export const updateOrganisationSettingsRoute = authenticatedProcedure
// Branding related settings. // Branding related settings.
brandingEnabled, brandingEnabled,
brandingLogo,
brandingUrl, brandingUrl,
brandingCompanyDetails, brandingCompanyDetails,
brandingColors, brandingColors,
@@ -174,7 +173,6 @@ export const updateOrganisationSettingsRoute = authenticatedProcedure
// Branding related settings. // Branding related settings.
brandingEnabled, brandingEnabled,
brandingLogo,
brandingUrl, brandingUrl,
brandingCompanyDetails, brandingCompanyDetails,
brandingColors: normalizedBrandingColors === null ? Prisma.DbNull : normalizedBrandingColors, brandingColors: normalizedBrandingColors === null ? Prisma.DbNull : normalizedBrandingColors,
@@ -32,7 +32,6 @@ export const ZUpdateOrganisationSettingsRequestSchema = z.object({
// Branding related settings. // Branding related settings.
brandingEnabled: z.boolean().optional(), brandingEnabled: z.boolean().optional(),
brandingLogo: z.string().optional(),
brandingUrl: z.string().optional(), brandingUrl: z.string().optional(),
brandingCompanyDetails: z.string().optional(), brandingCompanyDetails: z.string().optional(),
brandingColors: ZCssVarsSchema.nullish(), brandingColors: ZCssVarsSchema.nullish(),
@@ -1,4 +1,4 @@
import { ZNameSchema } from '@documenso/lib/constants/auth'; import { ZNameSchema } from '@documenso/lib/types/name';
import { z } from 'zod'; import { z } from 'zod';
export const ZFindUserSecurityAuditLogsSchema = z.object({ export const ZFindUserSecurityAuditLogsSchema = z.object({
@@ -1,5 +1,6 @@
import { ZNameSchema } from '@documenso/lib/types/name';
import { z } from 'zod'; import { z } from 'zod';
import { ZTeamNameSchema, ZTeamUrlSchema } from './schema'; import { ZTeamUrlSchema } from './schema';
// export const createTeamMeta: TrpcOpenApiMeta = { // export const createTeamMeta: TrpcOpenApiMeta = {
// openapi: { // openapi: {
@@ -13,7 +14,7 @@ import { ZTeamNameSchema, ZTeamUrlSchema } from './schema';
export const ZCreateTeamRequestSchema = z.object({ export const ZCreateTeamRequestSchema = z.object({
organisationId: z.string(), organisationId: z.string(),
teamName: ZTeamNameSchema, teamName: ZNameSchema,
teamUrl: ZTeamUrlSchema, teamUrl: ZTeamUrlSchema,
inheritMembers: z inheritMembers: z
.boolean() .boolean()
@@ -25,6 +25,7 @@ import {
ZUpdateTeamEmailMutationSchema, ZUpdateTeamEmailMutationSchema,
} from './schema'; } from './schema';
import { updateTeamRoute } from './update-team'; import { updateTeamRoute } from './update-team';
import { updateTeamBrandingLogoRoute } from './update-team-branding-logo';
import { updateTeamGroupRoute } from './update-team-group'; import { updateTeamGroupRoute } from './update-team-group';
import { updateTeamMemberRoute } from './update-team-member'; import { updateTeamMemberRoute } from './update-team-member';
import { updateTeamSettingsRoute } from './update-team-settings'; import { updateTeamSettingsRoute } from './update-team-settings';
@@ -50,6 +51,7 @@ export const teamRouter = router({
}, },
settings: { settings: {
update: updateTeamSettingsRoute, update: updateTeamSettingsRoute,
updateBrandingLogo: updateTeamBrandingLogoRoute,
}, },
// Old routes (to be migrated) // Old routes (to be migrated)
+1 -10
View File
@@ -1,5 +1,5 @@
import { URL_PATTERN, ZNameSchema } from '@documenso/lib/constants/auth';
import { PROTECTED_TEAM_URLS } from '@documenso/lib/constants/teams'; import { PROTECTED_TEAM_URLS } from '@documenso/lib/constants/teams';
import { ZNameSchema } from '@documenso/lib/types/name';
import { zEmail } from '@documenso/lib/utils/zod'; import { zEmail } from '@documenso/lib/utils/zod';
import { TeamMemberRole } from '@prisma/client'; import { TeamMemberRole } from '@prisma/client';
import { z } from 'zod'; import { z } from 'zod';
@@ -32,15 +32,6 @@ export const ZTeamUrlSchema = z
message: 'This URL is already in use.', message: 'This URL is already in use.',
}); });
export const ZTeamNameSchema = z
.string()
.trim()
.min(3, { message: 'Team name must be at least 3 characters long.' })
.max(30, { message: 'Team name must not exceed 30 characters.' })
.refine((value) => !URL_PATTERN.test(value), {
message: 'Team name cannot contain URLs.',
});
export const ZCreateTeamEmailVerificationMutationSchema = z.object({ export const ZCreateTeamEmailVerificationMutationSchema = z.object({
teamId: z.number(), teamId: z.number(),
name: ZNameSchema, name: ZNameSchema,
@@ -0,0 +1,69 @@
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
import { TEAM_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/teams';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { buildBrandingLogoData } from '@documenso/lib/server-only/branding/store-branding-logo';
import { getOrganisationClaimByTeamId } from '@documenso/lib/server-only/organisation/get-organisation-claims';
import { buildTeamWhereQuery } from '@documenso/lib/utils/teams';
import { prisma } from '@documenso/prisma';
import { authenticatedProcedure } from '../trpc';
import {
ZUpdateTeamBrandingLogoRequestSchema,
ZUpdateTeamBrandingLogoResponseSchema,
} from './update-team-branding-logo.types';
export const updateTeamBrandingLogoRoute = authenticatedProcedure
.input(ZUpdateTeamBrandingLogoRequestSchema)
.output(ZUpdateTeamBrandingLogoResponseSchema)
.mutation(async ({ ctx, input }) => {
const { user } = ctx;
const { payload, brandingLogo } = input;
const { teamId } = payload;
ctx.logger.info({
input: {
teamId,
},
});
const team = await prisma.team.findFirst({
where: buildTeamWhereQuery({
teamId,
userId: user.id,
roles: TEAM_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_TEAM'],
}),
});
if (!team) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'You do not have permission to update this team.',
});
}
// Setting a logo requires the custom-branding entitlement; clearing it is
// always allowed so a downgraded team can still remove its logo.
if (brandingLogo && IS_BILLING_ENABLED()) {
const claim = await getOrganisationClaimByTeamId({ teamId });
if (claim.flags?.allowCustomBranding !== true) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'Your plan does not allow custom branding.',
});
}
}
const brandingLogoValue = brandingLogo ? await buildBrandingLogoData(brandingLogo) : '';
await prisma.team.update({
where: {
id: team.id,
},
data: {
teamGlobalSettings: {
update: {
brandingLogo: brandingLogoValue,
},
},
},
});
});
@@ -0,0 +1,17 @@
import { z } from 'zod';
import { zfd } from 'zod-form-data';
import { zfdBrandingImageFile, zodFormData } from '../../utils/zod-form-data';
export const ZUpdateTeamBrandingLogoRequestSchema = zodFormData({
payload: zfd.json(
z.object({
teamId: z.number(),
}),
),
brandingLogo: zfdBrandingImageFile().optional(),
});
export const ZUpdateTeamBrandingLogoResponseSchema = z.void();
export type TUpdateTeamBrandingLogoRequest = z.infer<typeof ZUpdateTeamBrandingLogoRequestSchema>;
@@ -42,7 +42,6 @@ export const updateTeamSettingsRoute = authenticatedProcedure
// Branding related settings. // Branding related settings.
brandingEnabled, brandingEnabled,
brandingLogo,
brandingUrl, brandingUrl,
brandingCompanyDetails, brandingCompanyDetails,
brandingColors, brandingColors,
@@ -176,7 +175,6 @@ export const updateTeamSettingsRoute = authenticatedProcedure
// Branding related settings. // Branding related settings.
brandingEnabled, brandingEnabled,
brandingLogo,
brandingUrl, brandingUrl,
brandingCompanyDetails, brandingCompanyDetails,
brandingColors: normalizedBrandingColors === null ? Prisma.DbNull : normalizedBrandingColors, brandingColors: normalizedBrandingColors === null ? Prisma.DbNull : normalizedBrandingColors,
@@ -35,7 +35,6 @@ export const ZUpdateTeamSettingsRequestSchema = z.object({
// Branding related settings. // Branding related settings.
brandingEnabled: z.boolean().nullish(), brandingEnabled: z.boolean().nullish(),
brandingLogo: z.string().nullish(),
brandingUrl: z.string().nullish(), brandingUrl: z.string().nullish(),
brandingCompanyDetails: z.string().nullish(), brandingCompanyDetails: z.string().nullish(),
brandingColors: ZCssVarsSchema.nullish(), brandingColors: ZCssVarsSchema.nullish(),
@@ -1,6 +1,7 @@
import { ZNameSchema } from '@documenso/lib/types/name';
import { z } from 'zod'; import { z } from 'zod';
import { ZTeamNameSchema, ZTeamUrlSchema } from './schema'; import { ZTeamUrlSchema } from './schema';
export const MAX_PROFILE_BIO_LENGTH = 256; export const MAX_PROFILE_BIO_LENGTH = 256;
@@ -19,7 +20,7 @@ export const MAX_PROFILE_BIO_LENGTH = 256;
export const ZUpdateTeamRequestSchema = z.object({ export const ZUpdateTeamRequestSchema = z.object({
teamId: z.number(), teamId: z.number(),
data: z.object({ data: z.object({
name: ZTeamNameSchema.optional(), name: ZNameSchema.optional(),
url: ZTeamUrlSchema.optional(), url: ZTeamUrlSchema.optional(),
profileBio: z profileBio: z
.string() .string()
@@ -1,4 +1,5 @@
import { isPrivateUrl } from '@documenso/lib/server-only/webhooks/is-private-url'; import { isPrivateUrl } from '@documenso/lib/server-only/webhooks/is-private-url';
import { URL_PATTERN } from '@documenso/lib/types/name';
import { WebhookTriggerEvents } from '@prisma/client'; import { WebhookTriggerEvents } from '@prisma/client';
import { z } from 'zod'; import { z } from 'zod';
@@ -7,6 +8,13 @@ export const ZWebhookUrlSchema = z
.url() .url()
.refine((url) => !isPrivateUrl(url), { .refine((url) => !isPrivateUrl(url), {
message: 'Webhook URL cannot point to a private or loopback address', message: 'Webhook URL cannot point to a private or loopback address',
})
/*
* Without this, values like "foo: bar" would be valid URLs.
* Keep the same error message as the zod url() validator.
*/
.refine((value) => URL_PATTERN.test(value), {
message: 'Invalid url',
}); });
export const ZCreateWebhookRequestSchema = z.object({ export const ZCreateWebhookRequestSchema = z.object({
+20
View File
@@ -1,4 +1,9 @@
import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app'; import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app';
import {
BRANDING_LOGO_ALLOWED_TYPES,
BRANDING_LOGO_MAX_SIZE_BYTES,
BRANDING_LOGO_MAX_SIZE_MB,
} from '@documenso/lib/constants/branding';
import { megabytesToBytes } from '@documenso/lib/universal/unit-convertions'; import { megabytesToBytes } from '@documenso/lib/universal/unit-convertions';
import type { ZodRawShape } from 'zod'; import type { ZodRawShape } from 'zod';
import z from 'zod'; import z from 'zod';
@@ -17,6 +22,21 @@ export const zfdFile = () => {
}); });
}; };
/**
* A `zfd.file()` schema constrained to branding-logo images: size-limited and
* restricted to a MIME allowlist. Use for server-side branding logo uploads.
*/
export const zfdBrandingImageFile = () => {
return zfd
.file()
.refine((file) => file.size <= BRANDING_LOGO_MAX_SIZE_BYTES, {
message: `File cannot be larger than ${BRANDING_LOGO_MAX_SIZE_MB}MB`,
})
.refine((file) => BRANDING_LOGO_ALLOWED_TYPES.includes(file.type), {
message: 'File must be a JPG, PNG, or WebP image',
});
};
/** /**
* This helper takes the place of the `z.object` at the root of your schema. * This helper takes the place of the `z.object` at the root of your schema.
* It wraps your schema in a `z.preprocess` that extracts all the data out of a `FormData` * It wraps your schema in a `z.preprocess` that extracts all the data out of a `FormData`