Compare commits

...

20 Commits

Author SHA1 Message Date
Lucas Smith c6b797a6ce fix: increase global API rate limits to 1000/min 2026-07-08 17:07:31 +10: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 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
Lucas Smith 337f85f021 chore: upgrade libpdf (#3058) 2026-07-02 15:09:07 +10:00
github-actions[bot] 2332b0316b chore: extract translations (#3013) 2026-07-02 14:58:56 +10:00
David Nguyen 393b51d484 fix: add sticky form update button (#3056) 2026-07-02 14:52:28 +10:00
Arun Kumar 5a8335e0eb fix: webhook payload contains stale deletedAt on document cancellation (#2980) 2026-07-01 17:19:21 +10:00
Kendry Grullon 562d78e2d7 feat: add granular signin disable flags and OIDC auto-redirect (#2857) 2026-06-30 16:08:09 +10:00
Grégory Chevalier 3b110cf70d fix: french translation for confirmation message (#3050) 2026-06-30 15:52:59 +10:00
David Nguyen 7062fadf0b fix: add additional team group permission checks (#3052) 2026-06-30 15:45:48 +10:00
Martin Glaser 9cdd2e7ff9 fix(email): render Preview inside Body across all email templates (#3004) 2026-06-29 16:07:43 +10:00
David Nguyen a70b0702c3 fix: add missing teams branding guard (#3049) 2026-06-29 14:50:35 +10:00
David Nguyen 1f170ef5e5 fix: scope organisation group deletion (#3047) 2026-06-29 14:11:31 +10:00
Lucas Smith 8f68393241 fix: tighten permission and validation checks (#3046) 2026-06-29 13:15:13 +10:00
Lucas Smith 381293af0c fix: invite email placeholder (#3045)
- **fix: interpolate inviterEmail in invite email mailto link**
- **fix: add alt attributes to email template images**
2026-06-28 22:01:20 +10:00
David Nguyen 97835b8dbb feat: add field multiselect (#3031) 2026-06-28 15:08:11 +10:00
David Nguyen 977d07330b fix: auto select field on drop (#3028) 2026-06-28 15:07:33 +10:00
204 changed files with 6430 additions and 2583 deletions
+14
View File
@@ -180,6 +180,20 @@ NEXT_PUBLIC_DISABLE_MICROSOFT_SIGNUP=
NEXT_PUBLIC_DISABLE_OIDC_SIGNUP=
# OPTIONAL: Comma-separated list of email domains allowed to sign up (e.g., example.com,acme.org).
NEXT_PRIVATE_ALLOWED_SIGNUP_DOMAINS=
# OPTIONAL: Set to "true" to disable all signin methods (email, Google, Microsoft, OIDC).
NEXT_PUBLIC_DISABLE_SIGNIN=
# OPTIONAL: Set to "true" to disable email/password signin only. Also closes /forgot-password and /reset-password.
NEXT_PUBLIC_DISABLE_EMAIL_PASSWORD_SIGNIN=
# OPTIONAL: Set to "true" to hide the Google signin button.
NEXT_PUBLIC_DISABLE_GOOGLE_SIGNIN=
# OPTIONAL: Set to "true" to hide the Microsoft signin button.
NEXT_PUBLIC_DISABLE_MICROSOFT_SIGNIN=
# OPTIONAL: Set to "true" to hide the OIDC signin button.
NEXT_PUBLIC_DISABLE_OIDC_SIGNIN=
# OPTIONAL: When OIDC is the only enabled signin transport, /signin auto-redirects
# to the OIDC provider (rendering only a spinner). Set to "true" to disable this
# and keep showing the signin page.
NEXT_PUBLIC_DISABLE_OIDC_AUTO_REDIRECT=
# OPTIONAL: Set to true to use internal webapp url in browserless requests.
NEXT_PUBLIC_USE_INTERNAL_URL_BROWSERLESS=false
@@ -11,9 +11,14 @@ Documenso enforces rate limits on all API endpoints to ensure service stability.
## HTTP Rate Limits
**Limit:** 100 requests per minute per IP address
**Limit:** 1000 requests per minute per IP address
**Response:** 429 Too Many Requests
<Callout type="info">
This is the global per-IP ceiling. Your organisation may have its own rate limits configured below
this value, in which case you can be rate-limited before reaching the global limit.
</Callout>
### Rate Limit Response
```json
@@ -472,7 +472,7 @@ Send the same document to multiple recipients in parallel. Useful for policy ack
<code>distributeDocument: true</code>
</Step>
<Step>
Process in batches with a short delay to respect rate limits (e.g. 100 requests/minute)
Process in batches with a short delay to respect rate limits (e.g. 1000 requests/minute)
</Step>
</Steps>
@@ -638,8 +638,8 @@ done
</Tabs>
<Callout type="info">
The API allows 100 requests per minute. For large batches, implement rate limiting with delays
between requests to avoid hitting limits.
The API allows 1000 requests per minute (your organisation may have its own lower limit). For large
batches, implement rate limiting with delays between requests to avoid hitting limits.
</Callout>
---
@@ -483,7 +483,7 @@ The API returns standard HTTP status codes and JSON error responses:
### Handling Rate Limits
The API allows 100 requests per minute per IP address. When rate limited, wait at least 60 seconds before retrying:
The API allows 1000 requests per minute per IP address. Your organisation may have its own lower rate limits. When rate limited, wait at least 60 seconds before retrying:
```javascript
async function fetchWithRetry(url, options, maxRetries = 3) {
@@ -76,6 +76,8 @@ The Enterprise Edition is required when you:
4. Restart your Documenso instance
5. Verify the license is active in the **Admin Panel** under the **Stats** section
See [Apply Your License Key](/docs/self-hosting/configuration/license) for the full walkthrough, including how to enable individual features once licensed.
</Accordion>
</Accordions>
@@ -197,7 +199,7 @@ See [Support](/docs/policies/support) for complete support options.
1. Sign the Enterprise license agreement
2. Receive license key and access credentials
3. Deploy using [self-hosting guides](/docs/self-hosting) or access Documenso Cloud
4. Configure Enterprise features with support assistance
4. Apply the key — see [Apply Your License Key](/docs/self-hosting/configuration/license) — and configure Enterprise features with support assistance
</Step>
<Step>
@@ -238,6 +240,7 @@ See [Support](/docs/policies/support) for complete support options.
## Related
- [Apply Your License Key](/docs/self-hosting/configuration/license) - Step-by-step license activation
- [Community Edition](/docs/policies/community-edition) - AGPL-3.0 open-source license
- [Licenses](/docs/policies/licenses) - Complete licensing overview and FAQ
- [Support](/docs/policies/support) - Support channels and response times
+6 -1
View File
@@ -41,12 +41,17 @@ When a limit is reached, requests return a `429 Too Many Requests` response with
| Action | Limit | Window |
| --- | --- | --- |
| API requests (v1 and v2) | 100 requests | 1 minute |
| API requests (v1 and v2) | 1000 requests | 1 minute |
| File uploads | 20 requests | 1 minute |
| AI features | 3 requests | 1 minute |
Authentication endpoints (login, signup, password reset, etc.) are also rate-limited to protect against abuse.
<Callout type="info">
The API request limit above is the global per-IP ceiling. Individual organisations also have their
own rate limits, which may be configured below this value.
</Callout>
<Callout type="info">
Rate limits may vary by plan. Enterprise plans can include higher or custom limits. Contact
[sales](https://documen.so/sales) for details.
@@ -272,6 +272,12 @@ For detailed certificate setup, see [Signing Certificate](/docs/self-hosting/con
| `NEXT_PUBLIC_DISABLE_MICROSOFT_SIGNUP` | Block new accounts via Microsoft. Existing linked users can still sign in | `false` |
| `NEXT_PUBLIC_DISABLE_OIDC_SIGNUP` | Block new accounts via OIDC, including the organisation portal | `false` |
| `NEXT_PRIVATE_ALLOWED_SIGNUP_DOMAINS` | Comma-separated list of email domains allowed to sign up (e.g., `example.com,acme.org`) | |
| `NEXT_PUBLIC_DISABLE_SIGNIN` | Master switch. Disable all signin methods application-wide | `false` |
| `NEXT_PUBLIC_DISABLE_EMAIL_PASSWORD_SIGNIN` | Disable email/password signin. Also closes `/forgot-password` and `/reset-password` | `false` |
| `NEXT_PUBLIC_DISABLE_GOOGLE_SIGNIN` | Hide the Google signin button | `false` |
| `NEXT_PUBLIC_DISABLE_MICROSOFT_SIGNIN` | Hide the Microsoft signin button | `false` |
| `NEXT_PUBLIC_DISABLE_OIDC_SIGNIN` | Hide the OIDC signin button | `false` |
| `NEXT_PUBLIC_DISABLE_OIDC_AUTO_REDIRECT` | Disable the automatic `/signin` redirect when OIDC is the only enabled transport | `false` |
| `NEXT_PUBLIC_POSTHOG_KEY` | PostHog API key for analytics and feature flags | |
| `NEXT_PUBLIC_FEATURE_BILLING_ENABLED` | Enable billing features | `false` |
@@ -303,6 +309,44 @@ NEXT_PUBLIC_DISABLE_MICROSOFT_SIGNUP="true"
NEXT_PUBLIC_DISABLE_SIGNUP="true"
```
### Sign-in Restrictions
You can control which methods are available for users to sign in with the following environment variables:
- **`NEXT_PUBLIC_DISABLE_SIGNIN`** (master switch): Set to `true` to block all signin methods (email/password, Google, Microsoft, OIDC). Hides every signin entry point on `/signin` and rejects email/password signin server-side with a `SIGNIN_DISABLED` error.
- **`NEXT_PUBLIC_DISABLE_EMAIL_PASSWORD_SIGNIN`**: Set to `true` to disable email/password signin only. The email/password form is hidden, the `/forgot-password` and `/reset-password` pages redirect to `/signin`, and the corresponding server endpoints reject requests. SSO signin is unaffected.
- **`NEXT_PUBLIC_DISABLE_GOOGLE_SIGNIN`**, **`NEXT_PUBLIC_DISABLE_MICROSOFT_SIGNIN`**, **`NEXT_PUBLIC_DISABLE_OIDC_SIGNIN`**: Set to `true` to hide the matching SSO button on the signin page. Useful when an SSO provider is kept configured for account linking but not advertised as a signin entry point.
These flags are opt-in: when none are set, signin behaviour is unchanged from a stock Documenso instance.
```bash
# Allow only OIDC signin (e.g. enterprise SSO-only)
NEXT_PUBLIC_DISABLE_EMAIL_PASSWORD_SIGNIN="true"
NEXT_PUBLIC_DISABLE_GOOGLE_SIGNIN="true"
NEXT_PUBLIC_DISABLE_MICROSOFT_SIGNIN="true"
# Or disable signin entirely
NEXT_PUBLIC_DISABLE_SIGNIN="true"
```
### OIDC Auto-redirect
When OIDC is the only enabled signin transport on your instance, `/signin` automatically redirects users straight to the OIDC provider instead of showing the signin form. The page renders a spinner while the redirect happens. No extra configuration is required — disabling every other signin method is enough to trigger it.
- **`NEXT_PUBLIC_DISABLE_OIDC_AUTO_REDIRECT`**: Set to `true` to opt out of the automatic redirect and keep rendering the signin page even when OIDC is the only enabled transport.
The redirect only triggers when OIDC is configured and email/password, Google, and Microsoft signin are all disabled. If any other transport remains enabled, the signin form is shown as normal.
```bash
# OIDC-only signin: disabling all other methods auto-redirects to the provider
NEXT_PUBLIC_DISABLE_EMAIL_PASSWORD_SIGNIN="true"
NEXT_PUBLIC_DISABLE_GOOGLE_SIGNIN="true"
NEXT_PUBLIC_DISABLE_MICROSOFT_SIGNIN="true"
# Opt out of the auto-redirect while still OIDC-only
# NEXT_PUBLIC_DISABLE_OIDC_AUTO_REDIRECT="true"
```
---
## AI Features
@@ -399,11 +443,11 @@ Telemetry collects only: app version, installation ID, and node ID. No personal
## Enterprise Features
These variables require an active [Enterprise Edition](/docs/policies/enterprise-edition) license. Obtain a license key from [license.documenso.com](https://license.documenso.com) and set it below to unlock enterprise features such as SSO, embed editor, and 21 CFR Part 11 compliance.
These variables require an active [Enterprise Edition](/docs/policies/enterprise-edition) license. Obtain a license key from [license.documenso.com](https://license.documenso.com) and set it below to unlock enterprise features such as SSO, embed editor, and 21 CFR Part 11 compliance. See [Apply Your License Key](/docs/self-hosting/configuration/license) for step-by-step setup.
| Variable | Description |
| ------------------------------------ | ------------------------------------------------ |
| `NEXT_PRIVATE_DOCUMENSO_LICENSE_KEY` | License key for enterprise features |
| `NEXT_PRIVATE_DOCUMENSO_LICENSE_KEY` | License key for enterprise features — see [Apply Your License Key](/docs/self-hosting/configuration/license) for how to apply it |
| `NEXT_PRIVATE_STRIPE_API_KEY` | Stripe API key for billing |
| `NEXT_PRIVATE_STRIPE_WEBHOOK_SECRET` | Stripe webhook secret |
| `NEXT_PRIVATE_SES_ACCESS_KEY_ID` | AWS SES access key for email domain verification |
@@ -446,6 +490,16 @@ NEXT_PRIVATE_SIGNING_PASSPHRASE="your-certificate-password"
# NEXT_PUBLIC_DISABLE_MICROSOFT_SIGNUP="true"
# NEXT_PUBLIC_DISABLE_OIDC_SIGNUP="true"
# NEXT_PRIVATE_ALLOWED_SIGNUP_DOMAINS="example.com,acme.org"
# Sign-in restrictions (optional)
# NEXT_PUBLIC_DISABLE_SIGNIN="true"
# NEXT_PUBLIC_DISABLE_EMAIL_PASSWORD_SIGNIN="true"
# NEXT_PUBLIC_DISABLE_GOOGLE_SIGNIN="true"
# NEXT_PUBLIC_DISABLE_MICROSOFT_SIGNIN="true"
# NEXT_PUBLIC_DISABLE_OIDC_SIGNIN="true"
# Opt out of the automatic OIDC redirect when OIDC is the only enabled transport (optional)
# NEXT_PUBLIC_DISABLE_OIDC_AUTO_REDIRECT="true"
```
---
@@ -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",
"pages": [
"environment",
"license",
"database",
"email",
"storage",
@@ -49,7 +49,7 @@ The callback URL is fixed — Documenso derives it from `NEXT_PUBLIC_WEBAPP_URL`
### Enterprise Edition license
CSC mode is gated by the `instanceCscSigning` license flag. Without a valid Enterprise license, the transport refuses to start (`CSC_UNLICENSED`).
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>
@@ -163,6 +163,19 @@ NEXT_PUBLIC_DISABLE_SIGNUP=false
# NEXT_PUBLIC_DISABLE_MICROSOFT_SIGNUP=true
# NEXT_PUBLIC_DISABLE_OIDC_SIGNUP=true
# NEXT_PRIVATE_ALLOWED_SIGNUP_DOMAINS=example.com,acme.org
# Signin restrictions (optional)
# Master switch — disables every signin method
# NEXT_PUBLIC_DISABLE_SIGNIN=true
# Per-method switches (optional). Each disables that signin path.
# NEXT_PUBLIC_DISABLE_EMAIL_PASSWORD_SIGNIN=true
# NEXT_PUBLIC_DISABLE_GOOGLE_SIGNIN=true
# NEXT_PUBLIC_DISABLE_MICROSOFT_SIGNIN=true
# NEXT_PUBLIC_DISABLE_OIDC_SIGNIN=true
# When OIDC is the only enabled transport, /signin auto-redirects to the provider.
# Set this to opt out and keep showing the signin page (optional).
# NEXT_PUBLIC_DISABLE_OIDC_AUTO_REDIRECT=true
```
<Callout type="info">Generate secure secrets using: `openssl rand -base64 32`</Callout>
@@ -112,6 +112,12 @@ See [Email Configuration](/docs/self-hosting/configuration/email) for other tran
| `NEXT_PUBLIC_DISABLE_MICROSOFT_SIGNUP` | Block new accounts via Microsoft OAuth | `false` |
| `NEXT_PUBLIC_DISABLE_OIDC_SIGNUP` | Block new accounts via OIDC (incl. organisation portal) | `false` |
| `NEXT_PRIVATE_ALLOWED_SIGNUP_DOMAINS` | Comma-separated list of allowed signup email domains | |
| `NEXT_PUBLIC_DISABLE_SIGNIN` | Master switch — disable all signin methods | `false` |
| `NEXT_PUBLIC_DISABLE_EMAIL_PASSWORD_SIGNIN` | Disable email/password signin only | `false` |
| `NEXT_PUBLIC_DISABLE_GOOGLE_SIGNIN` | Hide the Google signin button | `false` |
| `NEXT_PUBLIC_DISABLE_MICROSOFT_SIGNIN` | Hide the Microsoft signin button | `false` |
| `NEXT_PUBLIC_DISABLE_OIDC_SIGNIN` | Hide the OIDC signin button | `false` |
| `NEXT_PUBLIC_DISABLE_OIDC_AUTO_REDIRECT` | Disable auto-redirect to OIDC when it is the only transport | `false` |
For the complete list, see [Environment Variables](/docs/self-hosting/configuration/environment).
@@ -159,6 +159,12 @@ NEXT_PRIVATE_SMTP_FROM_ADDRESS=noreply@yourdomain.com
| `NEXT_PUBLIC_DISABLE_MICROSOFT_SIGNUP`| Block new accounts via Microsoft OAuth | `false` |
| `NEXT_PUBLIC_DISABLE_OIDC_SIGNUP` | Block new accounts via OIDC (incl. organisation portal)| `false` |
| `NEXT_PRIVATE_ALLOWED_SIGNUP_DOMAINS` | Comma-separated list of allowed signup email domains | |
| `NEXT_PUBLIC_DISABLE_SIGNIN` | Master switch — disable all signin methods | `false` |
| `NEXT_PUBLIC_DISABLE_EMAIL_PASSWORD_SIGNIN` | Disable email/password signin only | `false` |
| `NEXT_PUBLIC_DISABLE_GOOGLE_SIGNIN` | Hide the Google signin button | `false` |
| `NEXT_PUBLIC_DISABLE_MICROSOFT_SIGNIN`| Hide the Microsoft signin button | `false` |
| `NEXT_PUBLIC_DISABLE_OIDC_SIGNIN` | Hide the OIDC signin button | `false` |
| `NEXT_PUBLIC_DISABLE_OIDC_AUTO_REDIRECT` | Disable auto-redirect to OIDC when it is the only transport | `false` |
| `NEXT_PRIVATE_SIGNING_PASSPHRASE` | Passphrase for signing certificate | - |
| `DOCUMENSO_DISABLE_TELEMETRY` | Disable anonymous telemetry | `false` |
@@ -141,7 +141,7 @@ See the [Quick Start guide](/docs/self-hosting/getting-started/quick-start) for
Self-hosted Documenso includes full core functionality under the AGPL-3.0 license. If you need enterprise features such as SSO, embed editor white label, or 21 CFR Part 11 compliance, you can activate them with a license key.
See [Enterprise Edition](/docs/policies/enterprise-edition) for details and [Licenses](/docs/policies/licenses) for a comparison.
See [Enterprise Edition](/docs/policies/enterprise-edition) for details and [Licenses](/docs/policies/licenses) for a comparison. Already have a key? See [Apply Your License Key](/docs/self-hosting/configuration/license).
---
@@ -1,3 +1,4 @@
import { ZNameSchema } from '@documenso/lib/types/name';
import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import {
@@ -23,7 +24,7 @@ import { useParams } from 'react-router';
import { z } from 'zod';
const ZCreateFolderFormSchema = z.object({
name: z.string().min(1, { message: 'Folder name is required' }),
name: ZNameSchema,
});
type TCreateFolderFormSchema = z.infer<typeof ZCreateFolderFormSchema>;
@@ -65,7 +66,7 @@ export const FolderCreateDialog = ({ type, trigger, parentFolderId, ...props }:
toast({
description: t`Folder created successfully`,
});
} catch (err) {
} catch (_err) {
toast({
title: t`Failed to create folder`,
description: t`An unknown error occurred while creating the folder.`,
@@ -1,5 +1,6 @@
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { DocumentVisibility } from '@documenso/lib/types/document-visibility';
import { ZNameSchema } from '@documenso/lib/types/name';
import { trpc } from '@documenso/trpc/react';
import type { TFolderWithSubfolders } from '@documenso/trpc/server/folder-router/schema';
import { Button } from '@documenso/ui/primitives/button';
@@ -23,8 +24,6 @@ import { useEffect } from 'react';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { useOptionalCurrentTeam } from '~/providers/team';
export type FolderUpdateDialogProps = {
folder: TFolderWithSubfolders | null;
isOpen: boolean;
@@ -32,7 +31,7 @@ export type FolderUpdateDialogProps = {
} & Omit<DialogPrimitive.DialogProps, 'children'>;
export const ZUpdateFolderFormSchema = z.object({
name: z.string().min(1),
name: ZNameSchema,
visibility: z.nativeEnum(DocumentVisibility).optional(),
});
@@ -40,7 +39,6 @@ export type TUpdateFolderFormSchema = z.infer<typeof ZUpdateFolderFormSchema>;
export const FolderUpdateDialog = ({ folder, isOpen, onOpenChange }: FolderUpdateDialogProps) => {
const { t } = useLingui();
const team = useOptionalCurrentTeam();
const { toast } = useToast();
const { mutateAsync: updateFolder } = trpc.folder.updateFolder.useMutation();
@@ -1,5 +1,6 @@
import { MAXIMUM_PASSKEYS } from '@documenso/lib/constants/auth';
import { AppError } from '@documenso/lib/errors/app-error';
import { ZNameSchema } from '@documenso/lib/types/name';
import { trpc } from '@documenso/trpc/react';
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
@@ -25,14 +26,13 @@ import { useForm } from 'react-hook-form';
import { match } from 'ts-pattern';
import { UAParser } from 'ua-parser-js';
import { z } from 'zod';
export type PasskeyCreateDialogProps = {
trigger?: React.ReactNode;
onSuccess?: () => void;
} & Omit<DialogPrimitive.DialogProps, 'children'>;
const ZCreatePasskeyFormSchema = z.object({
passkeyName: z.string().min(3),
passkeyName: ZNameSchema,
});
type TCreatePasskeyFormSchema = z.infer<typeof ZCreatePasskeyFormSchema>;
@@ -1,4 +1,5 @@
import { trpc } from '@documenso/trpc/react';
import { ZUpdateTeamEmailMutationSchema } from '@documenso/trpc/server/team-router/schema';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
@@ -19,16 +20,16 @@ import type * as DialogPrimitive from '@radix-ui/react-dialog';
import { useEffect, useState } from 'react';
import { useForm } from 'react-hook-form';
import { useRevalidator } from 'react-router';
import { z } from 'zod';
import type { z } from 'zod';
export type TeamEmailUpdateDialogProps = {
teamEmail: TeamEmail;
trigger?: React.ReactNode;
} & Omit<DialogPrimitive.DialogProps, 'children'>;
const ZUpdateTeamEmailFormSchema = z.object({
name: z.string().trim().min(1, { message: 'Please enter a valid name.' }),
});
const ZUpdateTeamEmailFormSchema = ZUpdateTeamEmailMutationSchema.pick({
data: true,
}).shape.data;
type TUpdateTeamEmailFormSchema = z.infer<typeof ZUpdateTeamEmailFormSchema>;
@@ -44,6 +45,7 @@ export const TeamEmailUpdateDialog = ({ teamEmail, trigger, ...props }: TeamEmai
defaultValues: {
name: teamEmail.name,
},
mode: 'onSubmit',
});
const { mutateAsync: updateTeamEmail } = trpc.team.email.update.useMutation();
@@ -1,5 +1,10 @@
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import {
BRANDING_LOGO_ALLOWED_TYPES,
BRANDING_LOGO_MAX_SIZE_BYTES,
BRANDING_LOGO_MAX_SIZE_MB,
} from '@documenso/lib/constants/branding';
import { DEFAULT_BRAND_COLORS, DEFAULT_BRAND_RADIUS } from '@documenso/lib/constants/theme';
import { ZCssVarsSchema } from '@documenso/lib/types/css-vars';
import { cn } from '@documenso/ui/lib/utils';
@@ -21,15 +26,17 @@ import { z } from 'zod';
import { useOptionalCurrentTeam } from '~/providers/team';
import { useCspNonce } from '~/utils/nonce';
const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB
const ACCEPTED_FILE_TYPES = ['image/jpeg', 'image/png', 'image/webp'];
import { FormStickySaveBar } from './form-sticky-save-bar';
const ZBrandingPreferencesFormSchema = z.object({
brandingEnabled: z.boolean().nullable(),
brandingLogo: z
.instanceof(File)
.refine((file) => file.size <= MAX_FILE_SIZE, 'File size must be less than 5MB')
.refine((file) => ACCEPTED_FILE_TYPES.includes(file.type), 'Only .jpg, .png, and .webp files are accepted')
.refine(
(file) => file.size <= BRANDING_LOGO_MAX_SIZE_BYTES,
`File size must be less than ${BRANDING_LOGO_MAX_SIZE_MB}MB`,
)
.refine((file) => BRANDING_LOGO_ALLOWED_TYPES.includes(file.type), 'Only .jpg, .png, and .webp files are accepted')
.nullish(),
brandingUrl: z.string().url().optional().or(z.literal('')),
brandingCompanyDetails: z.string().max(500).optional(),
@@ -71,38 +78,82 @@ export function BrandingPreferencesForm({
const parsedColors = ZCssVarsSchema.safeParse(settings.brandingColors);
const initialColors = parsedColors.success ? parsedColors.data : {};
// The saved state the form maps to. Used both as the reactive `values` source and as
// the explicit target for a Reset (see handleReset).
const savedValues: TBrandingPreferencesFormSchema = {
brandingEnabled: settings.brandingEnabled ?? null,
brandingUrl: settings.brandingUrl ?? '',
brandingLogo: undefined,
brandingCompanyDetails: settings.brandingCompanyDetails ?? '',
brandingColors: initialColors,
brandingCss: settings.brandingCss ?? '',
};
const form = useForm<TBrandingPreferencesFormSchema>({
values: {
brandingEnabled: settings.brandingEnabled ?? null,
brandingUrl: settings.brandingUrl ?? '',
brandingLogo: undefined,
brandingCompanyDetails: settings.brandingCompanyDetails ?? '',
brandingColors: initialColors,
brandingCss: settings.brandingCss ?? '',
},
values: savedValues,
resolver: zodResolver(ZBrandingPreferencesFormSchema),
});
const isBrandingEnabled = form.watch('brandingEnabled');
const getSavedLogoPreviewUrl = () => {
if (!settings.brandingLogo) {
return '';
}
const file = JSON.parse(settings.brandingLogo);
if (!('type' in file) || !('data' in file)) {
return '';
}
const logoUrl =
context === 'Team'
? `${NEXT_PUBLIC_WEBAPP_URL()}/api/branding/logo/team/${team?.id}`
: `${NEXT_PUBLIC_WEBAPP_URL()}/api/branding/logo/organisation/${organisation?.id}`;
return `${logoUrl}?v=${Date.now()}`;
};
useEffect(() => {
if (settings.brandingLogo) {
const file = JSON.parse(settings.brandingLogo);
const savedLogoPreviewUrl = getSavedLogoPreviewUrl();
if ('type' in file && 'data' in file) {
const logoUrl =
context === 'Team'
? `${NEXT_PUBLIC_WEBAPP_URL()}/api/branding/logo/team/${team?.id}`
: `${NEXT_PUBLIC_WEBAPP_URL()}/api/branding/logo/organisation/${organisation?.id}`;
setPreviewUrl(logoUrl + '?v=' + Date.now());
setHasLoadedPreview(true);
}
if (savedLogoPreviewUrl) {
setPreviewUrl(savedLogoPreviewUrl);
}
setHasLoadedPreview(true);
}, [settings.brandingLogo]);
// Reset the form to the saved values. The form is driven by the `values` prop (no
// `defaultValues`), so `reset()` with no argument doesn't re-baseline the dirty check;
// passing the saved values clears the per-field dirty tracking (dirtyFields).
const handleReset = () => {
setPreviewUrl(getSavedLogoPreviewUrl());
form.reset(savedValues);
};
// `formState.isDirty` is unreliable for a `values`-driven form: after a reset (or a
// save + refetch) it can stay true even though every field already matches its saved
// value and `dirtyFields` is empty. Derive the flag from `dirtyFields` instead so the
// sticky save bar reliably disappears.
const hasUnsavedChanges = Object.keys(form.formState.dirtyFields).length > 0;
// Re-baseline the form to the just-saved state after a successful submit. The `values`
// prop re-syncs most fields once the route refetches, but write-only fields (the logo
// is a File that isn't reflected back into `values`) would otherwise stay dirty and
// keep the save bar visible. Relies on the page handler rethrowing on error so we only
// re-baseline on success.
const handleFormSubmit = form.handleSubmit(async (data) => {
try {
await onFormSubmit(data);
} catch {
return;
}
form.reset(form.getValues());
});
// Cleanup ObjectURL on unmount or when previewUrl changes
useEffect(() => {
return () => {
@@ -114,7 +165,7 @@ export function BrandingPreferencesForm({
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onFormSubmit)}>
<form onSubmit={handleFormSubmit}>
<fieldset className="flex h-full flex-col gap-y-4" disabled={form.formState.isSubmitting}>
<FormField
control={form.control}
@@ -167,7 +218,7 @@ export function BrandingPreferencesForm({
/>
<div className="relative flex w-full flex-col gap-y-4">
{!isBrandingEnabled && <div className="absolute inset-0 z-[9998] bg-background/60" />}
{!isBrandingEnabled && <div className="absolute inset-0 z-30 bg-background/60" />}
<FormField
control={form.control}
@@ -199,7 +250,7 @@ export function BrandingPreferencesForm({
<FormControl className="relative">
<Input
type="file"
accept={ACCEPTED_FILE_TYPES.join(',')}
accept={BRANDING_LOGO_ALLOWED_TYPES.join(',')}
disabled={!isBrandingEnabled}
onChange={(e) => {
const file = e.target.files?.[0];
@@ -321,7 +372,7 @@ export function BrandingPreferencesForm({
{hasAdvancedBranding && (
<div className="relative flex w-full flex-col gap-y-6">
{!isBrandingEnabled && <div className="absolute inset-0 z-[9998] bg-background/60" />}
{!isBrandingEnabled && <div className="absolute inset-0 z-30 bg-background/60" />}
<div>
<FormLabel>
@@ -538,11 +589,11 @@ export function BrandingPreferencesForm({
</div>
)}
<div className="flex flex-row justify-end space-x-4">
<Button type="submit" loading={form.formState.isSubmitting}>
<Trans>Update</Trans>
</Button>
</div>
<FormStickySaveBar
isDirty={hasUnsavedChanges}
isSubmitting={form.formState.isSubmitting}
onReset={handleReset}
/>
</fieldset>
</form>
</Form>
@@ -21,7 +21,6 @@ import { ReminderSettingsPicker } from '@documenso/ui/components/document/remind
import { RecipientRoleSelect } from '@documenso/ui/components/recipient/recipient-role-select';
import { Alert } from '@documenso/ui/primitives/alert';
import { AvatarWithText } from '@documenso/ui/primitives/avatar';
import { Button } from '@documenso/ui/primitives/button';
import { Combobox } from '@documenso/ui/primitives/combobox';
import {
Form,
@@ -46,6 +45,7 @@ import { z } from 'zod';
import { useOptionalCurrentTeam } from '~/providers/team';
import { DefaultRecipientsMultiSelectCombobox } from '../general/default-recipients-multiselect-combobox';
import { FormStickySaveBar } from './form-sticky-save-bar';
/**
* Can't infer this from the schema since we need to keep the schema inside the component to allow
@@ -147,9 +147,21 @@ export const DocumentPreferencesForm = ({
resolver: zodResolver(ZDocumentPreferencesFormSchema),
});
const handleFormSubmit = form.handleSubmit(async (data) => {
try {
await onFormSubmit(data);
} catch {
// The page handler surfaces its own error toast. Keep the form dirty so
// the save bar stays visible and the user can retry.
return;
}
form.reset(data);
});
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onFormSubmit)}>
<form onSubmit={handleFormSubmit}>
<fieldset className="flex h-full max-w-2xl flex-col gap-y-6" disabled={form.formState.isSubmitting}>
{!isPersonalLayoutMode && (
<FormField
@@ -756,11 +768,11 @@ export const DocumentPreferencesForm = ({
/>
)}
<div className="flex flex-row justify-end space-x-4">
<Button type="submit" loading={form.formState.isSubmitting}>
<Trans>Update</Trans>
</Button>
</div>
<FormStickySaveBar
isDirty={form.formState.isDirty}
isSubmitting={form.formState.isSubmitting}
onReset={() => form.reset()}
/>
</fieldset>
</form>
</Form>
@@ -4,7 +4,6 @@ import { DEFAULT_DOCUMENT_EMAIL_SETTINGS, ZDocumentEmailSettingsSchema } from '@
import { zEmail } from '@documenso/lib/utils/zod';
import { trpc } from '@documenso/trpc/react';
import { DocumentEmailCheckboxes } from '@documenso/ui/components/document/document-email-checkboxes';
import { Button } from '@documenso/ui/primitives/button';
import {
Form,
FormControl,
@@ -22,6 +21,8 @@ import type { TeamGlobalSettings } from '@prisma/client';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { FormStickySaveBar } from './form-sticky-save-bar';
const ZEmailPreferencesFormSchema = z.object({
emailId: z.string().nullable(),
emailReplyTo: zEmail().nullable(),
@@ -59,9 +60,21 @@ export const EmailPreferencesForm = ({ settings, onFormSubmit, canInherit }: Ema
const emails = emailData?.data || [];
const handleFormSubmit = form.handleSubmit(async (data) => {
try {
await onFormSubmit(data);
} catch {
// The page handler surfaces its own error toast. Keep the form dirty so
// the save bar stays visible and the user can retry.
return;
}
form.reset(data);
});
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onFormSubmit)}>
<form onSubmit={handleFormSubmit}>
<fieldset className="flex h-full max-w-2xl flex-col gap-y-6" disabled={form.formState.isSubmitting}>
{organisation.organisationClaim.flags.emailDomains && (
<FormField
@@ -203,11 +216,11 @@ export const EmailPreferencesForm = ({ settings, onFormSubmit, canInherit }: Ema
)}
/>
<div className="flex flex-row justify-end space-x-4">
<Button type="submit" loading={form.formState.isSubmitting}>
<Trans>Update</Trans>
</Button>
</div>
<FormStickySaveBar
isDirty={form.formState.isDirty}
isSubmitting={form.formState.isSubmitting}
onReset={() => form.reset()}
/>
</fieldset>
</form>
</Form>
@@ -1,3 +1,4 @@
import { ZNameSchema } from '@documenso/lib/types/name';
import {
Form,
FormControl,
@@ -15,8 +16,8 @@ import { useForm } from 'react-hook-form';
import { z } from 'zod';
const ZEmailTransportFormSchema = z.object({
name: z.string().min(1),
fromName: z.string().min(1),
name: ZNameSchema,
fromName: ZNameSchema,
fromAddress: z.string().email(),
type: z.enum(['SMTP_AUTH', 'SMTP_API', 'RESEND', 'MAILCHANNELS']),
host: z.string().optional(),
@@ -0,0 +1,119 @@
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import { Trans, useLingui } from '@lingui/react/macro';
import { AnimatePresence, motion } from 'framer-motion';
import { AlertTriangleIcon } from 'lucide-react';
import { useEffect, useRef, useState } from 'react';
export type FormStickySaveBarProps = {
isDirty: boolean;
isSubmitting: boolean;
onReset: () => void;
};
/**
* A single `position: sticky` bar rendered at the bottom of the form.
*
* - When the form's end is on screen it settles into place as a plain footer (just the
* Reset / Save buttons).
* - When the form's end is scrolled off, it sticks to the bottom of the viewport and
* shows the "unsaved changes" pill chrome.
*
* Because it's the same element in the form's flow, it auto-aligns to the form and the
* float <-> dock hand-off is a native, scroll-linked transition (no measurement, no
* shared-layout morph). A 1px sentinel below it detects the stuck state so we can toggle
* the pill chrome.
*/
export const FormStickySaveBar = ({ isDirty, isSubmitting, onReset }: FormStickySaveBarProps) => {
const { t } = useLingui();
const sentinelRef = useRef<HTMLDivElement>(null);
const [isStuck, setIsStuck] = useState(false);
useEffect(() => {
const sentinel = sentinelRef.current;
if (!sentinel) {
return;
}
// The sentinel sits at the bar's resting position (the end of the form). While the
// bar is stuck to the bottom of the viewport the sentinel is scrolled past (out of
// view); once you reach the form's end it comes into view and the bar settles.
const observer = new IntersectionObserver(
([entry]) => {
setIsStuck(!entry.isIntersecting);
},
{
root: null,
rootMargin: '0px 0px -24px 0px',
threshold: 0,
},
);
observer.observe(sentinel);
return () => {
observer.disconnect();
};
}, []);
// Show the floating pill chrome only when there are unsaved changes AND the form's
// end is off screen.
const isFloating = isDirty && isStuck;
return (
<>
<div
data-testid="form-sticky-save-bar"
className={cn(
'z-40 flex min-h-9 min-w-0 items-center gap-x-2 rounded-lg py-4 transition-[margin,padding,background-color,border-color,box-shadow] duration-200 md:gap-x-4',
isDirty ? 'sticky bottom-6' : '',
// On mobile the docked and floating states are geometrically identical (only
// paint changes): a horizontal bleed there overflows the narrow viewport and
// fights the IntersectionObserver (oscillation + partial hiding). From `sm` up
// there's room, so we restore the original chrome — the island bleeds 8px past
// the form when floating, and the buttons sit flush with the fields when docked.
isFloating
? 'border border-border bg-background px-4 shadow-2xl sm:-mx-2'
: 'border border-transparent bg-transparent px-4 shadow-none sm:px-0',
)}
>
<AnimatePresence initial={false}>
{isFloating && (
<motion.div
key="notice"
role="region"
aria-label={t`Unsaved changes`}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.15 }}
className="flex min-h-9 min-w-0 items-center gap-x-2 text-sm"
>
<AlertTriangleIcon className="h-5 w-5 flex-shrink-0 text-destructive" />
<span className="font-medium text-xs md:text-sm">
<Trans>You have unsaved changes</Trans>
</span>
</motion.div>
)}
</AnimatePresence>
<div className="ml-auto flex flex-shrink-0 items-center gap-x-2">
{isDirty && (
<Button type="button" variant="secondary" size="sm" onClick={onReset} disabled={isSubmitting}>
<Trans>Undo</Trans>
</Button>
)}
<Button type="submit" className="shrink-0" size="sm" loading={isSubmitting} disabled={!isDirty}>
<Trans>Save changes</Trans>
</Button>
</div>
</div>
{/* Sentinel: detects when the sticky bar is floating (stuck) vs settled (docked). */}
<div ref={sentinelRef} aria-hidden className="pointer-events-none h-px w-full" />
</>
);
};
@@ -4,7 +4,6 @@ import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { trpc } from '@documenso/trpc/react';
import { ZUpdateOrganisationRequestSchema } from '@documenso/trpc/server/organisation-router/update-organisation.types';
import { Button } from '@documenso/ui/primitives/button';
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { useToast } from '@documenso/ui/primitives/use-toast';
@@ -12,11 +11,12 @@ import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { AnimatePresence, motion } from 'framer-motion';
import { useForm } from 'react-hook-form';
import { useNavigate } from 'react-router';
import type { z } from 'zod';
import { FormStickySaveBar } from './form-sticky-save-bar';
const ZOrganisationUpdateFormSchema = ZUpdateOrganisationRequestSchema.shape.data.pick({
name: true,
url: true,
@@ -137,36 +137,11 @@ export const OrganisationUpdateForm = () => {
)}
/>
<div className="flex flex-row justify-end space-x-4">
<AnimatePresence>
{form.formState.isDirty && (
<motion.div
initial={{
opacity: 0,
}}
animate={{
opacity: 1,
}}
exit={{
opacity: 0,
}}
>
<Button type="button" variant="secondary" onClick={() => form.reset()}>
<Trans>Reset</Trans>
</Button>
</motion.div>
)}
</AnimatePresence>
<Button
type="submit"
className="transition-opacity"
disabled={!form.formState.isDirty}
loading={form.formState.isSubmitting}
>
<Trans>Update organisation</Trans>
</Button>
</div>
<FormStickySaveBar
isDirty={form.formState.isDirty}
isSubmitting={form.formState.isSubmitting}
onReset={() => form.reset()}
/>
</fieldset>
</form>
</Form>
+1 -1
View File
@@ -1,5 +1,5 @@
import { useSession } from '@documenso/lib/client-only/providers/session';
import { ZNameSchema } from '@documenso/lib/constants/auth';
import { ZNameSchema } from '@documenso/lib/types/name';
import { trpc } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
+63 -49
View File
@@ -58,6 +58,7 @@ export type TSignInFormSchema = z.infer<typeof ZSignInFormSchema>;
export type SignInFormProps = {
className?: string;
initialEmail?: string;
isEmailPasswordSigninEnabled?: boolean;
isGoogleSSOEnabled?: boolean;
isMicrosoftSSOEnabled?: boolean;
isOIDCSSOEnabled?: boolean;
@@ -68,6 +69,7 @@ export type SignInFormProps = {
export const SignInForm = ({
className,
initialEmail,
isEmailPasswordSigninEnabled = true,
isGoogleSSOEnabled,
isMicrosoftSSOEnabled,
isOIDCSSOEnabled,
@@ -324,66 +326,78 @@ export const SignInForm = ({
<Form {...form}>
<form className={cn('flex w-full flex-col gap-y-4', className)} onSubmit={form.handleSubmit(onFormSubmit)}>
<fieldset className="flex w-full flex-col gap-y-4" disabled={isSubmitting || isPasskeyLoading}>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Email</Trans>
</FormLabel>
{isEmailPasswordSigninEnabled && (
<>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Email</Trans>
</FormLabel>
<FormControl>
<Input type="email" {...field} />
</FormControl>
<FormControl>
<Input type="email" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Password</Trans>
</FormLabel>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Password</Trans>
</FormLabel>
<FormControl>
<PasswordInput {...field} />
</FormControl>
<FormControl>
<PasswordInput {...field} />
</FormControl>
<FormMessage />
<FormMessage />
<p className="mt-2 text-right">
<Link to="/forgot-password" className="text-muted-foreground text-sm duration-200 hover:opacity-70">
<Trans>Forgot your password?</Trans>
</Link>
</p>
</FormItem>
)}
/>
<p className="mt-2 text-right">
<Link
to="/forgot-password"
className="text-muted-foreground text-sm duration-200 hover:opacity-70"
>
<Trans>Forgot your password?</Trans>
</Link>
</p>
</FormItem>
)}
/>
{turnstileSiteKey && !isTwoFactorAuthenticationDialogOpen && (
<Turnstile
ref={turnstileRef}
siteKey={turnstileSiteKey}
options={{
size: 'flexible',
appearance: 'always',
}}
/>
{turnstileSiteKey && !isTwoFactorAuthenticationDialogOpen && (
<Turnstile
ref={turnstileRef}
siteKey={turnstileSiteKey}
options={{
size: 'flexible',
appearance: 'always',
}}
/>
)}
<Button
type="submit"
size="lg"
loading={isSubmitting}
className="dark:bg-documenso dark:hover:opacity-90"
>
{isSubmitting ? <Trans>Signing in...</Trans> : <Trans>Sign In</Trans>}
</Button>
</>
)}
<Button type="submit" size="lg" loading={isSubmitting} className="dark:bg-documenso dark:hover:opacity-90">
{isSubmitting ? <Trans>Signing in...</Trans> : <Trans>Sign In</Trans>}
</Button>
{!isEmbeddedRedirect && (
<>
{hasSocialAuthEnabled && (
{isEmailPasswordSigninEnabled && hasSocialAuthEnabled && (
<div className="relative flex items-center justify-center gap-x-4 py-2 text-xs uppercase">
<div className="h-px flex-1 bg-border" />
<span className="bg-transparent text-muted-foreground">
+1 -1
View File
@@ -1,8 +1,8 @@
import communityCardsImage from '@documenso/assets/images/community-cards.png';
import { authClient } from '@documenso/auth/client';
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
import { ZNameSchema } from '@documenso/lib/constants/auth';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { ZNameSchema } from '@documenso/lib/types/name';
import { env } from '@documenso/lib/utils/env';
import { zEmail } from '@documenso/lib/utils/zod';
import { ZPasswordSchema } from '@documenso/trpc/server/auth-router/schema';
@@ -2,7 +2,6 @@ import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { trpc } from '@documenso/trpc/react';
import { ZUpdateTeamRequestSchema } from '@documenso/trpc/server/team-router/update-team.types';
import { Button } from '@documenso/ui/primitives/button';
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { useToast } from '@documenso/ui/primitives/use-toast';
@@ -10,11 +9,12 @@ import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { AnimatePresence, motion } from 'framer-motion';
import { useForm } from 'react-hook-form';
import { useNavigate } from 'react-router';
import type { z } from 'zod';
import { FormStickySaveBar } from './form-sticky-save-bar';
export type UpdateTeamDialogProps = {
teamId: number;
teamName: string;
@@ -135,36 +135,11 @@ export const TeamUpdateForm = ({ teamId, teamName, teamUrl }: UpdateTeamDialogPr
)}
/>
<div className="flex flex-row justify-end space-x-4">
<AnimatePresence>
{form.formState.isDirty && (
<motion.div
initial={{
opacity: 0,
}}
animate={{
opacity: 1,
}}
exit={{
opacity: 0,
}}
>
<Button type="button" variant="secondary" onClick={() => form.reset()}>
<Trans>Reset</Trans>
</Button>
</motion.div>
)}
</AnimatePresence>
<Button
type="submit"
className="transition-opacity"
disabled={!form.formState.isDirty}
loading={form.formState.isSubmitting}
>
<Trans>Update team</Trans>
</Button>
</div>
<FormStickySaveBar
isDirty={form.formState.isDirty}
isSubmitting={form.formState.isSubmitting}
onReset={() => form.reset()}
/>
</fieldset>
</form>
</Form>
@@ -5,6 +5,7 @@ import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import type { OrganisationGlobalSettings, TeamGlobalSettings } from '@prisma/client';
import type { ReactNode } from 'react';
import { DetailsCard, DetailsValue } from '~/components/general/admin-details';
@@ -25,38 +26,72 @@ const emailSettingsKeys = Object.keys(EMAIL_SETTINGS_LABELS) as (keyof TDocument
type AdminGlobalSettingsSectionProps = {
settings: TeamGlobalSettings | OrganisationGlobalSettings | null;
isTeam?: boolean;
/** When viewing a team, the parent organisation settings the team inherits from. */
inheritedSettings?: OrganisationGlobalSettings | null;
};
export const AdminGlobalSettingsSection = ({ settings, isTeam = false }: AdminGlobalSettingsSectionProps) => {
export const AdminGlobalSettingsSection = ({
settings,
isTeam = false,
inheritedSettings,
}: AdminGlobalSettingsSectionProps) => {
const { _ } = useLingui();
const notSetLabel = isTeam ? <Trans>Inherited</Trans> : <Trans>Not set</Trans>;
if (!settings) {
return null;
}
const textValue = (value: string | null | undefined) => {
if (value === null || value === undefined) {
return notSetLabel;
const notSet = <Trans>Not set</Trans>;
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) => {
if (value === null || value === undefined || value.trim() === '') {
return notSetLabel;
const textValue = (value: string | null | undefined, inherited?: string | null) => {
if (value && value.trim() !== '') {
return value;
}
return value;
if (inherited && inherited.trim() !== '') {
return inheritedValue(inherited);
}
return notSet;
};
const booleanValue = (value: boolean | null | undefined) => {
if (value === null || value === undefined) {
return notSetLabel;
const booleanLabel = (value: boolean) => (value ? <Trans>Enabled</Trans> : <Trans>Disabled</Trans>);
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);
@@ -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">
<DetailsCard label={<Trans>Document visibility</Trans>}>
<DetailsValue>
{settings.documentVisibility != null
? _(DOCUMENT_VISIBILITY[settings.documentVisibility].value)
: notSetLabel}
{visibilityValue(settings.documentVisibility, inheritedSettings?.documentVisibility)}
</DetailsValue>
</DetailsCard>
<DetailsCard label={<Trans>Document language</Trans>}>
<DetailsValue>{textValue(settings.documentLanguage)}</DetailsValue>
<DetailsValue>{textValue(settings.documentLanguage, inheritedSettings?.documentLanguage)}</DetailsValue>
</DetailsCard>
<DetailsCard label={<Trans>Document timezone</Trans>}>
<DetailsValue>{textValue(settings.documentTimezone)}</DetailsValue>
<DetailsValue>{textValue(settings.documentTimezone, inheritedSettings?.documentTimezone)}</DetailsValue>
</DetailsCard>
<DetailsCard label={<Trans>Date format</Trans>}>
<DetailsValue>{textValue(settings.documentDateFormat)}</DetailsValue>
<DetailsValue>{textValue(settings.documentDateFormat, inheritedSettings?.documentDateFormat)}</DetailsValue>
</DetailsCard>
<DetailsCard label={<Trans>Include sender details</Trans>}>
<DetailsValue>{booleanValue(settings.includeSenderDetails)}</DetailsValue>
<DetailsValue>
{booleanValue(settings.includeSenderDetails, inheritedSettings?.includeSenderDetails)}
</DetailsValue>
</DetailsCard>
<DetailsCard label={<Trans>Include signing certificate</Trans>}>
<DetailsValue>{booleanValue(settings.includeSigningCertificate)}</DetailsValue>
<DetailsValue>
{booleanValue(settings.includeSigningCertificate, inheritedSettings?.includeSigningCertificate)}
</DetailsValue>
</DetailsCard>
<DetailsCard label={<Trans>Include audit log</Trans>}>
<DetailsValue>{booleanValue(settings.includeAuditLog)}</DetailsValue>
<DetailsValue>{booleanValue(settings.includeAuditLog, inheritedSettings?.includeAuditLog)}</DetailsValue>
</DetailsCard>
<DetailsCard label={<Trans>Delegate document ownership</Trans>}>
<DetailsValue>{booleanValue(settings.delegateDocumentOwnership)}</DetailsValue>
<DetailsValue>
{booleanValue(settings.delegateDocumentOwnership, inheritedSettings?.delegateDocumentOwnership)}
</DetailsValue>
</DetailsCard>
<DetailsCard label={<Trans>Typed signature</Trans>}>
<DetailsValue>{booleanValue(settings.typedSignatureEnabled)}</DetailsValue>
<DetailsValue>
{booleanValue(settings.typedSignatureEnabled, inheritedSettings?.typedSignatureEnabled)}
</DetailsValue>
</DetailsCard>
<DetailsCard label={<Trans>Upload signature</Trans>}>
<DetailsValue>{booleanValue(settings.uploadSignatureEnabled)}</DetailsValue>
<DetailsValue>
{booleanValue(settings.uploadSignatureEnabled, inheritedSettings?.uploadSignatureEnabled)}
</DetailsValue>
</DetailsCard>
<DetailsCard label={<Trans>Draw signature</Trans>}>
<DetailsValue>{booleanValue(settings.drawSignatureEnabled)}</DetailsValue>
<DetailsValue>
{booleanValue(settings.drawSignatureEnabled, inheritedSettings?.drawSignatureEnabled)}
</DetailsValue>
</DetailsCard>
<DetailsCard label={<Trans>Branding</Trans>}>
<DetailsValue>{booleanValue(settings.brandingEnabled)}</DetailsValue>
<DetailsValue>{booleanValue(settings.brandingEnabled, inheritedSettings?.brandingEnabled)}</DetailsValue>
</DetailsCard>
<DetailsCard label={<Trans>Branding logo</Trans>}>
<DetailsValue>{brandingTextValue(settings.brandingLogo)}</DetailsValue>
<DetailsValue>{textValue(settings.brandingLogo, inheritedSettings?.brandingLogo)}</DetailsValue>
</DetailsCard>
<DetailsCard label={<Trans>Branding URL</Trans>}>
<DetailsValue>{brandingTextValue(settings.brandingUrl)}</DetailsValue>
<DetailsValue>{textValue(settings.brandingUrl, inheritedSettings?.brandingUrl)}</DetailsValue>
</DetailsCard>
<DetailsCard label={<Trans>Branding company details</Trans>}>
<DetailsValue>{brandingTextValue(settings.brandingCompanyDetails)}</DetailsValue>
<DetailsValue>
{textValue(settings.brandingCompanyDetails, inheritedSettings?.brandingCompanyDetails)}
</DetailsValue>
</DetailsCard>
<DetailsCard label={<Trans>Email reply-to</Trans>}>
<DetailsValue>{textValue(settings.emailReplyTo)}</DetailsValue>
<DetailsValue>{textValue(settings.emailReplyTo, inheritedSettings?.emailReplyTo)}</DetailsValue>
</DetailsCard>
{isTeam && parsedEmailSettings.success && (
@@ -145,7 +192,7 @@ export const AdminGlobalSettingsSection = ({ settings, isTeam = false }: AdminGl
)}
<DetailsCard label={<Trans>AI features</Trans>}>
<DetailsValue>{booleanValue(settings.aiFeaturesEnabled)}</DetailsValue>
<DetailsValue>{booleanValue(settings.aiFeaturesEnabled, inheritedSettings?.aiFeaturesEnabled)}</DetailsValue>
</DetailsCard>
</div>
);
@@ -1,6 +1,7 @@
import { authClient } from '@documenso/auth/client';
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
import { AppError } from '@documenso/lib/errors/app-error';
import { ZNameSchema } from '@documenso/lib/types/name';
import { env } from '@documenso/lib/utils/env';
import { zEmail } from '@documenso/lib/utils/zod';
import { ZPasswordSchema } from '@documenso/trpc/server/auth-router/schema';
@@ -19,7 +20,6 @@ import { useRef } from 'react';
import { useForm } from 'react-hook-form';
import { useNavigate } from 'react-router';
import { z } from 'zod';
import { SIGNUP_ERROR_MESSAGES } from '~/components/forms/signup';
export type ClaimAccountProps = {
@@ -30,7 +30,7 @@ export type ClaimAccountProps = {
export const ZClaimAccountFormSchema = z
.object({
name: z.string().trim().min(1, { message: msg`Please enter a valid name.`.id }),
name: ZNameSchema,
email: zEmail().min(1),
password: ZPasswordSchema,
})
@@ -1,11 +1,4 @@
import {
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { FormControl, FormField, FormItem, FormLabel, FormMessage } from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { Trans, useLingui } from '@lingui/react/macro';
import type { ReactNode } from 'react';
@@ -13,6 +6,13 @@ import type { Control, FieldValues, Path } from 'react-hook-form';
import { RateLimitArrayInput } from './rate-limit-array-input';
/**
* The rate-limit editor renders its own per-row inline errors, but a submit
* attempt can still surface array-level Zod issues (e.g. a committed duplicate
* window). Rendering the field's message here guarantees the form never fails
* silently when those errors are not tied to a row the editor is showing.
*/
type ClaimLimitFieldsProps<T extends FieldValues> = {
control: Control<T>;
/** e.g. '' for the claim form, 'claims.' for the org admin form. */
@@ -20,6 +20,12 @@ type ClaimLimitFieldsProps<T extends FieldValues> = {
disabled?: boolean;
};
type LimitGroup = {
title: ReactNode;
quotaKey: string;
rateLimitKey: string;
};
export const ClaimLimitFields = <T extends FieldValues>({
control,
prefix = '',
@@ -30,13 +36,33 @@ export const ClaimLimitFields = <T extends FieldValues>({
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const name = (key: string) => `${prefix}${key}` as Path<T>;
const 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
control={control}
name={name(key)}
name={name(group.quotaKey)}
render={({ field }) => (
<FormItem>
<FormLabel>{label}</FormLabel>
<FormLabel className="text-muted-foreground text-xs">
<Trans>Monthly quota</Trans>
</FormLabel>
<FormControl>
<Input
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))}
/>
</FormControl>
<FormDescription>{description}</FormDescription>
<FormMessage />
</FormItem>
)}
/>
);
const renderRateLimitField = (key: string, label: ReactNode) => (
const renderRateLimitField = (group: LimitGroup) => (
<FormField
control={control}
name={name(key)}
name={name(group.rateLimitKey)}
render={({ field }) => (
<FormItem>
<FormLabel>{label}</FormLabel>
<FormControl>
<RateLimitArrayInput value={field.value ?? []} onChange={field.onChange} disabled={disabled} />
</FormControl>
@@ -71,27 +95,30 @@ export const ClaimLimitFields = <T extends FieldValues>({
);
return (
<div className="space-y-4 rounded-md border p-4">
<FormLabel>
<Trans>Limits</Trans>
</FormLabel>
<div className="space-y-3">
<div>
<h3 className="font-semibold text-base">
<Trans>Limits</Trans>
</h3>
<p className="mt-1 text-muted-foreground text-sm">
<Trans>
Empty quota means unlimited, 0 blocks the resource. Rate limit windows accept values like 5m, 1h or 24h.
</Trans>
</p>
</div>
{renderQuotaField(
'documentQuota',
<Trans>Monthly document quota</Trans>,
<Trans>Empty = Unlimited, 0 = Blocked</Trans>,
)}
{renderRateLimitField('documentRateLimits', <Trans>Document rate limits</Trans>)}
<div className="overflow-hidden rounded-lg border">
<div className="grid grid-cols-1 divide-y divide-border md:grid-cols-3 md:divide-x md:divide-y-0">
{limitGroups.map((group) => (
<div key={group.quotaKey} className="space-y-4 p-4">
<h4 className="font-semibold text-sm">{group.title}</h4>
{renderQuotaField(
'emailQuota',
<Trans>Monthly email quota</Trans>,
<Trans>Empty = Unlimited, 0 = Blocked</Trans>,
)}
{renderRateLimitField('emailRateLimits', <Trans>Email rate limits</Trans>)}
{renderQuotaField('apiQuota', <Trans>Monthly API quota</Trans>, <Trans>Empty = Unlimited, 0 = Blocked</Trans>)}
{renderRateLimitField('apiRateLimits', <Trans>API rate limits</Trans>)}
{renderQuotaField(group)}
{renderRateLimitField(group)}
</div>
))}
</div>
</div>
</div>
);
};
@@ -1,3 +1,4 @@
import { toSafeHref } from '@documenso/lib/utils/is-http-url';
import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import { Popover, PopoverContent, PopoverTrigger } from '@documenso/ui/primitives/popover';
@@ -53,7 +54,7 @@ export const DocumentSigningAttachmentsPopover = ({
{attachments?.data.map((attachment) => (
<a
key={attachment.id}
href={attachment.data}
href={toSafeHref(attachment.data)}
title={attachment.data}
target="_blank"
rel="noopener noreferrer"
@@ -1,5 +1,6 @@
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
import { AppError } from '@documenso/lib/errors/app-error';
import { isHttpUrl, toSafeHref } from '@documenso/lib/utils/is-http-url';
import { trpc } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
@@ -24,7 +25,7 @@ export type DocumentAttachmentsPopoverProps = {
const ZAttachmentFormSchema = z.object({
label: z.string().min(1, 'Label is required'),
url: z.string().url('Must be a valid URL'),
url: z.string().url('Must be a valid URL').refine(isHttpUrl, 'URL must use the http or https protocol'),
});
type TAttachmentFormSchema = z.infer<typeof ZAttachmentFormSchema>;
@@ -156,7 +157,7 @@ export const DocumentAttachmentsPopover = ({
<div className="min-w-0 flex-1">
<p className="truncate font-medium text-sm">{attachment.label}</p>
<a
href={attachment.data}
href={toSafeHref(attachment.data)}
target="_blank"
rel="noopener noreferrer"
className="truncate text-muted-foreground text-xs underline hover:text-foreground"
@@ -1,5 +1,6 @@
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
import { nanoid } from '@documenso/lib/universal/id';
import { isHttpUrl, toSafeHref } from '@documenso/lib/utils/is-http-url';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import { Form, FormControl, FormField, FormItem, FormMessage } from '@documenso/ui/primitives/form/form';
@@ -22,7 +23,7 @@ export type EmbeddedEditorAttachmentPopoverProps = {
const ZAttachmentFormSchema = z.object({
label: z.string().min(1, 'Label is required'),
url: z.string().url('Must be a valid URL'),
url: z.string().url('Must be a valid URL').refine(isHttpUrl, 'URL must use the http or https protocol'),
});
type TAttachmentFormSchema = z.infer<typeof ZAttachmentFormSchema>;
@@ -117,7 +118,7 @@ export const EmbeddedEditorAttachmentPopover = ({
<div className="min-w-0 flex-1">
<p className="truncate font-medium text-sm">{attachment.label}</p>
<a
href={attachment.data}
href={toSafeHref(attachment.data)}
target="_blank"
rel="noopener noreferrer"
className="truncate text-muted-foreground text-xs underline hover:text-foreground"
@@ -49,6 +49,13 @@ export const EnvelopeEditorFieldsPageRenderer = ({ pageData }: { pageData: PageR
const [isFieldChanging, setIsFieldChanging] = useState(false);
const [pendingFieldCreation, setPendingFieldCreation] = useState<Konva.Rect | null>(null);
/**
* Whether the field was automatically selected on creation (drag-drop or marquee).
*
* We purposefully supress the floating toolbar for newly created fields.
*/
const [isAutoSelectedField, setIsAutoSelectedField] = useState(false);
const { stage, pageLayer, konvaContainer, scaledViewport, unscaledViewport } = usePageRenderer(
({ stage, pageLayer }) => createPageCanvas(stage, pageLayer),
pageData,
@@ -237,10 +244,26 @@ export const EnvelopeEditorFieldsPageRenderer = ({ pageData }: { pageData: PageR
fieldGroup.off('transformend');
fieldGroup.off('dragend');
// Set up field selection.
fieldGroup.on('click', () => {
// Set up field selection. Shift + click toggles this field in/out of the current
// multi-selection, so fields can be added to a group by clicking them --
// complementing marquee drag-selection. A plain click (no modifier) selects just
// this field.
fieldGroup.on('click', (event) => {
removePendingField();
setSelectedFields([fieldGroup]);
const isMultiSelectModifier = event.evt.shiftKey;
if (isMultiSelectModifier) {
const currentNodes = interactiveTransformer.current?.nodes() ?? [];
const isAlreadySelected = currentNodes.includes(fieldGroup);
setSelectedFields(
isAlreadySelected ? currentNodes.filter((node) => node !== fieldGroup) : [...currentNodes, fieldGroup],
);
} else {
setSelectedFields([fieldGroup]);
}
pageLayer.current?.batchDraw();
});
@@ -445,43 +468,18 @@ export const EnvelopeEditorFieldsPageRenderer = ({ pageData }: { pageData: PageR
}
});
// Clicks should select/deselect shapes
// Clicking empty stage area clears the selection. Field clicks -- including
// Shift+click multi-select -- are handled by each field group's own click
// handler in `unsafeRenderFieldOnLayer`.
currentStage.on('click tap', (e) => {
// if we are selecting with rect, do nothing
// If we are selecting with the marquee rectangle, do nothing.
if (selectionRectangle.visible() && selectionRectangle.width() > 0 && selectionRectangle.height() > 0) {
return;
}
// If empty area clicked, remove all selections
// If empty area clicked, remove all selections.
if (e.target === stage.current) {
setSelectedFields([]);
return;
}
// Do nothing if field not clicked, or if field is not editable
if (!e.target.hasName('field-group') || e.target.draggable() === false) {
return;
}
// do we pressed shift or ctrl?
const metaPressed = e.evt.shiftKey || e.evt.ctrlKey || e.evt.metaKey;
const isSelected = transformer.nodes().indexOf(e.target) >= 0;
if (!metaPressed && !isSelected) {
// if no key pressed and the node is not selected
// select just one
setSelectedFields([e.target]);
} else if (metaPressed && isSelected) {
// if we pressed keys and node was selected
// we need to remove it from selection:
const nodes = transformer.nodes().slice(); // use slice to have new copy of array
// remove node from array
nodes.splice(nodes.indexOf(e.target), 1);
setSelectedFields(nodes);
} else if (metaPressed && !isSelected) {
// add the node into selection
const nodes = transformer.nodes().concat([e.target]);
setSelectedFields(nodes);
}
});
@@ -521,13 +519,48 @@ export const EnvelopeEditorFieldsPageRenderer = ({ pageData }: { pageData: PageR
setSelectedFields(liveSelectedFieldGroups);
}
// Mirror the editor's single selected field onto the canvas (Konva) selection.
//
// `addField` already marks a newly created field as the selected field, so this
// makes a field placed via the palette (drag-drop) or marquee creation show its
// resize handles immediately -- no second click needed. It also clears the canvas
// selection when the selected field is cleared (e.g. when the author starts
// placing another field), so the floating action toolbar can't intercept the next
// placement click. Runs after the render loop above so the field's group exists.
const selectedFormId = editorFields.selectedField?.formId ?? null;
const isSingleCanvasSelection = selectedKonvaFieldGroups.length === 1;
if (selectedFormId && localPageFields.some((field) => field.formId === selectedFormId)) {
const isAlreadySelected = isSingleCanvasSelection && selectedKonvaFieldGroups[0].id() === selectedFormId;
if (!isAlreadySelected) {
const fieldGroupToSelect = pageLayer.current.findOne(`#${selectedFormId}`);
if (fieldGroupToSelect instanceof Konva.Group) {
setSelectedFields([fieldGroupToSelect], { isAutoSelect: true });
}
}
} else if (selectedFormId === null && isSingleCanvasSelection) {
setSelectedFields([]);
}
// Rerender the transformer
interactiveTransformer.current?.forceUpdate();
pageLayer.current.batchDraw();
}, [localPageFields, selectedKonvaFieldGroups, overlappingFieldFormIds, isFieldChanging]);
}, [
localPageFields,
selectedKonvaFieldGroups,
overlappingFieldFormIds,
isFieldChanging,
editorFields.selectedField?.formId,
]);
const setSelectedFields = (nodes: Konva.Node[], options?: { isAutoSelect?: boolean }) => {
// Any explicit (user-driven) selection shows the action toolbar; only auto-selection
// on field creation suppresses it.
setIsAutoSelectedField(Boolean(options?.isAutoSelect));
const setSelectedFields = (nodes: Konva.Node[]) => {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const fieldGroups = nodes.filter(
(node) => node.hasName('field-group') && Boolean(node.getStage()) && Boolean(node.getParent()),
@@ -663,25 +696,30 @@ export const EnvelopeEditorFieldsPageRenderer = ({ pageData }: { pageData: PageR
return (
<>
{selectedKonvaFieldGroups.length > 0 && interactiveTransformer.current && !isFieldChanging && (
<FieldActionButtons
handleDuplicateSelectedFields={duplicatedSelectedFields}
handleDuplicateSelectedFieldsOnAllPages={duplicatedSelectedFieldsOnAllPages}
handleDeleteSelectedFields={deletedSelectedFields}
handleChangeRecipient={changeSelectedFieldsRecipients}
handleChangeFieldType={changeSelectedFieldsType}
selectedFieldFormId={selectedKonvaFieldGroups.map((field) => field.id())}
style={{
position: 'absolute',
top: interactiveTransformer.current.y() + interactiveTransformer.current.getClientRect().height + 5 + 'px',
left: interactiveTransformer.current.x() + interactiveTransformer.current.getClientRect().width / 2 + 'px',
transform: 'translateX(-50%)',
gap: '8px',
pointerEvents: 'auto',
zIndex: 50,
}}
/>
)}
{selectedKonvaFieldGroups.length > 0 &&
interactiveTransformer.current &&
!isFieldChanging &&
!isAutoSelectedField && (
<FieldActionButtons
handleDuplicateSelectedFields={duplicatedSelectedFields}
handleDuplicateSelectedFieldsOnAllPages={duplicatedSelectedFieldsOnAllPages}
handleDeleteSelectedFields={deletedSelectedFields}
handleChangeRecipient={changeSelectedFieldsRecipients}
handleChangeFieldType={changeSelectedFieldsType}
selectedFieldFormId={selectedKonvaFieldGroups.map((field) => field.id())}
style={{
position: 'absolute',
top:
interactiveTransformer.current.y() + interactiveTransformer.current.getClientRect().height + 5 + 'px',
left:
interactiveTransformer.current.x() + interactiveTransformer.current.getClientRect().width / 2 + 'px',
transform: 'translateX(-50%)',
gap: '8px',
pointerEvents: 'auto',
zIndex: 50,
}}
/>
)}
{pendingFieldCreation && (
<div
@@ -1,13 +1,38 @@
import { currentMonthlyPeriod } from '@documenso/lib/universal/monthly-period';
import {
getQuotaUsagePercent,
isQuotaExceeded,
isQuotaNearing,
normalizeCapacityLimit,
} from '@documenso/lib/universal/quota-usage';
import { cn } from '@documenso/ui/lib/utils';
import type { BadgeProps } from '@documenso/ui/primitives/badge';
import { Badge } from '@documenso/ui/primitives/badge';
import { Progress } from '@documenso/ui/primitives/progress';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@documenso/ui/primitives/select';
import { Trans } from '@lingui/react/macro';
import type { OrganisationClaim, OrganisationMonthlyStat } from '@prisma/client';
import { useState } from 'react';
import { match } from 'ts-pattern';
import type { LucideIcon } from 'lucide-react';
import { FileIcon, MailIcon, MailOpenIcon, PlugIcon, UsersIcon, UsersRoundIcon } from 'lucide-react';
import type { ReactNode } from 'react';
import { useId, useState } from 'react';
import { OrganisationUsageResetButton } from './organisation-usage-reset-button';
type CapacityUsage = {
members: number;
teams: number;
};
type UsageRow = {
counter: 'document' | 'email' | 'api';
label: ReactNode;
icon: LucideIcon;
used: number;
effectiveLimit: number | null;
};
type OrganisationUsagePanelProps = {
organisationId: string;
monthlyStats: Pick<
@@ -15,13 +40,151 @@ type OrganisationUsagePanelProps = {
'period' | 'documentCount' | 'emailCount' | 'apiCount' | 'emailReports'
>[];
organisationClaim: OrganisationClaim;
capacityUsage?: CapacityUsage;
};
type UsageCardState = {
status: {
label: ReactNode;
variant: NonNullable<BadgeProps['variant']>;
};
percent: number;
hasFiniteLimit: boolean;
progressClassName: string;
subtext: ReactNode;
};
type UsageCardStateOptions = {
used: number;
limit: number | null | undefined;
footnote?: ReactNode;
};
const getUsageCardState = ({ used, limit, footnote }: UsageCardStateOptions): UsageCardState => {
const percent = getQuotaUsagePercent(used, limit ?? null);
const hasFiniteLimit = Boolean(limit && limit > 0);
if (limit === null || limit === undefined) {
return {
status: { label: <Trans>Unlimited</Trans>, variant: 'neutral' },
percent,
hasFiniteLimit,
progressClassName: '',
subtext: footnote ?? null,
};
}
if (limit === 0) {
return {
status: { label: <Trans>Blocked</Trans>, variant: 'destructive' },
percent,
hasFiniteLimit,
progressClassName: '',
subtext: footnote ?? <Trans>Resource blocked</Trans>,
};
}
if (used > limit) {
return {
status: { label: <Trans>Exceeded</Trans>, variant: 'destructive' },
percent,
hasFiniteLimit,
progressClassName: '[&>div]:bg-destructive',
subtext: footnote ?? null,
};
}
if (isQuotaExceeded(limit, used)) {
return {
status: { label: <Trans>Limit reached</Trans>, variant: 'orange' },
percent,
hasFiniteLimit,
progressClassName: '[&>div]:bg-orange-500 dark:[&>div]:bg-orange-400',
subtext: footnote ?? null,
};
}
if (isQuotaNearing(limit, used)) {
return {
status: { label: <Trans>Near limit</Trans>, variant: 'warning' },
percent,
hasFiniteLimit,
progressClassName: '[&>div]:bg-yellow-500 dark:[&>div]:bg-yellow-400',
subtext: footnote ?? null,
};
}
return {
status: { label: <Trans>Within limit</Trans>, variant: 'default' },
percent,
hasFiniteLimit,
progressClassName: '',
subtext: footnote ?? null,
};
};
type UsageStatCardProps = {
label: ReactNode;
icon: LucideIcon;
used: number;
limit: number | null | undefined;
/** When true the card is a plain counter with no limit, status or progress. */
countOnly?: boolean;
footnote?: ReactNode;
action?: ReactNode;
};
const UsageStatCard = ({ label, icon: Icon, used, limit, countOnly = false, footnote, action }: UsageStatCardProps) => {
const { status, percent, hasFiniteLimit, progressClassName, subtext } = getUsageCardState({ used, limit, footnote });
return (
<div className="flex flex-col rounded-lg border bg-background p-5">
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2 font-medium text-foreground text-sm">
<Icon className="h-4 w-4 text-muted-foreground" />
<span>{label}</span>
</div>
{!countOnly && (
<Badge variant={status.variant} size="small">
{status.label}
</Badge>
)}
</div>
<div className="mt-4 flex flex-1 flex-col">
<div className="flex items-baseline justify-between gap-2">
<div className="flex items-baseline gap-1.5">
<span className="font-semibold text-3xl text-foreground tabular-nums tracking-tight">
{used.toLocaleString()}
</span>
{hasFiniteLimit ? (
<span className="text-base text-muted-foreground tabular-nums">/ {limit?.toLocaleString()}</span>
) : null}
</div>
{hasFiniteLimit ? (
<span className="font-medium text-muted-foreground text-sm tabular-nums">{percent}%</span>
) : null}
</div>
{hasFiniteLimit ? <Progress className={cn('mt-3 h-2', progressClassName)} value={percent} /> : null}
{subtext ? <p className="mt-2 text-muted-foreground text-xs">{subtext}</p> : null}
</div>
{action ? <div className="mt-4 flex justify-end border-t pt-4">{action}</div> : null}
</div>
);
};
export const OrganisationUsagePanel = ({
organisationId,
monthlyStats,
organisationClaim,
capacityUsage,
}: OrganisationUsagePanelProps) => {
const monthlyUsagePeriodId = useId();
const [selectedPeriod, setSelectedPeriod] = useState<string | undefined>(() => monthlyStats[0]?.period);
const selectedStat = monthlyStats.find((stat) => stat.period === selectedPeriod) ?? monthlyStats[0];
@@ -30,86 +193,105 @@ export const OrganisationUsagePanel = ({
// current period), so only offer the reset action when viewing the current month.
const isCurrentPeriod = selectedStat?.period === currentMonthlyPeriod();
const rows = [
const capacityRows = capacityUsage
? [
{
key: 'members',
label: <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>,
icon: FileIcon,
used: selectedStat?.documentCount ?? 0,
effectiveLimit: organisationClaim.documentQuota,
},
{
counter: 'email' as const,
counter: 'email',
label: <Trans>Emails</Trans>,
icon: MailIcon,
used: selectedStat?.emailCount ?? 0,
effectiveLimit: organisationClaim.emailQuota,
},
{
counter: 'api' as const,
counter: 'api',
label: <Trans>API requests</Trans>,
icon: PlugIcon,
used: selectedStat?.apiCount ?? 0,
effectiveLimit: organisationClaim.apiQuota,
},
];
return (
<div className="space-y-4 rounded-md border p-4">
<div className="flex items-center justify-between gap-2">
<h3 className="font-medium text-sm">
<Trans>Usage for period: {selectedStat?.period || 'N/A'}</Trans>
</h3>
<div className="mt-4 space-y-6">
{capacityRows.length > 0 ? (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-3">
{capacityRows.map((row) => (
<UsageStatCard key={row.key} label={row.label} icon={row.icon} used={row.used} limit={row.limit} />
))}
</div>
) : null}
{monthlyStats.length > 0 && (
<Select value={selectedStat?.period} onValueChange={setSelectedPeriod}>
<SelectTrigger className="w-40">
<SelectValue />
</SelectTrigger>
<SelectContent>
{monthlyStats.map((stat) => (
<SelectItem key={stat.period} value={stat.period}>
{stat.period}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
<div className="space-y-3">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<h3 id={monthlyUsagePeriodId} className="font-semibold text-base">
<Trans>Monthly usage</Trans>
</h3>
{rows.map((row) => {
const percent =
row.effectiveLimit && row.effectiveLimit > 0
? Math.min(100, Math.round((row.used / row.effectiveLimit) * 100))
: 0;
{monthlyStats.length > 0 ? (
<Select value={selectedStat?.period} onValueChange={setSelectedPeriod}>
<SelectTrigger className="h-9 w-full sm:w-44" aria-labelledby={monthlyUsagePeriodId}>
<SelectValue />
</SelectTrigger>
<SelectContent>
{monthlyStats.map((stat) => (
<SelectItem key={stat.period} value={stat.period}>
{stat.period}
</SelectItem>
))}
</SelectContent>
</Select>
) : null}
</div>
return (
<div key={row.counter} className="space-y-1">
<div className="flex items-center justify-between text-sm">
<span>{row.label}</span>
<span className="text-muted-foreground">
{row.used} /{' '}
{match(row.effectiveLimit)
.with(null, () => <Trans>Unlimited</Trans>)
.with(0, () => <Trans>Blocked</Trans>)
.otherwise(String)}
</span>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-3">
{monthlyRows.map((row) => (
<UsageStatCard
key={row.counter}
label={row.label}
icon={row.icon}
used={row.used}
limit={row.effectiveLimit}
action={
selectedStat && isCurrentPeriod ? (
<OrganisationUsageResetButton organisationId={organisationId} counter={row.counter} />
) : undefined
}
/>
))}
{row.effectiveLimit && row.effectiveLimit > 0 ? <Progress className="h-2 w-full" value={percent} /> : null}
{selectedStat && isCurrentPeriod && (
<div className="flex w-full justify-end pt-1">
<OrganisationUsageResetButton organisationId={organisationId} counter={row.counter} />
</div>
)}
</div>
);
})}
<div className="space-y-1">
<div className="flex items-center justify-between text-sm">
<span>
<Trans>Reports</Trans>
</span>
<span className="text-muted-foreground">{selectedStat?.emailReports ?? 0}</span>
<UsageStatCard
label={<Trans>Reports</Trans>}
icon={MailOpenIcon}
used={selectedStat?.emailReports ?? 0}
limit={null}
countOnly
footnote={<Trans>Sent this period</Trans>}
/>
</div>
</div>
</div>
@@ -2,6 +2,7 @@ import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { Trans, useLingui } from '@lingui/react/macro';
import { RotateCcwIcon } from 'lucide-react';
import { useRevalidator } from 'react-router';
type OrganisationUsageResetButtonProps = {
@@ -32,6 +33,7 @@ export const OrganisationUsageResetButton = ({ organisationId, counter }: Organi
loading={isPending}
onClick={() => reset({ organisationId, counter })}
>
<RotateCcwIcon className="mr-2 h-3.5 w-3.5" />
<Trans>Reset</Trans>
</Button>
);
@@ -1,7 +1,9 @@
import { RATE_LIMIT_WINDOW_REGEX } from '@documenso/lib/types/subscription';
import { Button } from '@documenso/ui/primitives/button';
import { Input } from '@documenso/ui/primitives/input';
import { Trans } from '@lingui/react/macro';
import { Trans, useLingui } from '@lingui/react/macro';
import { PlusIcon, Trash2Icon } from 'lucide-react';
import { useState } from 'react';
type RateLimitEntryValue = { window: string; max: number };
@@ -11,50 +13,153 @@ type RateLimitArrayInputProps = {
disabled?: boolean;
};
const EMPTY_ENTRY: RateLimitEntryValue = { window: '', max: 0 };
/** A row counts as "started" once either field has input; fully-empty rows are dropped on commit. */
const hasEntryInput = (entry: RateLimitEntryValue) => entry.window.trim() !== '' || entry.max > 0;
/** Keep in-progress rows; drop rows that are completely empty. */
const persistEntries = (entries: RateLimitEntryValue[]) => {
return entries.map((entry) => ({ ...entry, window: entry.window.trim() })).filter(hasEntryInput);
};
export const RateLimitArrayInput = ({ value, onChange, disabled }: RateLimitArrayInputProps) => {
const entries = value ?? [];
const { t } = useLingui();
const [draftEntry, setDraftEntry] = useState<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 next = entries.map((entry, i) => (i === index ? { ...entry, ...patch } : entry));
onChange(next);
if (index >= value.length) {
const nextDraftEntry = { ...(draftEntry ?? EMPTY_ENTRY), ...patch };
if (hasEntryInput(nextDraftEntry)) {
onChange(persistEntries([...value, nextDraftEntry]));
setDraftEntry(null);
return;
}
setDraftEntry(nextDraftEntry);
return;
}
const next = value.map((entry, i) => (i === index ? { ...entry, ...patch } : entry));
onChange(persistEntries(next));
};
const removeEntry = (index: number) => {
onChange(entries.filter((_, i) => i !== index));
if (index >= value.length) {
setDraftEntry(null);
return;
}
const next = value.filter((_, i) => i !== index);
onChange(persistEntries(next));
};
const addEntry = () => {
onChange([...entries, { window: '5m', max: 100 }]);
setDraftEntry(EMPTY_ENTRY);
};
const hasErrors = entries.some((entry, index) => getWindowError(entry, index) || getMaxError(entry));
const isAddDisabled = disabled || value.length === 0 || Boolean(draftEntry) || hasErrors;
return (
<div className="space-y-2">
{entries.map((entry, index) => (
<div key={index} className="flex items-center gap-2">
<Input
className="w-24"
placeholder="5m"
value={entry.window}
disabled={disabled}
onChange={(e) => updateEntry(index, { window: e.target.value })}
/>
<Input
className="w-32"
type="number"
min={1}
value={entry.max}
disabled={disabled}
onChange={(e) => updateEntry(index, { max: parseInt(e.target.value, 10) || 0 })}
/>
<Button type="button" variant="ghost" size="sm" disabled={disabled} onClick={() => removeEntry(index)}>
<Trash2Icon className="h-4 w-4" />
</Button>
</div>
))}
<div className="flex items-center gap-2 text-muted-foreground text-xs">
<span className="w-20 shrink-0">
<Trans>Window</Trans>
</span>
<span className="flex-1">
<Trans>Max requests</Trans>
</span>
<span className="w-9 shrink-0" aria-hidden="true" />
</div>
<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" />
<Trans>Add rate limit</Trans>
<Trans>Add rate limit window</Trans>
</Button>
</div>
);
@@ -1,3 +1,4 @@
import { ZNameSchema } from '@documenso/lib/types/name';
import { trpc } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
@@ -29,7 +30,7 @@ export type SettingsSecurityPasskeyTableActionsProps = {
};
const ZUpdatePasskeySchema = z.object({
name: z.string(),
name: ZNameSchema,
});
type TUpdatePasskeySchema = z.infer<typeof ZUpdatePasskeySchema>;
@@ -8,6 +8,7 @@ import { getHighestOrganisationRoleInGroup } from '@documenso/lib/utils/organisa
import { trpc } from '@documenso/trpc/react';
import type { TGetAdminOrganisationResponse } from '@documenso/trpc/server/admin-router/get-admin-organisation.types';
import { ZUpdateAdminOrganisationRequestSchema } from '@documenso/trpc/server/admin-router/update-admin-organisation.types';
import { cn } from '@documenso/ui/lib/utils';
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@documenso/ui/primitives/accordion';
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
import { Badge } from '@documenso/ui/primitives/badge';
@@ -30,7 +31,7 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { Trans, useLingui } from '@lingui/react/macro';
import { OrganisationMemberRole } from '@prisma/client';
import { OrganisationMemberRole, SubscriptionStatus } from '@prisma/client';
import { ExternalLinkIcon, InfoIcon, Loader } from 'lucide-react';
import { useMemo } from 'react';
import { useForm } from 'react-hook-form';
@@ -42,7 +43,6 @@ import { AdminOrganisationDeleteDialog } from '~/components/dialogs/admin-organi
import { AdminOrganisationMemberDeleteDialog } from '~/components/dialogs/admin-organisation-member-delete-dialog';
import { AdminOrganisationMemberUpdateDialog } from '~/components/dialogs/admin-organisation-member-update-dialog';
import { AdminOrganisationSyncSubscriptionDialog } from '~/components/dialogs/admin-organisation-sync-subscription-dialog';
import { DetailsCard, DetailsValue } from '~/components/general/admin-details';
import { AdminGlobalSettingsSection } from '~/components/general/admin-global-settings-section';
import { ClaimLimitFields } from '~/components/general/claim-limit-fields';
import { GenericErrorLayout } from '~/components/general/generic-error-layout';
@@ -268,54 +268,32 @@ export default function OrganisationGroupSettingsPage({ params, loaderData }: Ro
<GenericOrganisationAdminForm organisation={organisation} />
<div className="mt-6 rounded-lg border p-4">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<p className="font-medium text-sm">
<Trans>Organisation usage</Trans>
</p>
<p className="mt-1 text-muted-foreground text-sm">
<Trans>Current usage against organisation limits.</Trans>
</p>
</div>
</div>
<SettingsHeader
title={t`Organisation usage`}
subtitle={t`Current usage against organisation limits.`}
className="mt-6"
hideDivider
/>
<div className="mt-4 grid grid-cols-1 gap-3 text-sm sm:grid-cols-2">
<DetailsCard label={<Trans>Members</Trans>}>
<DetailsValue>
{organisation.members.length} /{' '}
{organisation.organisationClaim.memberCount === 0
? t`Unlimited`
: organisation.organisationClaim.memberCount}
</DetailsValue>
</DetailsCard>
<DetailsCard label={<Trans>Teams</Trans>}>
<DetailsValue>
{organisation.teams.length} /{' '}
{organisation.organisationClaim.teamCount === 0 ? t`Unlimited` : organisation.organisationClaim.teamCount}
</DetailsValue>
</DetailsCard>
</div>
<div className="mt-4">
<OrganisationUsagePanel
organisationId={organisation.id}
monthlyStats={organisation.monthlyStats}
organisationClaim={organisation.organisationClaim}
/>
</div>
</div>
<OrganisationUsagePanel
organisationId={organisation.id}
monthlyStats={organisation.monthlyStats}
organisationClaim={organisation.organisationClaim}
capacityUsage={{
members: organisation.members.length,
teams: organisation.teams.length,
}}
/>
<div className="mt-6 rounded-lg border p-4">
<Accordion type="single" collapsible>
<AccordionItem value="global-settings" className="border-b-0">
<AccordionTrigger className="py-0">
<div className="text-left">
<p className="font-medium text-sm">
<p className="font-semibold text-base">
<Trans>Global Settings</Trans>
</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>
</p>
</div>
@@ -335,7 +313,15 @@ export default function OrganisationGroupSettingsPage({ params, loaderData }: Ro
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">
<AlertTitle>
<Trans>Subscription</Trans>
@@ -343,7 +329,12 @@ export default function OrganisationGroupSettingsPage({ params, loaderData }: Ro
<AlertDescription className="mr-2">
{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>
<Trans>No subscription found</Trans>
@@ -356,6 +347,7 @@ export default function OrganisationGroupSettingsPage({ params, loaderData }: Ro
<div>
<Button
variant="outline"
className="bg-background"
loading={isCreatingStripeCustomer}
onClick={async () => createStripeCustomer({ organisationId })}
>
@@ -366,7 +358,7 @@ export default function OrganisationGroupSettingsPage({ params, loaderData }: Ro
{organisation.customerId && !organisation.subscription && (
<div>
<Button variant="outline" asChild>
<Button variant="outline" className="bg-background" asChild>
<Link
target="_blank"
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
organisationId={organisationId}
trigger={
<Button variant="outline">
<Button variant="outline" className="bg-background">
<Trans>Sync Stripe subscription</Trans>
</Button>
}
/>
<Button variant="outline" asChild>
<Button variant="outline" className="bg-background" asChild>
<Link
target="_blank"
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>
<label className="font-medium text-sm leading-none">
<h3 className="font-semibold text-base">
<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} />
</div>
</div>
<div>
<label className="font-medium text-sm leading-none">
<h3 className="font-semibold text-base">
<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} />
</div>
</div>
@@ -648,7 +646,7 @@ const OrganisationAdminForm = ({ organisation, licenseFlags }: OrganisationAdmin
<FormLabel className="flex items-center">
<Trans>Inherited subscription claim</Trans>
<Tooltip>
<TooltipTrigger>
<TooltipTrigger type="button">
<InfoIcon className="mx-2 h-4 w-4" />
</TooltipTrigger>
@@ -681,10 +679,15 @@ const OrganisationAdminForm = ({ organisation, licenseFlags }: OrganisationAdmin
</TooltipContent>
</Tooltip>
</FormLabel>
<FormControl>
<Input disabled {...field} />
</FormControl>
<FormMessage />
<div className="rounded-lg border bg-muted/40 px-3 py-2.5 text-sm">
{field.value ? (
<span className="font-mono text-foreground">{field.value}</span>
) : (
<span className="text-muted-foreground">
<Trans>No inherited claim</Trans>
</span>
)}
</div>
</FormItem>
)}
/>
@@ -715,108 +718,113 @@ const OrganisationAdminForm = ({ organisation, licenseFlags }: OrganisationAdmin
)}
/>
<FormField
control={form.control}
name="claims.teamCount"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Team Count</Trans>
</FormLabel>
<FormControl>
<Input
type="number"
min={0}
{...field}
onChange={(e) => field.onChange(parseInt(e.target.value, 10) || 0)}
/>
</FormControl>
<FormDescription>
<Trans>Number of teams allowed. 0 = Unlimited</Trans>
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<FormField
control={form.control}
name="claims.teamCount"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Team Count</Trans>
</FormLabel>
<FormControl>
<Input
type="number"
min={0}
{...field}
onChange={(e) => field.onChange(parseInt(e.target.value, 10) || 0)}
/>
</FormControl>
<FormDescription>
<Trans>Number of teams allowed. 0 = Unlimited</Trans>
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="claims.memberCount"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Member Count</Trans>
</FormLabel>
<FormControl>
<Input
type="number"
min={0}
{...field}
onChange={(e) => field.onChange(parseInt(e.target.value, 10) || 0)}
/>
</FormControl>
<FormDescription>
<Trans>Number of members allowed. 0 = Unlimited</Trans>
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="claims.memberCount"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Member Count</Trans>
</FormLabel>
<FormControl>
<Input
type="number"
min={0}
{...field}
onChange={(e) => field.onChange(parseInt(e.target.value, 10) || 0)}
/>
</FormControl>
<FormDescription>
<Trans>Number of members allowed. 0 = Unlimited</Trans>
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="claims.envelopeItemCount"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Envelope Item Count</Trans>
</FormLabel>
<FormControl>
<Input
type="number"
min={1}
{...field}
onChange={(e) => field.onChange(parseInt(e.target.value, 10) || 0)}
/>
</FormControl>
<FormDescription>
<Trans>Maximum number of uploaded files per envelope allowed</Trans>
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="claims.envelopeItemCount"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Envelope Item Count</Trans>
</FormLabel>
<FormControl>
<Input
type="number"
min={1}
{...field}
onChange={(e) => field.onChange(parseInt(e.target.value, 10) || 0)}
/>
</FormControl>
<FormDescription>
<Trans>Maximum number of uploaded files per envelope allowed</Trans>
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="claims.recipientCount"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Recipient Count</Trans>
</FormLabel>
<FormControl>
<Input
type="number"
min={0}
{...field}
onChange={(e) => field.onChange(parseInt(e.target.value, 10) || 0)}
/>
</FormControl>
<FormDescription>
<Trans>Maximum number of recipients per document allowed. 0 = Unlimited</Trans>
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="claims.recipientCount"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Recipient Count</Trans>
</FormLabel>
<FormControl>
<Input
type="number"
min={0}
{...field}
onChange={(e) => field.onChange(parseInt(e.target.value, 10) || 0)}
/>
</FormControl>
<FormDescription>
<Trans>Maximum number of recipients per document allowed. 0 = Unlimited</Trans>
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
<div>
<FormLabel>
<h3 className="font-semibold text-base">
<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 }) => {
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>
<AccordionContent>
<div className="mt-4">
<AdminGlobalSettingsSection settings={team.teamGlobalSettings} isTeam />
<AdminGlobalSettingsSection
settings={team.teamGlobalSettings}
inheritedSettings={team.organisation.organisationGlobalSettings}
isTeam
/>
</div>
</AccordionContent>
</AccordionItem>
@@ -1,7 +1,6 @@
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
import { putFile } from '@documenso/lib/universal/upload/put-file';
import { canExecuteOrganisationAction, isPersonalLayout } from '@documenso/lib/utils/organisations';
import type { SanitizeBrandingCssWarning } from '@documenso/lib/utils/sanitize-branding-css';
import { trpc } from '@documenso/trpc/react';
@@ -49,26 +48,29 @@ export default function OrganisationSettingsBrandingPage() {
const { mutateAsync: updateOrganisationSettings } = trpc.organisation.settings.update.useMutation();
const { mutateAsync: updateOrganisationBrandingLogo } = trpc.organisation.settings.updateBrandingLogo.useMutation();
const onBrandingPreferencesFormSubmit = async (data: TBrandingPreferencesFormSchema) => {
try {
const { brandingEnabled, brandingLogo, brandingUrl, brandingCompanyDetails, brandingColors, brandingCss } = data;
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) {
uploadedBrandingLogo = JSON.stringify(await putFile(brandingLogo));
}
formData.append('payload', JSON.stringify({ organisationId: organisation.id }));
// Empty the branding logo if the user unsets it.
if (brandingLogo === null) {
uploadedBrandingLogo = '';
if (brandingLogo instanceof File) {
formData.append('brandingLogo', brandingLogo);
}
await updateOrganisationBrandingLogo(formData);
}
const result = await updateOrganisationSettings({
organisationId: organisation.id,
data: {
brandingEnabled: brandingEnabled ?? undefined,
brandingLogo: uploadedBrandingLogo,
brandingUrl,
brandingCompanyDetails,
brandingColors,
@@ -104,6 +106,9 @@ export default function OrganisationSettingsBrandingPage() {
description: t`We were unable to update your branding preferences at this time, please try again later`,
variant: 'destructive',
});
// Rethrow so the form knows the save failed and keeps the unsaved changes.
throw err;
}
};
@@ -105,6 +105,8 @@ export default function OrganisationSettingsDocumentPage() {
description: t`We were unable to update your document preferences at this time, please try again later`,
variant: 'destructive',
});
throw err;
}
};
@@ -49,6 +49,8 @@ export default function OrganisationSettingsGeneral() {
description: t`We were unable to update your email preferences at this time, please try again later`,
variant: 'destructive',
});
throw err;
}
};
@@ -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 { TEAM_MEMBER_ROLE_MAP } from '@documenso/lib/constants/teams-translations';
import { AppError } from '@documenso/lib/errors/app-error';
import { ZNameSchema } from '@documenso/lib/types/name';
import { trpc } from '@documenso/trpc/react';
import type { TFindOrganisationGroupsResponse } from '@documenso/trpc/server/organisation-router/find-organisation-groups.types';
import { Button } from '@documenso/ui/primitives/button';
@@ -28,7 +29,6 @@ import { useMemo, useState } from 'react';
import { useForm } from 'react-hook-form';
import { Link } from 'react-router';
import { z } from 'zod';
import { OrganisationGroupDeleteDialog } from '~/components/dialogs/organisation-group-delete-dialog';
import { GenericErrorLayout } from '~/components/general/generic-error-layout';
import {
@@ -36,7 +36,6 @@ import {
OrganisationMembersMultiSelectCombobox,
} from '~/components/general/organisation-members-multiselect-combobox';
import { SettingsHeader } from '~/components/general/settings-header';
import type { Route } from './+types/o.$orgUrl.settings.groups.$id';
export default function OrganisationGroupSettingsPage({ params }: Route.ComponentProps) {
@@ -113,7 +112,7 @@ export default function OrganisationGroupSettingsPage({ params }: Route.Componen
}
const ZUpdateOrganisationGroupFormSchema = z.object({
name: z.string().min(1, msg`Name is required`.id),
name: ZNameSchema,
organisationRole: z.nativeEnum(OrganisationMemberRole),
memberIds: z.array(z.string()),
});
@@ -1,14 +1,16 @@
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
import { putFile } from '@documenso/lib/universal/upload/put-file';
import { canExecuteOrganisationAction } from '@documenso/lib/utils/organisations';
import type { SanitizeBrandingCssWarning } from '@documenso/lib/utils/sanitize-branding-css';
import { trpc } from '@documenso/trpc/react';
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { plural } from '@lingui/core/macro';
import { Trans, useLingui } from '@lingui/react/macro';
import { Loader } from 'lucide-react';
import { useState } from 'react';
import { Link } from 'react-router';
import {
BrandingPreferencesForm,
@@ -35,6 +37,9 @@ export default function TeamsSettingsPage() {
});
const { mutateAsync: updateTeamSettings } = trpc.team.settings.update.useMutation();
const { mutateAsync: updateTeamBrandingLogo } = trpc.team.settings.updateBrandingLogo.useMutation();
const canConfigureBranding = organisation.organisationClaim.flags.allowCustomBranding || !IS_BILLING_ENABLED();
const canCustomBranding =
organisation.organisationClaim.flags.embedSigningWhiteLabel === true || !IS_BILLING_ENABLED();
@@ -43,22 +48,23 @@ export default function TeamsSettingsPage() {
try {
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) {
uploadedBrandingLogo = JSON.stringify(await putFile(brandingLogo));
}
formData.append('payload', JSON.stringify({ teamId: team.id }));
// Empty the branding logo if the user unsets it.
if (brandingLogo === null) {
uploadedBrandingLogo = '';
if (brandingLogo instanceof File) {
formData.append('brandingLogo', brandingLogo);
}
await updateTeamBrandingLogo(formData);
}
const result = await updateTeamSettings({
teamId: team.id,
data: {
brandingEnabled,
brandingLogo: uploadedBrandingLogo,
brandingUrl: brandingUrl || null,
brandingCompanyDetails: brandingCompanyDetails || null,
brandingColors,
@@ -94,6 +100,9 @@ export default function TeamsSettingsPage() {
description: t`We were unable to update your branding preferences at this time, please try again later`,
variant: 'destructive',
});
// Rethrow so the form knows the save failed and keeps the unsaved changes.
throw err;
}
};
@@ -112,39 +121,61 @@ export default function TeamsSettingsPage() {
subtitle={t`Here you can set preferences and defaults for branding.`}
/>
<section>
<BrandingPreferencesForm
canInherit={true}
hasAdvancedBranding={canCustomBranding}
context="Team"
settings={teamWithSettings.teamSettings}
onFormSubmit={onBrandingPreferencesFormSubmit}
/>
{canConfigureBranding ? (
<section>
<BrandingPreferencesForm
canInherit={true}
hasAdvancedBranding={canCustomBranding}
context="Team"
settings={teamWithSettings.teamSettings}
onFormSubmit={onBrandingPreferencesFormSubmit}
/>
{cssWarnings.length > 0 && (
<Alert variant="warning" className="mt-6">
{cssWarnings.length > 0 && (
<Alert variant="warning" className="mt-6">
<AlertTitle>
<Trans>CSS rules were dropped during sanitisation</Trans>
</AlertTitle>
<AlertDescription>
<ul className="list-disc pl-5">
{cssWarnings.map((warning, index) => (
<li key={index}>
{warning.detail}
{warning.line !== undefined && (
<span className="text-muted-foreground">
{' '}
<Trans>(line {warning.line})</Trans>
</span>
)}
</li>
))}
</ul>
</AlertDescription>
</Alert>
)}
</section>
) : (
<Alert className="mt-8 flex flex-col justify-between p-6 sm:flex-row sm:items-center" variant="neutral">
<div className="mb-4 sm:mb-0">
<AlertTitle>
<Trans>CSS rules were dropped during sanitisation</Trans>
<Trans>Branding Preferences</Trans>
</AlertTitle>
<AlertDescription>
<ul className="list-disc pl-5">
{cssWarnings.map((warning, index) => (
<li key={index}>
{warning.detail}
{warning.line !== undefined && (
<span className="text-muted-foreground">
{' '}
<Trans>(line {warning.line})</Trans>
</span>
)}
</li>
))}
</ul>
<AlertDescription className="mr-2">
<Trans>Currently branding can only be configured for Teams and above plans.</Trans>
</AlertDescription>
</Alert>
)}
</section>
</div>
{canExecuteOrganisationAction('MANAGE_BILLING', organisation.currentOrganisationRole) && (
<Button asChild variant="outline">
<Link to={`/o/${organisation.url}/settings/billing`}>
<Trans>Update Billing</Trans>
</Link>
</Button>
)}
</Alert>
)}
</div>
);
}
@@ -96,6 +96,8 @@ export default function TeamsSettingsPage() {
description: t`We were unable to update your document preferences at this time, please try again later`,
variant: 'destructive',
});
throw err;
}
};
@@ -49,6 +49,8 @@ export default function TeamEmailSettingsGeneral() {
description: t`We were unable to update your email preferences at this time, please try again later`,
variant: 'destructive',
});
throw err;
}
};
@@ -1,6 +1,7 @@
import { isSigninEnabledForProvider } from '@documenso/lib/constants/auth';
import { msg } from '@lingui/core/macro';
import { Trans } from '@lingui/react/macro';
import { Link } from 'react-router';
import { Link, redirect } from 'react-router';
import { ForgotPasswordForm } from '~/components/forms/forgot-password';
import { appMetaTags } from '~/utils/meta';
@@ -9,6 +10,14 @@ export function meta() {
return appMetaTags(msg`Forgot Password`);
}
export async function loader() {
if (!isSigninEnabledForProvider('email')) {
throw redirect('/signin');
}
return null;
}
export default function ForgotPasswordPage() {
return (
<div className="w-screen max-w-lg px-4">
@@ -1,3 +1,4 @@
import { isSigninEnabledForProvider } from '@documenso/lib/constants/auth';
import { getResetTokenValidity } from '@documenso/lib/server-only/user/get-reset-token-validity';
import { msg } from '@lingui/core/macro';
import { Trans } from '@lingui/react/macro';
@@ -13,6 +14,10 @@ export function meta() {
}
export async function loader({ params }: Route.LoaderArgs) {
if (!isSigninEnabledForProvider('email')) {
throw redirect('/signin');
}
const { token } = params;
const isValid = await getResetTokenValidity({ token });
@@ -1,7 +1,8 @@
import { isSigninEnabledForProvider } from '@documenso/lib/constants/auth';
import { Button } from '@documenso/ui/primitives/button';
import { msg } from '@lingui/core/macro';
import { Trans } from '@lingui/react/macro';
import { Link } from 'react-router';
import { Link, redirect } from 'react-router';
import { appMetaTags } from '~/utils/meta';
@@ -9,6 +10,14 @@ export function meta() {
return appMetaTags(msg`Reset Password`);
}
export async function loader() {
if (!isSigninEnabledForProvider('email')) {
throw redirect('/signin');
}
return null;
}
export default function ResetPasswordPage() {
return (
<div className="w-screen max-w-lg px-4">
@@ -1,8 +1,11 @@
import { authClient } from '@documenso/auth/client';
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
import {
IS_GOOGLE_SSO_ENABLED,
IS_MICROSOFT_SSO_ENABLED,
IS_OIDC_AUTO_REDIRECT_DISABLED,
IS_OIDC_SSO_ENABLED,
isSigninEnabledForProvider,
isSignupEnabledForProvider,
OIDC_PROVIDER_LABEL,
} from '@documenso/lib/constants/auth';
@@ -11,6 +14,7 @@ import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { Loader2Icon } from 'lucide-react';
import { useEffect, useState } from 'react';
import { Link, redirect, useSearchParams } from 'react-router';
@@ -28,10 +32,20 @@ export async function loader({ request }: Route.LoaderArgs) {
const { isAuthenticated } = await getOptionalSession(request);
// SSR env variables.
const isGoogleSSOEnabled = IS_GOOGLE_SSO_ENABLED;
const isMicrosoftSSOEnabled = IS_MICROSOFT_SSO_ENABLED;
const isOIDCSSOEnabled = IS_OIDC_SSO_ENABLED;
const isEmailPasswordSigninEnabled = isSigninEnabledForProvider('email');
const isGoogleSSOEnabled = IS_GOOGLE_SSO_ENABLED && isSigninEnabledForProvider('google');
const isMicrosoftSSOEnabled = IS_MICROSOFT_SSO_ENABLED && isSigninEnabledForProvider('microsoft');
const isOIDCSSOEnabled = IS_OIDC_SSO_ENABLED && isSigninEnabledForProvider('oidc');
// Automatically redirect to OIDC when it is the only enabled signin transport,
// unless the redirect has been explicitly disabled via env.
const isOIDCOnlyTransport =
isOIDCSSOEnabled && !isEmailPasswordSigninEnabled && !isGoogleSSOEnabled && !isMicrosoftSSOEnabled;
const shouldAutoRedirectToOIDC = isOIDCOnlyTransport && !IS_OIDC_AUTO_REDIRECT_DISABLED;
const oidcProviderLabel = OIDC_PROVIDER_LABEL;
const isSignupEnabled =
isSignupEnabledForProvider('email') ||
(IS_GOOGLE_SSO_ENABLED && isSignupEnabledForProvider('google')) ||
@@ -47,18 +61,28 @@ export async function loader({ request }: Route.LoaderArgs) {
}
return {
isEmailPasswordSigninEnabled,
isGoogleSSOEnabled,
isMicrosoftSSOEnabled,
isOIDCSSOEnabled,
isSignupEnabled,
oidcProviderLabel,
returnTo,
shouldAutoRedirectToOIDC,
};
}
export default function SignIn({ loaderData }: Route.ComponentProps) {
const { isGoogleSSOEnabled, isMicrosoftSSOEnabled, isOIDCSSOEnabled, isSignupEnabled, oidcProviderLabel, returnTo } =
loaderData;
const {
isEmailPasswordSigninEnabled,
isGoogleSSOEnabled,
isMicrosoftSSOEnabled,
isOIDCSSOEnabled,
isSignupEnabled,
oidcProviderLabel,
returnTo,
shouldAutoRedirectToOIDC,
} = loaderData;
const { _ } = useLingui();
@@ -76,6 +100,27 @@ export default function SignIn({ loaderData }: Route.ComponentProps) {
setIsEmbeddedRedirect(params.get('embedded') === 'true');
}, []);
useEffect(() => {
if (!shouldAutoRedirectToOIDC) {
return;
}
void authClient.oidc.signIn({ redirectPath: returnTo ?? '/' });
}, [shouldAutoRedirectToOIDC, returnTo]);
if (shouldAutoRedirectToOIDC) {
return (
<div className="w-screen max-w-lg px-4">
<div className="flex flex-col items-center justify-center gap-y-4 py-12">
<Loader2Icon className="h-8 w-8 animate-spin text-muted-foreground" />
<p className="text-muted-foreground text-sm">
<Trans>Redirecting to {oidcProviderLabel || 'OIDC'}...</Trans>
</p>
</div>
</div>
);
}
return (
<div className="w-screen max-w-lg px-4">
<div className="z-10 rounded-xl border border-border bg-neutral-100 p-6 dark:bg-background">
@@ -95,6 +140,7 @@ export default function SignIn({ loaderData }: Route.ComponentProps) {
<hr className="-mx-6 my-4" />
<SignInForm
isEmailPasswordSigninEnabled={isEmailPasswordSigninEnabled}
isGoogleSSOEnabled={isGoogleSSOEnabled}
isMicrosoftSSOEnabled={isMicrosoftSSOEnabled}
isOIDCSSOEnabled={isOIDCSSOEnabled}
+4 -4
View File
@@ -36,8 +36,8 @@
"@lingui/react": "^5.6.0",
"@oslojs/crypto": "^1.0.1",
"@oslojs/encoding": "^1.1.0",
"@react-router/node": "^7.12.0",
"@react-router/serve": "^7.12.0",
"@react-router/node": "^7.18.1",
"@react-router/serve": "^7.18.1",
"@simplewebauthn/browser": "^13.2.2",
"@simplewebauthn/server": "^13.2.2",
"@tanstack/react-query": "5.90.10",
@@ -81,8 +81,8 @@
"@babel/preset-typescript": "^7.28.5",
"@lingui/babel-plugin-lingui-macro": "^5.6.0",
"@lingui/vite-plugin": "^5.6.0",
"@react-router/dev": "^7.12.0",
"@react-router/remix-routes-option-adapter": "^7.12.0",
"@react-router/dev": "^7.18.1",
"@react-router/remix-routes-option-adapter": "^7.18.1",
"@rollup/plugin-babel": "^6.1.0",
"@rollup/plugin-commonjs": "^28.0.9",
"@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 { 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 { putNormalizedPdfFileServerSide } from '@documenso/lib/universal/upload/put-file.server';
import { getPresignPostUrl } from '@documenso/lib/universal/upload/server-actions';
import { prisma } from '@documenso/prisma';
import { sValidator } from '@hono/standard-validator';
import type { Prisma } from '@prisma/client';
@@ -12,14 +11,11 @@ import { Hono } from 'hono';
import type { HonoEnv } from '../../router';
import { checkEnvelopeFileAccess, handleEnvelopeItemFileRequest, resolveFileUploadUserId } from './files.helpers';
import {
isAllowedUploadContentType,
type TGetPresignedPostUrlResponse,
ZGetEnvelopeItemFileDownloadRequestParamsSchema,
ZGetEnvelopeItemFileRequestParamsSchema,
ZGetEnvelopeItemFileRequestQuerySchema,
ZGetEnvelopeItemFileTokenDownloadRequestParamsSchema,
ZGetEnvelopeItemFileTokenRequestParamsSchema,
ZGetPresignedPostUrlRequestSchema,
ZUploadPdfRequestSchema,
} from './files.types';
import getEnvelopeItemPdfRoute from './routes/get-envelope-item-pdf';
@@ -61,29 +57,6 @@ export const filesRoute = new Hono<HonoEnv>()
return c.json({ error: 'Upload failed' }, 500);
}
})
.post('/presigned-post-url', sValidator('json', ZGetPresignedPostUrlRequestSchema), async (c) => {
const userId = await resolveFileUploadUserId(c);
if (!userId) {
return c.json({ error: 'Unauthorized' }, 401);
}
const { fileName, contentType } = c.req.valid('json');
if (!isAllowedUploadContentType(contentType)) {
return c.json({ error: 'Unsupported content type' }, 400);
}
try {
const { key, url } = await getPresignPostUrl(fileName, contentType, userId);
return c.json({ key, url } satisfies TGetPresignedPostUrlResponse);
} catch (err) {
console.error(err);
throw new AppError(AppErrorCode.UNKNOWN_ERROR);
}
})
.get(
'/envelope/:envelopeId/envelopeItem/:envelopeItemId',
sValidator('param', ZGetEnvelopeItemFileRequestParamsSchema),
@@ -13,27 +13,6 @@ export const ZUploadPdfResponseSchema = DocumentDataSchema.pick({
export type TUploadPdfRequest = z.infer<typeof ZUploadPdfRequestSchema>;
export type TUploadPdfResponse = z.infer<typeof ZUploadPdfResponseSchema>;
export const ALLOWED_UPLOAD_CONTENT_TYPES = ['application/pdf', 'image/jpeg', 'image/png', 'image/webp'] as const;
export const isAllowedUploadContentType = (contentType: string): boolean => {
const normalizedContentType = contentType.split(';').at(0)?.trim().toLowerCase();
return ALLOWED_UPLOAD_CONTENT_TYPES.some((allowed) => allowed === normalizedContentType);
};
export const ZGetPresignedPostUrlRequestSchema = z.object({
fileName: z.string().min(1),
contentType: z.string().min(1),
});
export const ZGetPresignedPostUrlResponseSchema = z.object({
key: z.string().min(1),
url: z.string().min(1),
});
export type TGetPresignedPostUrlRequest = z.infer<typeof ZGetPresignedPostUrlRequestSchema>;
export type TGetPresignedPostUrlResponse = z.infer<typeof ZGetPresignedPostUrlResponseSchema>;
export const ZGetEnvelopeItemFileRequestParamsSchema = z.object({
envelopeId: z.string().min(1),
envelopeItemId: z.string().min(1),
-1
View File
@@ -105,7 +105,6 @@ app.route('/api/auth', auth);
// Files route.
app.use('/api/files/upload-pdf', fileRateLimitMiddleware);
app.use('/api/files/presigned-post-url', fileRateLimitMiddleware);
app.route('/api/files', filesRoute);
// AI route.
+6
View File
@@ -64,6 +64,12 @@ services:
- NEXT_PUBLIC_DISABLE_MICROSOFT_SIGNUP=${NEXT_PUBLIC_DISABLE_MICROSOFT_SIGNUP}
- NEXT_PUBLIC_DISABLE_OIDC_SIGNUP=${NEXT_PUBLIC_DISABLE_OIDC_SIGNUP}
- NEXT_PRIVATE_ALLOWED_SIGNUP_DOMAINS=${NEXT_PRIVATE_ALLOWED_SIGNUP_DOMAINS}
- NEXT_PUBLIC_DISABLE_SIGNIN=${NEXT_PUBLIC_DISABLE_SIGNIN}
- NEXT_PUBLIC_DISABLE_EMAIL_PASSWORD_SIGNIN=${NEXT_PUBLIC_DISABLE_EMAIL_PASSWORD_SIGNIN}
- NEXT_PUBLIC_DISABLE_GOOGLE_SIGNIN=${NEXT_PUBLIC_DISABLE_GOOGLE_SIGNIN}
- NEXT_PUBLIC_DISABLE_MICROSOFT_SIGNIN=${NEXT_PUBLIC_DISABLE_MICROSOFT_SIGNIN}
- NEXT_PUBLIC_DISABLE_OIDC_SIGNIN=${NEXT_PUBLIC_DISABLE_OIDC_SIGNIN}
- NEXT_PUBLIC_DISABLE_OIDC_AUTO_REDIRECT=${NEXT_PUBLIC_DISABLE_OIDC_AUTO_REDIRECT}
- NEXT_PRIVATE_SIGNING_LOCAL_FILE_PATH=${NEXT_PRIVATE_SIGNING_LOCAL_FILE_PATH:-/opt/documenso/cert.p12}
- NEXT_PRIVATE_SIGNING_PASSPHRASE=${NEXT_PRIVATE_SIGNING_PASSPHRASE}
- NEXT_PUBLIC_USE_INTERNAL_URL_BROWSERLESS=${NEXT_PUBLIC_USE_INTERNAL_URL_BROWSERLESS}
+2052 -1268
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -87,7 +87,7 @@
"dependencies": {
"@ai-sdk/google-vertex": "3.0.81",
"@documenso/prisma": "*",
"@libpdf/core": "^0.4.0",
"@libpdf/core": "^0.4.1",
"@lingui/conf": "^5.6.0",
"@lingui/core": "^5.6.0",
"@prisma/extension-read-replicas": "^0.4.1",
@@ -526,7 +526,7 @@ test('[ADMIN]: verify organisation access after ownership change', async ({ page
// Should be able to access organisation settings
await expect(page.getByText('Organisation Settings')).toBeVisible();
await expect(page.getByLabel('Organisation Name*')).toBeVisible();
await expect(page.getByRole('button', { name: 'Update organisation' })).toBeVisible();
await expect(page.getByLabel('Organisation Name*')).toBeEnabled();
// Should have delete permissions
await expect(page.getByRole('button', { name: 'Delete' })).toBeVisible();
@@ -50,7 +50,7 @@ import type { Organisation, Team, User } from '@prisma/client';
*
* --- GLOBAL LIMIT AWARENESS ---
* apps/remix/server/router.ts applies a GLOBAL per-IP limiter to /api/v1/*:
* apiV1RateLimit = 100 requests / 1 minute (action `api.v1`, see rate-limits.ts).
* apiV1RateLimit = 1000 requests / 1 minute (action `api.v1`, see rate-limits.ts).
* Every per-org limit/quota configured here is kept FAR below that ceiling (single
* digits) and the suite runs serially so the shared-IP global bucket is never the
* thing that trips. A global-limit 429 is shaped `{ error }` whereas an org-limit
@@ -62,7 +62,7 @@ const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
const baseUrl = `${WEBAPP_BASE_URL}/api/v1`;
// Run serially: all workers share one IP, and the global /api/v1 limiter is
// per-IP. Serial execution keeps the shared global bucket well under 100/min.
// per-IP. Serial execution keeps the shared global bucket well under 1000/min.
test.describe.configure({ mode: 'serial' });
// This suite is only meaningful with real rate limiting enabled. CI sets the
@@ -125,7 +125,7 @@ const setClaimLimits = async (team: Team, limits: ClaimLimits) => {
* GLOBAL /api/v1 IP bucket so a fresh scenario starts from zero.
*
* - The org windowed limiter keys its rows `ip:org:<id>`.
* - The GLOBAL limiter (apps/remix/server/router.ts -> apiV1RateLimit, 100/min
* - The GLOBAL limiter (apps/remix/server/router.ts -> apiV1RateLimit, 1000/min
* per IP, action `api.v1`) is shared by EVERY v1 request from this test client.
* Across the suite (and especially across repeated local runs within the same
* minute) that shared bucket would otherwise fill up and trip BEFORE the org
@@ -37,7 +37,7 @@ import type { Organisation, Team, User } from '@prisma/client';
*
* --- GLOBAL LIMIT AWARENESS ---
* apps/remix/server/router.ts applies a GLOBAL per-IP limiter to /api/v2/*:
* apiV2RateLimit = 100 requests / 1 minute (see rate-limits.ts).
* apiV2RateLimit = 1000 requests / 1 minute (see rate-limits.ts).
* Every per-org limit/quota configured here is kept FAR below that ceiling (single
* digits) and the suite runs serially so the shared-IP global bucket is never the
* thing that trips. A global-limit 429 is shaped `{ error }` whereas an org-limit
@@ -49,7 +49,7 @@ const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
const baseUrl = `${WEBAPP_BASE_URL}/api/v2-beta`;
// Run serially: all workers share one IP, and the global /api/v2 limiter is
// per-IP. Serial execution keeps the shared global bucket well under 100/min.
// per-IP. Serial execution keeps the shared global bucket well under 1000/min.
test.describe.configure({ mode: 'serial' });
// This suite is only meaningful with real rate limiting enabled. CI sets the
@@ -44,46 +44,6 @@ test.describe('File upload endpoint authorization', () => {
expect(res.status()).toBe(401);
});
test('rejects an unauthenticated presigned-post-url request', async ({ request }) => {
const res = await request.post(`${WEBAPP_BASE_URL}/api/files/presigned-post-url`, {
headers: { 'Content-Type': 'application/json' },
data: { fileName: 'test.pdf', contentType: 'application/pdf' },
});
expect(res.ok()).toBeFalsy();
expect(res.status()).toBe(401);
});
test('rejects a presigned-post-url request with an invalid presign token', async ({ request }) => {
const res = await request.post(`${WEBAPP_BASE_URL}/api/files/presigned-post-url`, {
headers: {
'Content-Type': 'application/json',
Authorization: 'Bearer not-a-real-token',
},
data: { fileName: 'test.pdf', contentType: 'application/pdf' },
});
expect(res.ok()).toBeFalsy();
expect(res.status()).toBe(401);
});
test('rejects a presigned-post-url request with a disallowed content type', async ({ request }) => {
const { user, team } = await seedUser();
const presignToken = await createPresignTokenForUser(user.id, team.id);
const res = await request.post(`${WEBAPP_BASE_URL}/api/files/presigned-post-url`, {
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${presignToken}`,
},
data: { fileName: 'malware.exe', contentType: 'application/x-msdownload' },
});
// Authenticated, but the content type is not on the allow-list.
expect(res.ok()).toBeFalsy();
expect(res.status()).toBe(400);
});
test('allows an upload-pdf request authorized by a valid presign token', async ({ request }) => {
const { user, team } = await seedUser();
const presignToken = await createPresignTokenForUser(user.id, team.id);
@@ -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();
});
@@ -0,0 +1,89 @@
import { cancelDocument } from '@documenso/lib/server-only/document/cancel-document';
import { deleteDocument } from '@documenso/lib/server-only/document/delete-document';
import { getEnvelopeWhereInput } from '@documenso/lib/server-only/envelope/get-envelope-by-id';
import { prisma } from '@documenso/prisma';
import { DocumentStatus, DocumentVisibility, TeamMemberRole } from '@documenso/prisma/client';
import { seedBlankDocument } from '@documenso/prisma/seed/documents';
import { seedTeamMember } from '@documenso/prisma/seed/teams';
import { seedUser } from '@documenso/prisma/seed/users';
import { expect, test } from '@playwright/test';
const requestMetadata = {
auth: null,
requestMetadata: {},
source: 'app' as const,
};
test.describe.configure({ mode: 'parallel' });
const canReadEnvelope = async (envelopeId: string, userId: number, teamId: number) => {
try {
await getEnvelopeWhereInput({
id: { type: 'envelopeId', id: envelopeId },
userId,
teamId,
type: null,
}).then(({ envelopeWhereInput }) => prisma.envelope.findFirstOrThrow({ where: envelopeWhereInput }));
return true;
} catch {
return false;
}
};
test('[DOCUMENTS]: a member cannot delete a document with restricted visibility', async () => {
const { user: owner, team } = await seedUser();
const member = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.MEMBER });
const envelope = await seedBlankDocument(owner, team.id, {
createDocumentOptions: {
visibility: DocumentVisibility.ADMIN,
status: DocumentStatus.DRAFT,
},
});
// The member cannot read an ADMIN-visibility document, so they must not be
// able to delete it either.
expect(await canReadEnvelope(envelope.id, member.id, team.id)).toBe(false);
await expect(
deleteDocument({
id: { type: 'envelopeId', id: envelope.id },
userId: member.id,
teamId: team.id,
requestMetadata,
}),
).rejects.toThrow();
const stillExists = await prisma.envelope.findUnique({ where: { id: envelope.id } });
expect(stillExists).not.toBeNull();
});
test('[DOCUMENTS]: a manager cannot cancel a document with restricted visibility', async () => {
const { user: owner, team } = await seedUser();
const manager = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.MANAGER });
const envelope = await seedBlankDocument(owner, team.id, {
createDocumentOptions: {
visibility: DocumentVisibility.ADMIN,
status: DocumentStatus.PENDING,
},
});
// A manager outranks a member but still cannot read an ADMIN-visibility
// document, so cancellation must be blocked despite the sufficient role.
expect(await canReadEnvelope(envelope.id, manager.id, team.id)).toBe(false);
await expect(
cancelDocument({
id: { type: 'envelopeId', id: envelope.id },
userId: manager.id,
teamId: team.id,
reason: 'test-cancel',
requestMetadata,
}),
).rejects.toThrow();
const after = await prisma.envelope.findUnique({ where: { id: envelope.id } });
expect(after?.status).toBe(DocumentStatus.PENDING);
});
@@ -0,0 +1,70 @@
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { hashString } from '@documenso/lib/server-only/auth/hash';
import { alphaid } from '@documenso/lib/universal/id';
import { prisma } from '@documenso/prisma';
import { seedBlankDocument } from '@documenso/prisma/seed/documents';
import { seedTeam } from '@documenso/prisma/seed/teams';
import { expect, test } from '@playwright/test';
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
const API_BASE_URL = `${WEBAPP_BASE_URL}/api/v2-beta`;
test.describe.configure({ mode: 'parallel' });
const seedApiTokenForUser = async ({ userId, teamId }: { userId: number; teamId: number }) => {
const token = `api_${alphaid(16)}`;
await prisma.apiToken.create({
data: { name: 'attachment-url-test', token: hashString(token), expires: null, userId, teamId },
});
return { token };
};
/**
* Attachment URLs are rendered as link hrefs, so they must be restricted to
* http(s). The API must reject any other scheme.
*/
const NON_HTTP_URLS = [
'javascript:alert(document.cookie)',
'data:text/html,<script>alert(1)</script>',
'vbscript:msgbox(1)',
'file:///etc/passwd',
];
test('[ATTACHMENTS]: rejects attachment URLs with a non-http(s) protocol', async ({ request }) => {
const { team, owner } = await seedTeam();
const { token } = await seedApiTokenForUser({ userId: owner.id, teamId: team.id });
const envelope = await seedBlankDocument(owner, team.id);
for (const url of NON_HTTP_URLS) {
const res = await request.post(`${API_BASE_URL}/envelope/attachment/create`, {
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
data: { envelopeId: envelope.id, data: { label: 'attachment', data: url } },
});
expect(res.ok(), `expected ${url} to be rejected`).toBe(false);
}
const attachments = await prisma.envelopeAttachment.findMany({ where: { envelopeId: envelope.id } });
expect(attachments).toHaveLength(0);
});
test('[ATTACHMENTS]: accepts attachment URLs with an http(s) protocol', async ({ request }) => {
const { team, owner } = await seedTeam();
const { token } = await seedApiTokenForUser({ userId: owner.id, teamId: team.id });
const envelope = await seedBlankDocument(owner, team.id);
const res = await request.post(`${API_BASE_URL}/envelope/attachment/create`, {
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
data: { envelopeId: envelope.id, data: { label: 'safe', data: 'https://example.com/file.pdf' } },
});
expect(res.ok()).toBe(true);
const attachments = await prisma.envelopeAttachment.findMany({ where: { envelopeId: envelope.id } });
expect(attachments).toHaveLength(1);
expect(attachments[0].data).toBe('https://example.com/file.pdf');
});
@@ -0,0 +1,121 @@
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { hashString } from '@documenso/lib/server-only/auth/hash';
import { alphaid } from '@documenso/lib/universal/id';
import { prisma } from '@documenso/prisma';
import { DocumentVisibility, TeamMemberRole } from '@documenso/prisma/client';
import { seedBlankDocument } from '@documenso/prisma/seed/documents';
import { seedTeam, seedTeamMember } from '@documenso/prisma/seed/teams';
import { type APIRequestContext, expect, test } from '@playwright/test';
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
const API_BASE_URL = `${WEBAPP_BASE_URL}/api/v2-beta`;
test.describe.configure({ mode: 'parallel' });
const seedApiTokenForUser = async ({ userId, teamId }: { userId: number; teamId: number }) => {
const token = `api_${alphaid(16)}`;
await prisma.apiToken.create({
data: { name: 'attachment-access-test', token: hashString(token), expires: null, userId, teamId },
});
return { token };
};
const canReadEnvelope = async (request: APIRequestContext, token: string, envelopeId: string) => {
const res = await request.get(`${API_BASE_URL}/envelope/${envelopeId}`, {
headers: { Authorization: `Bearer ${token}` },
});
return res.ok();
};
/**
* Attachment create/update/delete/list must enforce document visibility, not
* just team membership. A member whose visibility tier excludes a restricted
* envelope must not be able to read or mutate its attachments.
*/
test('[ATTACHMENTS]: a member cannot create or delete attachments on a restricted document', async ({ request }) => {
const { team, owner } = await seedTeam();
const member = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.MEMBER });
const { token: memberToken } = await seedApiTokenForUser({ userId: member.id, teamId: team.id });
const envelope = await seedBlankDocument(owner, team.id, {
createDocumentOptions: { visibility: DocumentVisibility.ADMIN },
});
expect(await canReadEnvelope(request, memberToken, envelope.id)).toBe(false);
const createRes = await request.post(`${API_BASE_URL}/envelope/attachment/create`, {
headers: { Authorization: `Bearer ${memberToken}`, 'Content-Type': 'application/json' },
data: { envelopeId: envelope.id, data: { label: 'attachment', data: 'https://example.com' } },
});
expect(createRes.ok()).toBe(false);
// No attachment should have been created.
const attachments = await prisma.envelopeAttachment.findMany({ where: { envelopeId: envelope.id } });
expect(attachments).toHaveLength(0);
});
test('[ATTACHMENTS]: a member cannot update an attachment on a restricted document', async ({ request }) => {
const { team, owner } = await seedTeam();
const member = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.MEMBER });
const { token: ownerToken } = await seedApiTokenForUser({ userId: owner.id, teamId: team.id });
const { token: memberToken } = await seedApiTokenForUser({ userId: member.id, teamId: team.id });
const envelope = await seedBlankDocument(owner, team.id, {
createDocumentOptions: { visibility: DocumentVisibility.ADMIN },
});
// The owner (who can see the document) creates the attachment.
const createRes = await request.post(`${API_BASE_URL}/envelope/attachment/create`, {
headers: { Authorization: `Bearer ${ownerToken}`, 'Content-Type': 'application/json' },
data: { envelopeId: envelope.id, data: { label: 'original', data: 'https://example.com/original' } },
});
expect(createRes.ok()).toBe(true);
const attachment = await createRes.json();
expect(await canReadEnvelope(request, memberToken, envelope.id)).toBe(false);
const updateRes = await request.post(`${API_BASE_URL}/envelope/attachment/update`, {
headers: { Authorization: `Bearer ${memberToken}`, 'Content-Type': 'application/json' },
data: { id: attachment.id, data: { label: 'tampered', data: 'https://example.com/tampered' } },
});
expect(updateRes.ok()).toBe(false);
const persisted = await prisma.envelopeAttachment.findUnique({ where: { id: attachment.id } });
expect(persisted?.label).toBe('original');
});
test('[ATTACHMENTS]: a member cannot list attachments on a restricted document', async ({ request }) => {
const { team, owner } = await seedTeam();
const member = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.MEMBER });
const { token: ownerToken } = await seedApiTokenForUser({ userId: owner.id, teamId: team.id });
const { token: memberToken } = await seedApiTokenForUser({ userId: member.id, teamId: team.id });
const envelope = await seedBlankDocument(owner, team.id, {
createDocumentOptions: { visibility: DocumentVisibility.ADMIN },
});
await request.post(`${API_BASE_URL}/envelope/attachment/create`, {
headers: { Authorization: `Bearer ${ownerToken}`, 'Content-Type': 'application/json' },
data: { envelopeId: envelope.id, data: { label: 'restricted', data: 'https://example.com/restricted' } },
});
expect(await canReadEnvelope(request, memberToken, envelope.id)).toBe(false);
const findRes = await request.get(`${API_BASE_URL}/envelope/attachment?envelopeId=${envelope.id}`, {
headers: { Authorization: `Bearer ${memberToken}` },
});
expect(findRes.ok()).toBe(false);
const body = findRes.ok() ? await findRes.json() : null;
const attachments = body?.data ?? [];
expect(attachments).toHaveLength(0);
});
@@ -20,7 +20,7 @@ import {
type TEnvelopeEditorSurface,
} from '../fixtures/envelope-editor';
import { expectToastTextToBeVisible } from '../fixtures/generic';
import { getKonvaElementCountForPage } from '../fixtures/konva';
import { getKonvaElementCountForPage, getKonvaTransformerNodeCountForPage } from '../fixtures/konva';
type TFieldFlowResult = {
externalId: string;
@@ -46,6 +46,7 @@ const updateExternalId = async (surface: TEnvelopeEditorSurface, externalId: str
if (!surface.isEmbedded) {
await expectToastTextToBeVisible(surface.root, 'Envelope updated');
await surface.root.getByTestId('toast-close').click();
}
};
@@ -98,6 +99,17 @@ const selectFieldOnCanvas = async (root: Page, position: { x: number; y: number
await canvas.click({ position, force: true });
};
/**
* Shift+click a field on the canvas to toggle it in/out of the current multi-selection.
*/
const shiftClickFieldOnCanvas = async (root: Page, position: { x: number; y: number }) => {
const canvas = root.locator('.konva-container canvas').first();
await expect(canvas).toBeVisible();
await root.waitForTimeout(300);
// Use force:true to bypass any floating action toolbar buttons that may intercept clicks.
await canvas.click({ position, modifiers: ['Shift'], force: true });
};
const runAddAndPersistSignatureTextFields = async (surface: TEnvelopeEditorSurface): Promise<TFieldFlowResult> => {
const externalId = `e2e-fields-${nanoid()}`;
@@ -760,9 +772,106 @@ const assertChangeFieldTypePersistedInDatabase = async ({
expect(actualMetaTypes).toEqual(['date', 'date']);
};
// --- Shift+click multi-select flow ---
type TShiftClickFlowResult = {
externalId: string;
};
const SHIFT_CLICK_FIELD_POSITIONS = {
signature: { x: 150, y: 120 },
text: { x: 150, y: 260 },
name: { x: 150, y: 400 },
};
const runShiftClickMultiSelectFlow = async (surface: TEnvelopeEditorSurface): Promise<TShiftClickFlowResult> => {
const externalId = `e2e-shift-click-${nanoid()}`;
const root = surface.root;
if (surface.isEmbedded && !surface.envelopeId) {
await addEnvelopeItemPdf(root, 'embedded-fields.pdf');
}
await updateExternalId(surface, externalId);
await setupRecipientsForFieldPlacement(surface);
await clickEnvelopeEditorStep(root, 'addFields');
await expect(root.locator('.konva-container canvas').first()).toBeVisible();
// Place three fields, spaced far enough apart that their action toolbars don't
// overlap a neighbouring field's click target.
await placeFieldOnPdf(root, 'Signature', SHIFT_CLICK_FIELD_POSITIONS.signature);
await placeFieldOnPdf(root, 'Text', SHIFT_CLICK_FIELD_POSITIONS.text);
await placeFieldOnPdf(root, 'Name', SHIFT_CLICK_FIELD_POSITIONS.name);
expect(await getKonvaElementCountForPage(root, 1, '.field-group')).toBe(3);
// A plain click selects exactly one field.
await selectFieldOnCanvas(root, SHIFT_CLICK_FIELD_POSITIONS.signature);
await expect.poll(() => getKonvaTransformerNodeCountForPage(root, 1)).toBe(1);
// Shift+click a second field ADDS it to the selection (the new behaviour).
await shiftClickFieldOnCanvas(root, SHIFT_CLICK_FIELD_POSITIONS.text);
await expect.poll(() => getKonvaTransformerNodeCountForPage(root, 1)).toBe(2);
// Shift+click an already-selected field REMOVES it from the selection.
await shiftClickFieldOnCanvas(root, SHIFT_CLICK_FIELD_POSITIONS.signature);
await expect.poll(() => getKonvaTransformerNodeCountForPage(root, 1)).toBe(1);
// Shift+click it again RE-ADDS it, leaving Signature + Text selected and Name excluded.
await shiftClickFieldOnCanvas(root, SHIFT_CLICK_FIELD_POSITIONS.signature);
await expect.poll(() => getKonvaTransformerNodeCountForPage(root, 1)).toBe(2);
// Delete the two selected fields via the floating action toolbar. Only the
// un-selected Name field should remain -- proving the multi-selection contained
// exactly the two Shift-clicked fields.
await expect(root.locator('button[title="Remove"]')).toBeVisible();
await root.locator('button[title="Remove"]').click();
expect(await getKonvaElementCountForPage(root, 1, '.field-group')).toBe(1);
// Navigate away and back to verify persistence.
await clickEnvelopeEditorStep(root, 'upload');
await clickEnvelopeEditorStep(root, 'addFields');
expect(await getKonvaElementCountForPage(root, 1, '.field-group')).toBe(1);
return { externalId };
};
const assertShiftClickMultiSelectPersistedInDatabase = async ({
surface,
externalId,
}: {
surface: TEnvelopeEditorSurface;
externalId: string;
}) => {
const envelope = await prisma.envelope.findFirstOrThrow({
where: {
externalId,
userId: surface.userId,
teamId: surface.teamId,
type: surface.envelopeType,
},
orderBy: { createdAt: 'desc' },
include: { fields: true },
});
// Signature + Text were multi-selected via Shift+click and deleted; only Name remains.
expect(envelope.fields).toHaveLength(1);
expect(envelope.fields[0].type).toBe(FieldType.NAME);
};
// --- Test describe blocks ---
test.describe('document editor', () => {
test('shift+click adds and removes fields from the selection', async ({ page }) => {
const surface = await openDocumentEnvelopeEditor(page);
const result = await runShiftClickMultiSelectFlow(surface);
await assertShiftClickMultiSelectPersistedInDatabase({
surface,
...result,
});
});
test('add and persist signature/text fields', async ({ page }) => {
const surface = await openDocumentEnvelopeEditor(page);
const result = await runAddAndPersistSignatureTextFields(surface);
@@ -815,6 +924,16 @@ test.describe('document editor', () => {
});
test.describe('template editor', () => {
test('shift+click adds and removes fields from the selection', async ({ page }) => {
const surface = await openTemplateEnvelopeEditor(page);
const result = await runShiftClickMultiSelectFlow(surface);
await assertShiftClickMultiSelectPersistedInDatabase({
surface,
...result,
});
});
test('add and persist signature/text fields', async ({ page }) => {
const surface = await openTemplateEnvelopeEditor(page);
const result = await runAddAndPersistSignatureTextFields(surface);
@@ -867,6 +986,21 @@ test.describe('template editor', () => {
});
test.describe('embedded create', () => {
test('shift+click adds and removes fields from the selection', async ({ page }) => {
const surface = await openEmbeddedEnvelopeEditor(page, {
envelopeType: 'DOCUMENT',
tokenNamePrefix: 'e2e-embed-shift-click',
});
const result = await runShiftClickMultiSelectFlow(surface);
await persistEmbeddedEnvelope(surface);
await assertShiftClickMultiSelectPersistedInDatabase({
surface,
...result,
});
});
test('add and persist signature/text fields', async ({ page }) => {
const surface = await openEmbeddedEnvelopeEditor(page, {
envelopeType: 'DOCUMENT',
@@ -944,6 +1078,22 @@ test.describe('embedded create', () => {
});
test.describe('embedded edit', () => {
test('shift+click adds and removes fields from the selection', async ({ page }) => {
const surface = await openEmbeddedEnvelopeEditor(page, {
envelopeType: 'TEMPLATE',
mode: 'edit',
tokenNamePrefix: 'e2e-embed-shift-click',
});
const result = await runShiftClickMultiSelectFlow(surface);
await persistEmbeddedEnvelope(surface);
await assertShiftClickMultiSelectPersistedInDatabase({
surface,
...result,
});
});
test('add and persist signature/text fields', async ({ page }) => {
const surface = await openEmbeddedEnvelopeEditor(page, {
envelopeType: 'TEMPLATE',
@@ -19,7 +19,7 @@ test('[ENVELOPE_EXPIRATION]: set custom expiration period at organisation level'
});
// Wait for the form to load.
await expect(page.getByRole('button', { name: 'Update' }).first()).toBeVisible();
await expect(page.getByTestId('document-language-trigger')).toBeVisible();
// Change the amount to 2.
const amountInput = page.getByTestId('envelope-expiration-amount');
@@ -35,7 +35,7 @@ test('[ENVELOPE_EXPIRATION]: set custom expiration period at organisation level'
await unitTrigger.click();
await page.getByRole('option', { name: 'Weeks' }).click();
await page.getByRole('button', { name: 'Update' }).first().click();
await page.getByRole('button', { name: 'Save changes' }).first().click();
await expect(page.getByText('Your document preferences have been updated').first()).toBeVisible();
// Verify via database.
@@ -57,14 +57,14 @@ test('[ENVELOPE_EXPIRATION]: disable expiration at organisation level', async ({
redirectPath: `/o/${organisation.url}/settings/document`,
});
await expect(page.getByRole('button', { name: 'Update' }).first()).toBeVisible();
await expect(page.getByTestId('document-language-trigger')).toBeVisible();
// Find the mode select (shows "Custom duration") and change to "Never expires".
const modeTrigger = page.getByTestId('envelope-expiration-mode');
await modeTrigger.click();
await page.getByRole('option', { name: 'Never expires' }).click();
await page.getByRole('button', { name: 'Update' }).first().click();
await page.getByRole('button', { name: 'Save changes' }).first().click();
await expect(page.getByText('Your document preferences have been updated').first()).toBeVisible();
// Verify via database.
@@ -109,7 +109,7 @@ test('[ENVELOPE_EXPIRATION]: team overrides organisation expiration', async ({ p
redirectPath: `/t/${team.url}/settings/document`,
});
await expect(page.getByRole('button', { name: 'Update' }).first()).toBeVisible();
await expect(page.getByTestId('document-language-trigger')).toBeVisible();
// The expiration picker mode select should show "Inherit from organisation" by default.
const modeTrigger = page.getByTestId('envelope-expiration-mode');
@@ -128,7 +128,7 @@ test('[ENVELOPE_EXPIRATION]: team overrides organisation expiration', async ({ p
await unitTrigger.click();
await page.getByRole('option', { name: 'Days' }).click();
await page.getByRole('button', { name: 'Update' }).first().click();
await page.getByRole('button', { name: 'Save changes' }).first().click();
await expect(page.getByText('Your document preferences have been updated').first()).toBeVisible();
// Verify team setting is overridden.
@@ -324,10 +324,7 @@ test.describe('Signing Certificate Tests', () => {
.click();
await page.getByRole('option', { name: 'No' }).click();
await page
.getByRole('button', { name: /Update/ })
.first()
.click();
await page.getByRole('button', { name: 'Save changes' }).first().click();
await page.waitForTimeout(1000);
@@ -347,10 +344,7 @@ test.describe('Signing Certificate Tests', () => {
.getByRole('combobox')
.click();
await page.getByRole('option', { name: 'Yes' }).click();
await page
.getByRole('button', { name: /Update/ })
.first()
.click();
await page.getByRole('button', { name: 'Save changes' }).first().click();
await page.waitForTimeout(1000);
+32
View File
@@ -16,3 +16,35 @@ export const getKonvaElementCountForPage = async (page: Page, pageNumber: number
{ pageNumber, elementSelector },
);
};
/**
* Returns how many field groups are currently attached to the page's Konva
* transformer, i.e. the size of the active canvas selection. Used to assert
* multi-select behaviour (marquee drag and Shift+click).
*/
export const getKonvaTransformerNodeCountForPage = async (page: Page, pageNumber: number) => {
await page.locator('.konva-container canvas').first().waitFor({ state: 'visible' });
return await page.evaluate(
({ pageNumber }) => {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const konva: typeof Konva = (window as unknown as { Konva: typeof Konva }).Konva;
const stage = konva.stages.find((stage) => stage.attrs.id === `page-${pageNumber}`);
if (!stage) {
return 0;
}
const transformer = stage.find('Transformer')[0];
if (!transformer) {
return 0;
}
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
return (transformer as Konva.Transformer).nodes().length;
},
{ pageNumber },
);
};
@@ -60,7 +60,7 @@ test('[ORGANISATIONS]: manage general settings', async ({ page }) => {
await page.getByLabel('Organisation URL*').clear();
await page.getByLabel('Organisation URL*').fill(updatedOrganisationId);
await page.getByRole('button', { name: 'Update organisation' }).click();
await page.getByRole('button', { name: 'Save changes' }).click();
// Check we have been redirected to the new organisation URL and the name is updated.
await page.waitForURL(`/o/${updatedOrganisationId}/settings/general`);
@@ -340,3 +340,67 @@ test.describe('[ORGANISATION_PERMISSION_HIERARCHY]: leaving an organisation', ()
expect(deleted).toBeNull();
});
});
test.describe('[ORGANISATION_PERMISSION_HIERARCHY]: group membership scoping', () => {
test('cannot add a member from another organisation to a group', async ({ page }) => {
// Organisation A, where the actor is the owner/admin.
const { user: actor, organisation: organisationA } = await seedUser({
isPersonalOrganisation: false,
});
// A separate organisation B with a member the actor has no authority over.
const { organisation: organisationB } = await seedUser({ isPersonalOrganisation: false });
const [foreignUser] = await seedOrganisationMembers({
members: [{ name: 'Foreign', organisationRole: 'MEMBER' }],
organisationId: organisationB.id,
});
const foreignMember = await getOrganisationMember(foreignUser.id, organisationB.id);
// A custom group the actor legitimately controls in organisation A.
const groupA = await createCustomGroup(organisationA.id, 'MEMBER');
await apiSignin({ page, email: actor.email });
const res = await trpcMutation(page, 'organisation.group.update', {
id: groupA.id,
memberIds: [foreignMember.id],
});
expect(res.ok()).toBeFalsy();
const injectedMembership = await prisma.organisationGroupMember.findFirst({
where: { groupId: groupA.id, organisationMemberId: foreignMember.id },
});
expect(injectedMembership).toBeNull();
});
test('can add a member from the same organisation to a group (positive control)', async ({ page }) => {
const { user: actor, organisation } = await seedUser({ isPersonalOrganisation: false });
const [memberUser] = await seedOrganisationMembers({
members: [{ name: 'Member', organisationRole: 'MEMBER' }],
organisationId: organisation.id,
});
const member = await getOrganisationMember(memberUser.id, organisation.id);
const group = await createCustomGroup(organisation.id, 'MEMBER');
await apiSignin({ page, email: actor.email });
const res = await trpcMutation(page, 'organisation.group.update', {
id: group.id,
memberIds: [member.id],
});
expect(res.ok()).toBeTruthy();
const membership = await prisma.organisationGroupMember.findFirst({
where: { groupId: group.id, organisationMemberId: member.id },
});
expect(membership).not.toBeNull();
});
});
@@ -39,7 +39,7 @@ test('[ORGANISATIONS]: manage document preferences', async ({ page }) => {
await page.getByRole('option', { name: 'No' }).click();
await page.getByTestId('include-signing-certificate-trigger').click();
await page.getByRole('option', { name: 'No' }).click();
await page.getByRole('button', { name: 'Update' }).first().click();
await page.getByRole('button', { name: 'Save changes' }).first().click();
await expect(page.getByText('Your document preferences have been updated').first()).toBeVisible();
const teamSettings = await getTeamSettings({
@@ -73,7 +73,7 @@ test('[ORGANISATIONS]: manage document preferences', async ({ page }) => {
await page.getByTestId('document-date-format-trigger').click();
await page.getByRole('option', { name: 'MM/DD/YYYY', exact: true }).click();
await page.getByRole('button', { name: 'Update' }).first().click();
await page.getByRole('button', { name: 'Save changes' }).first().click();
await expect(page.getByText('Your document preferences have been updated').first()).toBeVisible();
const updatedTeamSettings = await getTeamSettings({
@@ -128,7 +128,7 @@ test('[ORGANISATIONS]: manage branding preferences', async ({ page }) => {
await page.getByRole('textbox', { name: 'Brand Website' }).fill('https://documenso.com');
await page.getByRole('textbox', { name: 'Brand Details' }).click();
await page.getByRole('textbox', { name: 'Brand Details' }).fill('BrandDetails');
await page.getByRole('button', { name: 'Update' }).first().click();
await page.getByRole('button', { name: 'Save changes' }).first().click();
await expect(page.getByText('Your branding preferences have been updated').first()).toBeVisible();
const teamSettings = await getTeamSettings({
@@ -150,7 +150,7 @@ test('[ORGANISATIONS]: manage branding preferences', async ({ page }) => {
await page.getByRole('textbox', { name: 'Brand Website' }).fill('https://example.com');
await page.getByRole('textbox', { name: 'Brand Details' }).click();
await page.getByRole('textbox', { name: 'Brand Details' }).fill('UpdatedBrandDetails');
await page.getByRole('button', { name: 'Update' }).first().click();
await page.getByRole('button', { name: 'Save changes' }).first().click();
await expect(page.getByText('Your branding preferences have been updated').first()).toBeVisible();
const updatedTeamSettings = await getTeamSettings({
@@ -165,7 +165,7 @@ test('[ORGANISATIONS]: manage branding preferences', async ({ page }) => {
// Test inheritance by setting team back to inherit from organisation
await page.getByTestId('enable-branding').click();
await page.getByRole('option', { name: 'Inherit from organisation' }).click();
await page.getByRole('button', { name: 'Update' }).first().click();
await page.getByRole('button', { name: 'Save changes' }).first().click();
await expect(page.getByText('Your branding preferences have been updated').first()).toBeVisible();
await page.waitForTimeout(2000);
@@ -208,7 +208,7 @@ test('[ORGANISATIONS]: manage email preferences', async ({ page }) => {
await page.getByRole('checkbox', { name: 'Email the signer if the document is still pending' }).uncheck();
await page.getByRole('checkbox', { name: 'Email recipients when a pending document is deleted' }).uncheck();
await page.getByRole('button', { name: 'Update' }).first().click();
await page.getByRole('button', { name: 'Save changes' }).first().click();
await expect(page.getByText('Your email preferences have been updated').first()).toBeVisible();
const teamSettings = await getTeamSettings({
@@ -245,7 +245,7 @@ test('[ORGANISATIONS]: manage email preferences', async ({ page }) => {
await page.getByRole('checkbox', { name: 'Email recipients when the document is completed', exact: true }).uncheck();
await page.getByRole('checkbox', { name: 'Email the owner when the document is completed' }).uncheck();
await page.getByRole('button', { name: 'Update' }).first().click();
await page.getByRole('button', { name: 'Save changes' }).first().click();
await expect(page.getByText('Your email preferences have been updated').first()).toBeVisible();
const updatedTeamSettings = await getTeamSettings({
@@ -292,7 +292,7 @@ test('[ORGANISATIONS]: manage email preferences', async ({ page }) => {
await page.getByRole('textbox', { name: 'Reply to email' }).fill('');
await page.getByRole('combobox').filter({ hasText: 'Override organisation settings' }).click();
await page.getByRole('option', { name: 'Inherit from organisation' }).click();
await page.getByRole('button', { name: 'Update' }).first().click();
await page.getByRole('button', { name: 'Save changes' }).first().click();
await expect(page.getByText('Your email preferences have been updated').first()).toBeVisible();
await page.waitForTimeout(1000);
@@ -0,0 +1,72 @@
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { hashString } from '@documenso/lib/server-only/auth/hash';
import { alphaid } from '@documenso/lib/universal/id';
import { prisma } from '@documenso/prisma';
import { DocumentVisibility, TeamMemberRole } from '@documenso/prisma/client';
import { seedCompletedDocument } from '@documenso/prisma/seed/documents';
import { seedTeam, seedTeamMember } from '@documenso/prisma/seed/teams';
import { expect, test } from '@playwright/test';
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
const API_BASE_URL = `${WEBAPP_BASE_URL}/api/v2-beta`;
test.describe.configure({ mode: 'parallel' });
const seedApiTokenForUser = async ({ userId, teamId }: { userId: number; teamId: number }) => {
const token = `api_${alphaid(16)}`;
await prisma.apiToken.create({
data: { name: 'recipient-access-test', token: hashString(token), expires: null, userId, teamId },
});
return { token };
};
/**
* Reading a recipient exposes its signing token (a bearer credential), so the
* recipient read must enforce document visibility — a member who cannot read a
* restricted document must not be able to read its recipients either. This
* mirrors the field read, which is asserted as a control below.
*/
test('[RECIPIENT]: a member cannot read a recipient of a restricted document', async ({ request }) => {
const { team, owner } = await seedTeam();
const member = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.MEMBER });
const { token: memberToken } = await seedApiTokenForUser({ userId: member.id, teamId: team.id });
const document = await seedCompletedDocument(owner, team.id, ['recipient@test.documenso.com'], {
createDocumentOptions: { visibility: DocumentVisibility.ADMIN },
});
const recipient = await prisma.recipient.findFirstOrThrow({ where: { envelopeId: document.id } });
const res = await request.get(`${API_BASE_URL}/envelope/recipient/${recipient.id}`, {
headers: { Authorization: `Bearer ${memberToken}` },
});
expect(res.status()).toBe(404);
const body = res.ok() ? await res.json() : null;
expect(body?.token).toBeUndefined();
});
test('[RECIPIENT]: a member cannot read a field of a restricted document', async ({ request }) => {
const { team, owner } = await seedTeam();
const member = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.MEMBER });
const { token: memberToken } = await seedApiTokenForUser({ userId: member.id, teamId: team.id });
const document = await seedCompletedDocument(owner, team.id, ['recipient@test.documenso.com'], {
createDocumentOptions: { visibility: DocumentVisibility.ADMIN },
});
const field = await prisma.field.findFirst({ where: { envelopeId: document.id } });
test.skip(!field, 'No field seeded on completed document');
const res = await request.get(`${API_BASE_URL}/envelope/field/${field!.id}`, {
headers: { Authorization: `Bearer ${memberToken}` },
});
expect(res.status()).toBe(404);
});
@@ -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.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();
});
@@ -66,7 +66,7 @@ test('[TEAMS]: update team', async ({ page }) => {
await page.getByLabel('Team URL*').clear();
await page.getByLabel('Team URL*').fill(updatedTeamId);
await page.getByRole('button', { name: 'Update team' }).click();
await page.getByRole('button', { name: 'Save changes' }).click();
// Check we have been redirected to the new team URL and the name is updated.
await page.waitForURL(`${NEXT_PUBLIC_WEBAPP_URL()}/t/${updatedTeamId}/settings`);
@@ -0,0 +1,163 @@
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { generateDatabaseId } from '@documenso/lib/universal/id';
import { prisma } from '@documenso/prisma';
import { seedTeamMember } from '@documenso/prisma/seed/teams';
import { seedUser } from '@documenso/prisma/seed/users';
import { expect, type Page, test } from '@playwright/test';
import { OrganisationGroupType, OrganisationMemberRole, TeamMemberRole } from '@prisma/client';
import { apiSignin } from '../fixtures/authentication';
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
test.describe.configure({ mode: 'parallel' });
/**
* Calls a team-group tRPC mutation directly, bypassing the UI.
*
* The UI only ever surfaces CUSTOM / INTERNAL_ORGANISATION groups, so these
* authorisation rules must be enforced on the server - a crafted request can
* target any `teamGroupId`, including the system-managed INTERNAL_TEAM groups.
*/
const callTeamGroupMutation = (
page: Page,
procedure: 'team.group.delete' | 'team.group.update',
teamId: number,
input: Record<string, unknown>,
) =>
page.context().request.post(`${WEBAPP_BASE_URL}/api/trpc/${procedure}`, {
headers: { 'content-type': 'application/json', 'x-team-id': teamId.toString() },
data: JSON.stringify({ json: input }),
});
/**
* Every team is created with three system-managed INTERNAL_TEAM groups
* (admin/manager/member). They are the backbone of team-specific access and,
* like organisation internal groups, must not be deletable - deleting them
* silently strips team members of access while leaving the team row in place.
*/
test('[TEAMS]: internal team groups cannot be deleted via the API', async ({ page }) => {
// Member inheritance OFF: membership is granted exclusively through the team's
// INTERNAL_TEAM groups, so removing them is what causes the access loss.
const { user: owner, team } = await seedUser({ inheritMembers: false });
// A direct team member whose access depends on the INTERNAL_TEAM member group.
const directMember = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.MEMBER });
await apiSignin({ page, email: owner.email });
const internalTeamGroups = await prisma.teamGroup.findMany({
where: {
teamId: team.id,
organisationGroup: { type: OrganisationGroupType.INTERNAL_TEAM },
},
});
// admin + manager + member.
expect(internalTeamGroups).toHaveLength(3);
for (const group of internalTeamGroups) {
const response = await callTeamGroupMutation(page, 'team.group.delete', team.id, {
teamId: team.id,
teamGroupId: group.id,
});
expect(response.status(), `INTERNAL_TEAM ${group.teamRole} group must not be deletable`).not.toBe(200);
}
// None of the internal groups were removed.
const remaining = await prisma.teamGroup.count({
where: {
teamId: team.id,
organisationGroup: { type: OrganisationGroupType.INTERNAL_TEAM },
},
});
expect(remaining).toBe(3);
// The direct member therefore keeps their team access.
const memberStillHasAccess = await prisma.teamGroup.findFirst({
where: {
teamId: team.id,
organisationGroup: {
type: OrganisationGroupType.INTERNAL_TEAM,
organisationGroupMembers: {
some: { organisationMember: { userId: directMember.id } },
},
},
},
});
expect(memberStillHasAccess).not.toBeNull();
});
/**
* Guards against over-blocking: user-created (CUSTOM) team groups are not
* internal and must remain removable by team managers/admins.
*/
test('[TEAMS]: custom team groups can still be deleted', async ({ page }) => {
const { user: owner, organisation, team } = await seedUser({ inheritMembers: false });
const customGroup = await prisma.organisationGroup.create({
data: {
id: generateDatabaseId('org_group'),
name: `custom-${team.url}`,
type: OrganisationGroupType.CUSTOM,
organisationRole: OrganisationMemberRole.MEMBER,
organisationId: organisation.id,
teamGroups: {
create: {
id: generateDatabaseId('team_group'),
teamId: team.id,
teamRole: TeamMemberRole.MEMBER,
},
},
},
include: { teamGroups: true },
});
const customTeamGroup = customGroup.teamGroups[0];
await apiSignin({ page, email: owner.email });
const response = await callTeamGroupMutation(page, 'team.group.delete', team.id, {
teamId: team.id,
teamGroupId: customTeamGroup.id,
});
expect(response.status()).toBe(200);
const deleted = await prisma.teamGroup.findUnique({ where: { id: customTeamGroup.id } });
expect(deleted).toBeNull();
});
/**
* The same root cause affects updates: an INTERNAL_TEAM group's role must not be
* editable either, otherwise a team admin could rewrite the backbone roles
* (e.g. promote the member group to admin).
*/
test('[TEAMS]: internal team groups cannot be updated via the API', async ({ page }) => {
const { user: owner, team } = await seedUser({ inheritMembers: false });
await apiSignin({ page, email: owner.email });
const internalMemberGroup = await prisma.teamGroup.findFirstOrThrow({
where: {
teamId: team.id,
teamRole: TeamMemberRole.MEMBER,
organisationGroup: { type: OrganisationGroupType.INTERNAL_TEAM },
},
});
const response = await callTeamGroupMutation(page, 'team.group.update', team.id, {
id: internalMemberGroup.id,
data: { teamRole: TeamMemberRole.ADMIN },
});
expect(response.status()).not.toBe(200);
const reloaded = await prisma.teamGroup.findUniqueOrThrow({ where: { id: internalMemberGroup.id } });
expect(reloaded.teamRole).toBe(TeamMemberRole.MEMBER);
});
@@ -0,0 +1,53 @@
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { prisma } from '@documenso/prisma';
import { TeamMemberRole } from '@documenso/prisma/client';
import { seedTeam, seedTeamMember } from '@documenso/prisma/seed/teams';
import { expect, test } from '@playwright/test';
import { apiSignin } from '../fixtures/authentication';
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
test.describe.configure({ mode: 'parallel' });
/**
* Editing the team public profile is a team-management action and must require
* MANAGE_TEAM, consistent with renaming the team or changing its URL.
*/
test('[TEAMS]: a member cannot edit the team public profile', async ({ page }) => {
const { team, owner } = await seedTeam();
const member = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.MEMBER });
await apiSignin({ page, email: member.email });
const profileRes = await page.context().request.post(`${WEBAPP_BASE_URL}/api/trpc/team.update`, {
headers: { 'content-type': 'application/json', 'x-team-id': team.id.toString() },
data: JSON.stringify({
json: {
teamId: team.id,
data: { profileEnabled: true, profileBio: 'edited-by-member' },
},
}),
});
expect(profileRes.status()).not.toBe(200);
const profile = await prisma.teamProfile.findUnique({ where: { teamId: team.id } });
expect(profile?.enabled ?? false).toBe(false);
expect(profile?.bio ?? '').not.toBe('edited-by-member');
// The name/url path of the same route is also management-gated.
const nameRes = await page.context().request.post(`${WEBAPP_BASE_URL}/api/trpc/team.update`, {
headers: { 'content-type': 'application/json', 'x-team-id': team.id.toString() },
data: JSON.stringify({
json: { teamId: team.id, data: { name: 'renamed-by-member' } },
}),
});
expect(nameRes.status()).not.toBe(200);
const reloaded = await prisma.team.findUnique({ where: { id: team.id } });
expect(reloaded?.name).not.toBe('renamed-by-member');
expect(owner.id).toBeTruthy();
});
@@ -0,0 +1,79 @@
import { seedUser } from '@documenso/prisma/seed/users';
import { expect, test } from '@playwright/test';
import { apiSignin } from '../fixtures/authentication';
test('[TEAMS]: settings save bar docks at the bottom of the form', async ({ page }) => {
const { user, team } = await seedUser();
await apiSignin({
page,
email: user.email,
redirectPath: `/t/${team.url}/settings`,
});
await expect(page.getByLabel('Team Name*')).toBeVisible();
const saveButton = page.getByRole('button', { name: 'Save changes' });
// Pristine: the docked Save button is present but disabled; no Undo, no floating notice.
await expect(saveButton).toBeVisible();
await expect(saveButton).toBeDisabled();
await expect(page.getByRole('button', { name: 'Undo' })).toHaveCount(0);
await expect(page.getByText('You have unsaved changes')).not.toBeVisible();
// Make a change → Save enables and Undo appears.
const updatedName = `team-${Date.now()}`;
await page.getByLabel('Team Name*').clear();
await page.getByLabel('Team Name*').fill(updatedName);
await expect(saveButton).toBeEnabled();
await expect(page.getByRole('button', { name: 'Undo' })).toBeVisible();
// Undo → value restored, Save disabled again, Undo gone.
await page.getByRole('button', { name: 'Undo' }).click();
await expect(page.getByLabel('Team Name*')).toHaveValue(team.name);
await expect(saveButton).toBeDisabled();
await expect(page.getByRole('button', { name: 'Undo' })).toHaveCount(0);
// Change again → Save → success toast, returns to a pristine (disabled) state.
await page.getByLabel('Team Name*').clear();
await page.getByLabel('Team Name*').fill(updatedName);
await expect(saveButton).toBeEnabled();
await saveButton.click();
await expect(page.getByText('Your team has been successfully updated.').first()).toBeVisible();
await expect(saveButton).toBeDisabled();
});
test('[ORGANISATIONS]: settings save bar floats when the form footer is off-screen', async ({ page }) => {
const { user, organisation } = await seedUser({
isPersonalOrganisation: false,
});
await apiSignin({
page,
email: user.email,
redirectPath: `/o/${organisation.url}/settings/document`,
});
// Wait for the long document-preferences form to load.
await expect(page.getByTestId('document-language-trigger')).toBeVisible();
// Pristine: no floating notice even though the footer is below the fold.
await expect(page.getByText('You have unsaved changes')).not.toBeVisible();
// Edit a field near the top → the footer is off-screen, so the floating pill appears.
await page.getByTestId('document-language-trigger').click();
await page.getByRole('option', { name: 'German' }).click();
await expect(page.getByText('You have unsaved changes')).toBeVisible();
await expect(page.getByRole('button', { name: 'Save changes' })).toBeVisible();
// Scroll to the footer → the floating pill merges into the docked buttons and the
// notice disappears.
await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));
await expect(page.getByText('You have unsaved changes')).not.toBeVisible();
await expect(page.getByRole('button', { name: 'Save changes' })).toBeVisible();
});
@@ -75,7 +75,7 @@ test('[TEAMS]: check signature modes can be disabled', async ({ page }) => {
await item.click();
}
await page.getByRole('button', { name: 'Update' }).first().click();
await page.getByRole('button', { name: 'Save changes' }).first().click();
// Wait for the update to complete
await expect(page.getByText('Document preferences updated', { exact: true })).toBeVisible();
@@ -140,7 +140,7 @@ test('[TEAMS]: check signature modes work for templates', async ({ page }) => {
await item.click();
}
await page.getByRole('button', { name: 'Update' }).first().click();
await page.getByRole('button', { name: 'Save changes' }).first().click();
// Wait for finish
await expect(page.getByText('Document preferences updated', { exact: true })).toBeVisible();
@@ -17,6 +17,7 @@ export const AuthenticationErrorCode = {
// TwoFactorMissingSecret: 'TWO_FACTOR_MISSING_SECRET',
// TwoFactorMissingCredentials: 'TWO_FACTOR_MISSING_CREDENTIALS',
InvalidTwoFactorCode: 'INVALID_TWO_FACTOR_CODE',
SigninDisabled: 'SIGNIN_DISABLED',
SignupDisabled: 'SIGNUP_DISABLED',
SignupDisposableEmail: 'SIGNUP_DISPOSABLE_EMAIL',
// IncorrectTwoFactorBackupCode: 'INCORRECT_TWO_FACTOR_BACKUP_CODE',
@@ -1,6 +1,7 @@
import {
isDisposableEmail,
isEmailDomainAllowedForSignup,
isSigninEnabledForProvider,
isSignupEnabledForProvider,
} from '@documenso/lib/constants/auth';
import { EMAIL_VERIFICATION_STATE } from '@documenso/lib/constants/email';
@@ -64,6 +65,12 @@ export const emailPasswordRoute = new Hono<HonoAuthContext>()
.post('/authorize', sValidator('json', ZSignInSchema), async (c) => {
const requestMetadata = c.get('requestMetadata');
if (!isSigninEnabledForProvider('email')) {
throw new AppError(AuthenticationErrorCode.SigninDisabled, {
statusCode: 400,
});
}
const { email, password, totpCode, backupCode, csrfToken, captchaToken } = c.req.valid('json');
const loginLimitResult = await loginRateLimit.check({
@@ -244,6 +251,12 @@ export const emailPasswordRoute = new Hono<HonoAuthContext>()
const { password, currentPassword } = c.req.valid('json');
const requestMetadata = c.get('requestMetadata');
if (!isSigninEnabledForProvider('email')) {
throw new AppError(AuthenticationErrorCode.SigninDisabled, {
statusCode: 400,
});
}
const { session, user } = await getSession(c);
await updatePassword({
@@ -346,6 +359,12 @@ export const emailPasswordRoute = new Hono<HonoAuthContext>()
.post('/forgot-password', sValidator('json', ZForgotPasswordSchema), async (c) => {
const requestMetadata = c.get('requestMetadata');
if (!isSigninEnabledForProvider('email')) {
throw new AppError(AuthenticationErrorCode.SigninDisabled, {
statusCode: 400,
});
}
const { email } = c.req.valid('json');
const forgotLimitResult = await forgotPasswordRateLimit.check({
@@ -377,6 +396,12 @@ export const emailPasswordRoute = new Hono<HonoAuthContext>()
.post('/reset-password', sValidator('json', ZResetPasswordSchema), async (c) => {
const requestMetadata = c.get('requestMetadata');
if (!isSigninEnabledForProvider('email')) {
throw new AppError(AuthenticationErrorCode.SigninDisabled, {
statusCode: 400,
});
}
const { token, password } = c.req.valid('json');
const resetLimitResult = await resetPasswordRateLimit.check({
+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 { z } from 'zod';
@@ -28,7 +28,11 @@ export const TemplateDocumentCompleted = ({
<Section className="mb-4">
<Column align="center">
<Text className="font-semibold text-base text-foreground">
<Img src={getAssetUrl('/static/completed.png')} className="-mt-0.5 mr-2 inline h-7 w-7 align-middle" />
<Img
src={getAssetUrl('/static/completed.png')}
className="-mt-0.5 mr-2 inline h-7 w-7 align-middle"
alt=""
/>
<Trans>Completed</Trans>
</Text>
</Column>
@@ -47,7 +51,7 @@ export const TemplateDocumentCompleted = ({
className="rounded-lg border border-border border-solid px-4 py-2 text-center font-medium text-foreground text-sm no-underline"
href={downloadLink}
>
<Img src={getAssetUrl('/static/download.png')} className="mr-2 mb-0.5 inline h-5 w-5 align-middle" />
<Img src={getAssetUrl('/static/download.png')} className="mr-2 mb-0.5 inline h-5 w-5 align-middle" alt="" />
<Trans>Download</Trans>
</Button>
</Section>
@@ -21,7 +21,7 @@ export const TemplateDocumentPending = ({ documentName, assetBaseUrl }: Template
<Section className="mb-4">
<Column align="center">
<Text className="font-semibold text-base text-foreground">
<Img src={getAssetUrl('/static/clock.png')} className="-mt-0.5 mr-2 inline h-7 w-7 align-middle" />
<Img src={getAssetUrl('/static/clock.png')} className="-mt-0.5 mr-2 inline h-7 w-7 align-middle" alt="" />
<Trans>Waiting for others</Trans>
</Text>
</Column>
@@ -30,7 +30,11 @@ export const TemplateDocumentRecipientSigned = ({
<Section className="mb-4">
<Column align="center">
<Text className="font-semibold text-base text-foreground">
<Img src={getAssetUrl('/static/completed.png')} className="-mt-0.5 mr-2 inline h-7 w-7 align-middle" />
<Img
src={getAssetUrl('/static/completed.png')}
className="-mt-0.5 mr-2 inline h-7 w-7 align-middle"
alt=""
/>
<Trans>Completed</Trans>
</Text>
</Column>
@@ -26,7 +26,11 @@ export const TemplateDocumentSelfSigned = ({ documentName, assetBaseUrl }: Templ
<Section>
<Column align="center">
<Text className="font-semibold text-base text-foreground">
<Img src={getAssetUrl('/static/completed.png')} className="-mt-0.5 mr-2 inline h-7 w-7 align-middle" />
<Img
src={getAssetUrl('/static/completed.png')}
className="-mt-0.5 mr-2 inline h-7 w-7 align-middle"
alt=""
/>
<Trans>Completed</Trans>
</Text>
</Column>
@@ -51,7 +55,11 @@ export const TemplateDocumentSelfSigned = ({ documentName, assetBaseUrl }: Templ
href={signUpUrl}
className="mr-4 rounded-lg border border-border border-solid px-4 py-2 text-center font-medium text-foreground text-sm no-underline"
>
<Img src={getAssetUrl('/static/user-plus.png')} className="mr-2 mb-0.5 inline h-5 w-5 align-middle" />
<Img
src={getAssetUrl('/static/user-plus.png')}
className="mr-2 mb-0.5 inline h-5 w-5 align-middle"
alt=""
/>
<Trans>Create account</Trans>
</Button>
@@ -59,7 +67,7 @@ export const TemplateDocumentSelfSigned = ({ documentName, assetBaseUrl }: Templ
className="rounded-lg border border-border border-solid px-4 py-2 text-center font-medium text-foreground text-sm no-underline"
href="https://documenso.com/pricing"
>
<Img src={getAssetUrl('/static/review.png')} className="mr-2 mb-0.5 inline h-5 w-5 align-middle" />
<Img src={getAssetUrl('/static/review.png')} className="mr-2 mb-0.5 inline h-5 w-5 align-middle" alt="" />
<Trans>View plans</Trans>
</Button>
</Section>
@@ -11,7 +11,7 @@ export const TemplateImage = ({ assetBaseUrl, className, staticAsset }: Template
return new URL(path, assetBaseUrl).toString();
};
return <Img className={className} src={getAssetUrl(`/static/${staticAsset}`)} />;
return <Img className={className} src={getAssetUrl(`/static/${staticAsset}`)} alt="" />;
};
export default TemplateImage;
+2 -1
View File
@@ -30,9 +30,10 @@ export const AccessAuth2FAEmailTemplate = ({
return (
<Html>
<Head />
<Preview>{_(previewText)}</Preview>
<Body className="mx-auto my-auto bg-background font-sans">
<Preview>{_(previewText)}</Preview>
<Section>
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-border border-solid p-4 backdrop-blur-sm">
<Section>
@@ -21,8 +21,9 @@ export const AdminUserCreatedTemplate = ({
return (
<Html>
<Head />
<Preview>{_(previewText)}</Preview>
<Body className="mx-auto my-auto bg-background font-sans">
<Preview>{_(previewText)}</Preview>
<Section>
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-border border-solid p-4 backdrop-blur-sm">
<Section>
@@ -24,11 +24,14 @@ export const BulkSendCompleteEmail = ({
}: BulkSendCompleteEmailProps) => {
const { _ } = useLingui();
const previewText = msg`Bulk send operation complete for template "${templateName}"`;
return (
<Html>
<Head />
<Preview>{_(msg`Bulk send operation complete for template "${templateName}"`)}</Preview>
<Body className="mx-auto my-auto bg-background font-sans">
<Preview>{_(previewText)}</Preview>
<Section>
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-border border-solid p-4 backdrop-blur-sm">
<Section>
+2 -1
View File
@@ -18,8 +18,9 @@ export const ConfirmEmailTemplate = ({
return (
<Html>
<Head />
<Preview>{_(previewText)}</Preview>
<Body className="mx-auto my-auto bg-background font-sans">
<Preview>{_(previewText)}</Preview>
<Section>
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-border border-solid p-4 backdrop-blur-sm">
<Section>
@@ -30,9 +30,9 @@ export const ConfirmTeamEmailTemplate = ({
return (
<Html>
<Head />
<Preview>{_(previewText)}</Preview>
<Body className="mx-auto my-auto font-sans">
<Preview>{_(previewText)}</Preview>
<Section className="bg-background">
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-border border-solid px-2 pt-2 backdrop-blur-sm">
<TemplateBrandingLogo assetBaseUrl={assetBaseUrl} className="mb-4 h-6 p-2" />
+2 -1
View File
@@ -23,9 +23,10 @@ export const DocumentCancelTemplate = ({
return (
<Html>
<Head />
<Preview>{_(previewText)}</Preview>
<Body className="mx-auto my-auto bg-background font-sans">
<Preview>{_(previewText)}</Preview>
<Section>
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-border border-solid p-4 backdrop-blur-sm">
<Section>
@@ -26,9 +26,9 @@ export const DocumentCompletedEmailTemplate = ({
return (
<Html>
<Head />
<Preview>{_(previewText)}</Preview>
<Body className="mx-auto my-auto font-sans">
<Preview>{_(previewText)}</Preview>
<Section className="bg-background">
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-border border-solid p-2 backdrop-blur-sm">
<Section className="p-2">
@@ -33,9 +33,9 @@ export const DocumentCreatedFromDirectTemplateEmailTemplate = ({
return (
<Html>
<Head />
<Preview>{_(previewText)}</Preview>
<Body className="mx-auto my-auto font-sans">
<Preview>{_(previewText)}</Preview>
<Section className="bg-background">
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-border border-solid p-2 backdrop-blur-sm">
<Section className="p-2">
+3 -2
View File
@@ -56,9 +56,10 @@ export const DocumentInviteEmailTemplate = ({
return (
<Html>
<Head />
<Preview>{_(previewText)}</Preview>
<Body className="mx-auto my-auto bg-background font-sans">
<Preview>{_(previewText)}</Preview>
<Section>
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-border border-solid p-4 backdrop-blur-sm">
<Section>
@@ -85,7 +86,7 @@ export const DocumentInviteEmailTemplate = ({
<Text className="my-4 font-semibold text-base">
<Trans>
{inviterName}{' '}
<Link className="font-normal text-muted-foreground" href="mailto:{inviterEmail}">
<Link className="font-normal text-muted-foreground" href={`mailto:${inviterEmail}`}>
({inviterEmail})
</Link>
</Trans>
@@ -20,9 +20,9 @@ export const DocumentPendingEmailTemplate = ({
return (
<Html>
<Head />
<Preview>{_(previewText)}</Preview>
<Body className="mx-auto my-auto font-sans">
<Preview>{_(previewText)}</Preview>
<Section className="bg-background">
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-border border-solid p-4 backdrop-blur-sm">
<Section>

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