Compare commits

...

16 Commits

Author SHA1 Message Date
David Nguyen 93d868a389 docs: deprecate endpoints 2026-06-24 17:16:13 +10:00
Lucas Smith 187b612568 chore: add translations (#3012) 2026-06-23 15:12:23 +10:00
Lucas Smith b37529a1cf fix: show warning on overlapping fields (#3017) 2026-06-23 15:11:57 +10:00
Lucas Smith 04f6e76178 feat: cap automated reminders before resend (#3016)
Stop sending automated reminders after a configurable threshold
(default 5) and reset the count on manual resend.
2026-06-23 15:11:52 +10:00
Lucas Smith f2525ae95b feat: add API endpoint to reject documents on behalf of recipients (#3007)
Programmatically record an external rejection on behalf of a recipient
who declined outside the platform. Flags the rejection as external in
the audit log, optionally attributes it to a specific team member via
actAsEmail, and enforces team membership and document visibility.
2026-06-22 21:59:07 +10:00
David Nguyen 2f24a8eab2 fix: set send status on resend (#3011) 2026-06-22 17:00:24 +10:00
David Nguyen d9b7722325 fix: correctly use default distribute envelope tab (#3010) 2026-06-22 16:27:50 +10:00
github-actions[bot] 783123f72b chore: extract translations (#2987) 2026-06-22 16:06:57 +10:00
Lucas Smith e8ed1c3d99 fix: respect branding enabled for recipient routes (#3009) 2026-06-22 16:06:06 +10:00
David Nguyen c23d739f76 feat: allow additional envelope duplicate settings (#3008) 2026-06-22 14:41:38 +10:00
Lucas Smith 0bf58ca66e feat: add custom brand colours to emails (#3005) 2026-06-22 14:33:34 +10:00
David Nguyen dee3259088 fix: remove old dialogs (#3006) 2026-06-22 14:17:22 +10:00
Nandini Dhanrale 6ad1a2dfaf fix: signing request email renders blank when organisation/team branding is enabled (#2968) 2026-06-22 14:15:12 +10:00
Abdelrahman Abdelhamed 306e7fe5ed fix: render unicode characters in typed signatures (#2728) 2026-06-22 13:40:56 +10:00
Yash Singh 219db32fdf fix: only send S3 checksums when required to support S3-compatible storage (#2984) 2026-06-22 13:35:37 +10:00
David Nguyen 948d1bbf12 fix: improve team member removal ux (#3001) 2026-06-22 12:16:55 +10:00
164 changed files with 7777 additions and 2068 deletions
+1 -1
View File
@@ -42,8 +42,8 @@ Documenso is an open-source document signing platform built as a **monorepo** us
| Package | Description | Port |
| -------------------------- | -------------------------------------------------------- | ---- |
| `@documenso/remix` | Main application - React Router (Remix) with Hono server | 3000 |
| `@documenso/documentation` | Documentation site (Next.js + Nextra) | 3002 |
| `@documenso/openpage-api` | Public analytics API | 3003 |
| `@documenso/docs` | Documentation site | 3004 |
### Core Packages (`packages/`)
@@ -6,6 +6,8 @@ description: Create, manage, and send documents for signing via the API.
import { Callout } from 'fumadocs-ui/components/callout';
import { Tab, Tabs } from 'fumadocs-ui/components/tabs';
<EnvelopeWarning />
<Callout type="warn">
This guide may not reflect the latest endpoints or parameters. For an always up-to-date reference,
see the [OpenAPI Reference](https://openapi.documenso.com).
@@ -5,6 +5,8 @@ description: Complete reference for the Documenso REST API.
import { Callout } from 'fumadocs-ui/components/callout';
<EnvelopeWarning />
<Callout type="warn">
The guides below cover common API patterns but may not reflect the latest endpoints or parameters.
For an always up-to-date reference, see the [OpenAPI Reference](https://openapi.documenso.com).
@@ -8,6 +8,7 @@
"teams",
"rate-limits",
"versioning",
"migrate-to-envelopes",
"developer-mode",
"common-errors"
]
@@ -0,0 +1,250 @@
---
title: Migrating to Envelopes
description: Why Documenso unified documents and templates into envelopes, and how to migrate from the deprecated document and template create endpoints.
---
import { Accordion, Accordions } from 'fumadocs-ui/components/accordion';
import { Callout } from 'fumadocs-ui/components/callout';
import { Step, Steps } from 'fumadocs-ui/components/steps';
import { Tab, Tabs } from 'fumadocs-ui/components/tabs';
## Summary
The following items have been deprecated and will be removed on the <strong>1st of March 2027</strong>:
- <strong>API V1</strong>
- <strong>A subset of SDK/API V2 endpoints</strong>
- <strong>Legacy documents and templates</strong>
- <strong>EmbedCreateDocumentV1</strong>
- <strong>EmbedCreateTemplateV1</strong>
- <strong>EmbedUpdateDocumentV1</strong>
- <strong>EmbedUpdateTemplateV1</strong>
The beta endpoint `/api/v2-beta` will also be removed, please use `/api/v2` instead which can be directly dropped in place.
Nothing breaks before 1st of March 2027 — you have time to migrate at your own pace.
## What are legacy documents and templates
These are documents and templates created by the following endpoints:
- `POST /api/v2/document/create`
- `POST /api/v2/document/create/beta`
- `POST /api/v2/template/create`
- `POST /api/v2/template/create/beta`
- `POST /api/v1/documents`
- `POST /api/v1/templates`
- `POST /api/v1/templates/create-document`
- `POST /api/v1/templates/generate-document`
## What replaces legacy documents and templates
At the end of 2025 we introduced a unified system for documents and templates, called <strong>envelopes</strong>.
We will still reference documents and templates throughout documentation and the application as a method to distinguish them, but internally they are envelopes.
By moving to the new envelope system, you get access to:
- **Multiple PDFs in one envelope** — send several documents to sign in a single request.
- **One API for documents and templates** — learn one set of endpoints instead of two parallel trees.
- **A better editor and signing experience** for you and your recipients.
## How to migrate
{/* prettier-ignore */}
<Steps>
<Step>
### Switch to the envelope endpoints
Replace each deprecated endpoint with its `/api/v2/envelope/*` equivalent from the [mapping tables](#endpoint-mapping-reference) below.
</Step>
<Step>
### Set the envelope `type` on create
A single endpoint, `POST /api/v2/envelope/create`, creates both documents and templates. Set `type` to `DOCUMENT` or `TEMPLATE`. You can now upload more than one PDF using the `files` field.
</Step>
<Step>
### Update how you store IDs
Envelope IDs are **strings** (for example `envelope_abc123`), not numbers. Update any code that stores, parses, or compares IDs.
</Step>
<Step>
### Test, then remove the old calls
Verify the new flow against your account, then delete the deprecated calls.
</Step>
</Steps>
The main data differences are as follows:
- ID format changed from number to string (e.g. `42` to `envelope_abc123`)
- pageNumber becomes page
- pageX becomes positionX
- pageY becomes positionY
See the [Documents API](/docs/developers/api/documents) and [Templates API](/docs/developers/api/templates) for the full envelope reference.
### Deprecated V1 API Endpoints
Verify these in the [V1 OpenAPI reference](https://openapi-v1.documenso.com).
| Deprecated endpoint | Replacement |
| -------------------------------------------------------- | ----------------------------------------------------- |
| `GET /api/v1/documents` | `GET /api/v2/envelope` |
| `GET /api/v1/documents/{id}` | `GET /api/v2/envelope/{envelopeId}` |
| `POST /api/v1/documents` | `POST /api/v2/envelope/create` |
| `POST /api/v1/documents/{id}/send` | `POST /api/v2/envelope/distribute` |
| `POST /api/v1/documents/{id}/resend` | `POST /api/v2/envelope/redistribute` |
| `DELETE /api/v1/documents/{id}` | `POST /api/v2/envelope/delete` |
| `GET /api/v1/documents/{id}/download` | `GET /api/v2/envelope/item/{envelopeItemId}/download` |
| `POST /api/v1/documents/{id}/recipients` | `POST /api/v2/envelope/recipient/create-many` |
| `PATCH /api/v1/documents/{id}/recipients/{recipientId}` | `POST /api/v2/envelope/recipient/update-many` |
| `DELETE /api/v1/documents/{id}/recipients/{recipientId}` | `POST /api/v2/envelope/recipient/delete` |
| `POST /api/v1/documents/{id}/fields` | `POST /api/v2/envelope/field/create-many` |
| `PATCH /api/v1/documents/{id}/fields/{fieldId}` | `POST /api/v2/envelope/field/update-many` |
| `DELETE /api/v1/documents/{id}/fields/{fieldId}` | `POST /api/v2/envelope/field/delete` |
| `GET /api/v1/templates` | `GET /api/v2/envelope` (with `type=TEMPLATE`) |
| `GET /api/v1/templates/{id}` | `GET /api/v2/envelope/{envelopeId}` |
| `POST /api/v1/templates` | `POST /api/v2/envelope/create` (`type=TEMPLATE`) |
| `DELETE /api/v1/templates/{id}` | `POST /api/v2/envelope/delete` |
| `POST /api/v1/templates/{templateId}/create-document` | `POST /api/v2/envelope/use` |
| `POST /api/v1/templates/{templateId}/generate-document` | `POST /api/v2/envelope/use` |
### Deprecated V2 API Endpoints
Verify these in the [V2 OpenAPI reference](https://openapi.documenso.com).
#### Documents
| Deprecated endpoint | Replacement |
| ------------------------------------------------- | ----------------------------------------------------- |
| `GET /api/v2/document` | `GET /api/v2/envelope` |
| `GET /api/v2/document/{documentId}` | `GET /api/v2/envelope/{envelopeId}` |
| `POST /api/v2/document/get-many` | `POST /api/v2/envelope/get-many` |
| `POST /api/v2/document/create` | `POST /api/v2/envelope/create` |
| `POST /api/v2/document/create/beta` | `POST /api/v2/envelope/create` |
| `POST /api/v2/document/update` | `POST /api/v2/envelope/update` |
| `POST /api/v2/document/delete` | `POST /api/v2/envelope/delete` |
| `POST /api/v2/document/duplicate` | `POST /api/v2/envelope/duplicate` |
| `POST /api/v2/document/distribute` | `POST /api/v2/envelope/distribute` |
| `POST /api/v2/document/redistribute` | `POST /api/v2/envelope/redistribute` |
| `GET /api/v2/document/attachment` | `GET /api/v2/envelope/attachment` |
| `POST /api/v2/document/attachment/create` | `POST /api/v2/envelope/attachment/create` |
| `POST /api/v2/document/attachment/update` | `POST /api/v2/envelope/attachment/update` |
| `POST /api/v2/document/attachment/delete` | `POST /api/v2/envelope/attachment/delete` |
| `GET /api/v2/document/{documentId}/download` | `GET /api/v2/envelope/item/{envelopeItemId}/download` |
| `GET /api/v2/document/{documentId}/download-beta` | `GET /api/v2/envelope/item/{envelopeItemId}/download` |
#### Templates
| Deprecated endpoint | Replacement |
| ------------------------------------- | ------------------------------------------------ |
| `GET /api/v2/template` | `GET /api/v2/envelope` (with `type=TEMPLATE`) |
| `GET /api/v2/template/{templateId}` | `GET /api/v2/envelope/{envelopeId}` |
| `POST /api/v2/template/get-many` | `POST /api/v2/envelope/get-many` |
| `POST /api/v2/template/create` | `POST /api/v2/envelope/create` (`type=TEMPLATE`) |
| `POST /api/v2/template/create/beta` | `POST /api/v2/envelope/create` (`type=TEMPLATE`) |
| `POST /api/v2/template/update` | `POST /api/v2/envelope/update` |
| `POST /api/v2/template/duplicate` | `POST /api/v2/envelope/duplicate` |
| `POST /api/v2/template/delete` | `POST /api/v2/envelope/delete` |
| `POST /api/v2/template/use` | `POST /api/v2/envelope/use` |
| `POST /api/v2/template/direct/create` | **Pending replacement** |
| `POST /api/v2/template/direct/delete` | **Pending replacement** |
| `POST /api/v2/template/direct/toggle` | **Pending replacement** |
#### Document fields
| Deprecated endpoint | Replacement |
| ----------------------------------------- | ----------------------------------------- |
| `GET /api/v2/document/field/{fieldId}` | `GET /api/v2/envelope/field/{fieldId}` |
| `POST /api/v2/document/field/create` | `POST /api/v2/envelope/field/create-many` |
| `POST /api/v2/document/field/create-many` | `POST /api/v2/envelope/field/create-many` |
| `POST /api/v2/document/field/update` | `POST /api/v2/envelope/field/update-many` |
| `POST /api/v2/document/field/update-many` | `POST /api/v2/envelope/field/update-many` |
| `POST /api/v2/document/field/delete` | `POST /api/v2/envelope/field/delete` |
#### Template fields
| Deprecated endpoint | Replacement |
| ----------------------------------------- | ----------------------------------------- |
| `GET /api/v2/template/field/{fieldId}` | `GET /api/v2/envelope/field/{fieldId}` |
| `POST /api/v2/template/field/create` | `POST /api/v2/envelope/field/create-many` |
| `POST /api/v2/template/field/create-many` | `POST /api/v2/envelope/field/create-many` |
| `POST /api/v2/template/field/update` | `POST /api/v2/envelope/field/update-many` |
| `POST /api/v2/template/field/update-many` | `POST /api/v2/envelope/field/update-many` |
| `POST /api/v2/template/field/delete` | `POST /api/v2/envelope/field/delete` |
#### Document recipients
| Deprecated endpoint | Replacement |
| ---------------------------------------------- | ---------------------------------------------- |
| `GET /api/v2/document/recipient/{recipientId}` | `GET /api/v2/envelope/recipient/{recipientId}` |
| `POST /api/v2/document/recipient/create` | `POST /api/v2/envelope/recipient/create-many` |
| `POST /api/v2/document/recipient/create-many` | `POST /api/v2/envelope/recipient/create-many` |
| `POST /api/v2/document/recipient/update` | `POST /api/v2/envelope/recipient/update-many` |
| `POST /api/v2/document/recipient/update-many` | `POST /api/v2/envelope/recipient/update-many` |
| `POST /api/v2/document/recipient/delete` | `POST /api/v2/envelope/recipient/delete` |
#### Template recipients
| Deprecated endpoint | Replacement |
| ---------------------------------------------- | ---------------------------------------------- |
| `GET /api/v2/template/recipient/{recipientId}` | `GET /api/v2/envelope/recipient/{recipientId}` |
| `POST /api/v2/template/recipient/create` | `POST /api/v2/envelope/recipient/create-many` |
| `POST /api/v2/template/recipient/create-many` | `POST /api/v2/envelope/recipient/create-many` |
| `POST /api/v2/template/recipient/update` | `POST /api/v2/envelope/recipient/update-many` |
| `POST /api/v2/template/recipient/update-many` | `POST /api/v2/envelope/recipient/update-many` |
| `POST /api/v2/template/recipient/delete` | `POST /api/v2/envelope/recipient/delete` |
### Embedding components
| Deprecated component | Replacement |
| ----------------------- | --------------------- |
| `EmbedCreateDocumentV1` | `EmbedCreateEnvelope` |
| `EmbedCreateTemplateV1` | `EmbedCreateEnvelope` |
| `EmbedUpdateDocumentV1` | `EmbedUpdateEnvelope` |
| `EmbedUpdateTemplateV1` | `EmbedUpdateEnvelope` |
See the [embedding guide](/docs/developers/embedding) for the envelope components.
## FAQ
<Accordions>
<Accordion title="What happens on 1 March 2027?">
The deprecated V1 API, the V2 endpoints listed above, and the V1 embedding components are removed.
Requests to them will fail, so migrate to the envelope API before that date.
</Accordion>
<Accordion title="Will my existing documents and templates keep working?">
Yes. Documents and templates you already created remain in your account and continue to work. They will automatically be converted to envelopes. Only
the deprecated endpoints you call are going away — your data is not deleted.
</Accordion>
<Accordion title="Do I need a new API token?">
No. Authentication is unchanged — the same API token works for the envelope endpoints under
`https://app.documenso.com/api/v2`.
</Accordion>
<Accordion title="What is the difference between a document and a template now?">
Both are envelopes, distinguished by a `type` field of `DOCUMENT` or `TEMPLATE`. They share the same
endpoints, recipients, fields, and attachments.
</Accordion>
<Accordion title="I use an official SDK — what should I do?">
The function calls to the legacy endpoints will break on the 1st of March 2027. Update to the latest SDK version and switch to its envelope methods.
The deprecated document and
template methods map to the envelope endpoints in the tables above.
</Accordion>
<Accordion title="I need more time or help migrating">
Reach out to [support@documenso.com](mailto:support@documenso.com) with your use case and we will
help you plan the migration.
</Accordion>
</Accordions>
## Getting help
- [V2 OpenAPI reference](https://openapi.documenso.com) — the always up-to-date envelope API.
- [V1 OpenAPI reference](https://openapi-v1.documenso.com) — the deprecated V1 API.
- [support@documenso.com](mailto:support@documenso.com) — migration questions and extensions.
## See also
- [Documents API](/docs/developers/api/documents) — create and manage envelopes
- [Templates API](/docs/developers/api/templates) — work with templates and direct links
- [Fields API](/docs/developers/api/fields) and [Recipients API](/docs/developers/api/recipients)
- [API Versioning](/docs/developers/api/versioning) — how Documenso versions the public API
@@ -11,7 +11,7 @@ Documenso enforces rate limits on all API endpoints to ensure service stability.
## HTTP Rate Limits
**Limit:** 100 requests per minute per IP address
**Limit:** 100 requests per minute per IP address
**Response:** 429 Too Many Requests
### Rate Limit Response
@@ -6,6 +6,8 @@ description: Create documents from reusable templates via API.
import { Callout } from 'fumadocs-ui/components/callout';
import { Tab, Tabs } from 'fumadocs-ui/components/tabs';
<EnvelopeWarning />
<Callout type="warn">
This guide may not reflect the latest endpoints or parameters. For an always up-to-date reference,
see the [OpenAPI Reference](https://openapi.documenso.com).
@@ -5,6 +5,8 @@ description: Versioning information for the Documenso public API.
import { Callout } from 'fumadocs-ui/components/callout';
<EnvelopeWarning />
## Overview
Documenso uses API versioning to manage changes to the public API. This allows us to introduce new features, fix bugs, and make other changes without breaking existing integrations.
@@ -19,7 +21,16 @@ Also, we may deprecate certain features or endpoints in the API. When we depreca
---
## Documents, Templates, and Envelopes
Documenso has unified documents and templates into a single resource called an **envelope**. New integrations should create documents and templates through the `/envelope/*` endpoints. The `POST /document/create` and `POST /template/create` endpoints (including their `/beta` variants) are deprecated in favor of `POST /envelope/create`.
See [Migrating to the Envelope API](/docs/developers/api/migrate-to-envelopes) for the rationale and step-by-step migration examples.
---
## See Also
- [Migrating to the Envelope API](/docs/developers/api/migrate-to-envelopes) - Move from the document and template create endpoints
- [Authentication](/docs/developers/getting-started/authentication) - API authentication guide
- [Rate Limits](/docs/developers/api/rate-limits) - API rate limit details
@@ -8,6 +8,8 @@ import { Callout } from 'fumadocs-ui/components/callout';
import { Step, Steps } from 'fumadocs-ui/components/steps';
import { Tab, Tabs } from 'fumadocs-ui/components/tabs';
<EnvelopeWarning />
## Workflow 1: Send a Document for Signature
The most common workflow: upload a PDF, add recipients with signature fields, and send for signing.
@@ -3,6 +3,8 @@ title: Examples
description: Common integration patterns and end-to-end workflows.
---
<EnvelopeWarning />
<Cards>
<Card
title="Common Workflows"
@@ -7,6 +7,8 @@ import { Accordion, Accordions } from 'fumadocs-ui/components/accordion';
import { Callout } from 'fumadocs-ui/components/callout';
import { Step, Steps } from 'fumadocs-ui/components/steps';
<EnvelopeWarning />
## Prerequisites
- A Documenso account (cloud or self-hosted)
@@ -7,6 +7,8 @@ import { Callout } from 'fumadocs-ui/components/callout';
import { Step, Steps } from 'fumadocs-ui/components/steps';
import { Tab, Tabs } from 'fumadocs-ui/components/tabs';
<EnvelopeWarning />
## Prerequisites
Before starting, you need:
@@ -3,6 +3,8 @@ title: Getting Started
description: Get your API key and make your first API call.
---
<EnvelopeWarning />
<Cards>
<Card
title="Authentication"
@@ -3,6 +3,8 @@ title: Developer Guide
description: Integrate Documenso into your applications using the REST API, webhooks, and embedding options.
---
<EnvelopeWarning />
## Getting Started
<Cards>
+1 -1
View File
@@ -4,7 +4,7 @@
"private": true,
"scripts": {
"build": "next build",
"dev": "next dev",
"dev": "next dev --port 3004",
"start": "next start",
"types:check": "fumadocs-mdx && next typegen && tsc --noEmit",
"postinstall": "fumadocs-mdx"
@@ -0,0 +1,19 @@
import { Callout } from 'fumadocs-ui/components/callout';
const MIGRATION_GUIDE_HREF = '/docs/developers/api/migrate-to-envelopes';
/**
* Deprecation banner steering API consumers away from the legacy document and
* template create endpoints and towards the unified Envelope API.
*
* Registered globally in `mdx-components.tsx`, so it can be used in any MDX page
* as `<EnvelopeWarning />` without an explicit import.
*/
export function EnvelopeWarning() {
return (
<Callout type="error">
<strong>Documents and templates are being deprecated and replaced by envelopes.</strong>{' '}
<a href={MIGRATION_GUIDE_HREF}>Read the migration guide here.</a>
</Callout>
);
}
+2
View File
@@ -1,6 +1,7 @@
import * as TabsComponents from 'fumadocs-ui/components/tabs';
import defaultMdxComponents from 'fumadocs-ui/mdx';
import type { MDXComponents } from 'mdx/types';
import { EnvelopeWarning } from '@/components/mdx/envelope-warning';
import { Mermaid } from '@/components/mdx/mermaid';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -9,6 +10,7 @@ export function getMDXComponents(components?: MDXComponents): any {
...defaultMdxComponents,
...TabsComponents,
Mermaid,
EnvelopeWarning,
...components,
};
}
@@ -1,243 +0,0 @@
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { FolderType } from '@documenso/lib/types/folder-type';
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@documenso/ui/primitives/dialog';
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';
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import type * as DialogPrimitive from '@radix-ui/react-dialog';
import { FolderIcon, HomeIcon, Loader2, Search } from 'lucide-react';
import { useEffect, useState } from 'react';
import { useForm } from 'react-hook-form';
import { useNavigate } from 'react-router';
import { z } from 'zod';
import { useCurrentTeam } from '~/providers/team';
export type DocumentMoveToFolderDialogProps = {
documentId: number;
open: boolean;
onOpenChange: (open: boolean) => void;
currentFolderId?: string;
} & Omit<DialogPrimitive.DialogProps, 'children'>;
const ZMoveDocumentFormSchema = z.object({
folderId: z.string().nullable().optional(),
});
type TMoveDocumentFormSchema = z.infer<typeof ZMoveDocumentFormSchema>;
export const DocumentMoveToFolderDialog = ({
documentId,
open,
onOpenChange,
currentFolderId,
...props
}: DocumentMoveToFolderDialogProps) => {
const { _ } = useLingui();
const { toast } = useToast();
const navigate = useNavigate();
const team = useCurrentTeam();
const [searchTerm, setSearchTerm] = useState('');
const form = useForm<TMoveDocumentFormSchema>({
resolver: zodResolver(ZMoveDocumentFormSchema),
defaultValues: {
folderId: currentFolderId,
},
});
const { data: folders, isLoading: isFoldersLoading } = trpc.folder.findFoldersInternal.useQuery(
{
parentId: currentFolderId,
type: FolderType.DOCUMENT,
},
{
enabled: open,
},
);
const { mutateAsync: updateDocument } = trpc.document.update.useMutation();
useEffect(() => {
if (!open) {
form.reset();
setSearchTerm('');
} else {
form.reset({ folderId: currentFolderId });
}
}, [open, currentFolderId, form]);
const onSubmit = async (data: TMoveDocumentFormSchema) => {
try {
await updateDocument({
documentId,
data: {
folderId: data.folderId ?? null,
},
});
const documentsPath = formatDocumentsPath(team.url);
if (data.folderId) {
await navigate(`${documentsPath}/f/${data.folderId}`);
} else {
await navigate(documentsPath);
}
toast({
title: _(msg`Document moved`),
description: _(msg`The document has been moved successfully.`),
variant: 'default',
});
onOpenChange(false);
} catch (err) {
const error = AppError.parseError(err);
if (error.code === AppErrorCode.NOT_FOUND) {
toast({
title: _(msg`Error`),
description: _(msg`The folder you are trying to move the document to does not exist.`),
variant: 'destructive',
});
return;
}
if (error.code === AppErrorCode.UNAUTHORIZED) {
toast({
title: _(msg`Error`),
description: _(msg`You are not allowed to move this document.`),
variant: 'destructive',
});
return;
}
toast({
title: _(msg`Error`),
description: _(msg`An error occurred while moving the document.`),
variant: 'destructive',
});
}
};
const filteredFolders = folders?.data.filter((folder) =>
folder.name.toLowerCase().includes(searchTerm.toLowerCase()),
);
return (
<Dialog {...props} open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>
<Trans>Move Document to Folder</Trans>
</DialogTitle>
<DialogDescription>
<Trans>Select a folder to move this document to.</Trans>
</DialogDescription>
</DialogHeader>
<div className="relative">
<Search className="absolute top-3 left-2 h-4 w-4 text-muted-foreground" />
<Input
placeholder={_(msg`Search folders...`)}
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-8"
/>
</div>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-y-4">
<FormField
control={form.control}
name="folderId"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Folder</Trans>
</FormLabel>
<FormControl>
<div className="max-h-96 space-y-2 overflow-y-auto">
{isFoldersLoading ? (
<div className="flex h-10 items-center justify-center">
<Loader2 className="h-4 w-4 animate-spin" />
</div>
) : (
<>
<Button
type="button"
variant={field.value === null ? 'default' : 'outline'}
className="w-full justify-start"
onClick={() => field.onChange(null)}
disabled={currentFolderId === null}
>
<HomeIcon className="mr-2 h-4 w-4" />
<Trans>Home (No Folder)</Trans>
</Button>
{filteredFolders?.map((folder) => (
<Button
key={folder.id}
type="button"
variant={field.value === folder.id ? 'default' : 'outline'}
className="w-full justify-start"
onClick={() => field.onChange(folder.id)}
disabled={currentFolderId === folder.id}
>
<FolderIcon className="mr-2 h-4 w-4" />
{folder.name}
</Button>
))}
{searchTerm && filteredFolders?.length === 0 && (
<div className="px-2 py-2 text-center text-muted-foreground text-sm">
<Trans>No folders found</Trans>
</div>
)}
</>
)}
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)}>
<Trans>Cancel</Trans>
</Button>
<Button
type="submit"
disabled={isFoldersLoading || form.formState.isSubmitting || currentFolderId === null}
>
<Trans>Move</Trans>
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
);
};
@@ -1,203 +0,0 @@
import { useSession } from '@documenso/lib/client-only/providers/session';
import { getRecipientType } from '@documenso/lib/client-only/recipient-type';
import { AppError } from '@documenso/lib/errors/app-error';
import type { TRecipientLite } from '@documenso/lib/types/recipient';
import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter';
import type { Document } from '@documenso/prisma/types/document-legacy-schema';
import { trpc as trpcReact } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import { Checkbox } from '@documenso/ui/primitives/checkbox';
import {
Dialog,
DialogClose,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@documenso/ui/primitives/dialog';
import { DropdownMenuItem } from '@documenso/ui/primitives/dropdown-menu';
import { Form, FormControl, FormField, FormItem, FormLabel } from '@documenso/ui/primitives/form/form';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { SigningStatus, type Team, type User } from '@prisma/client';
import { History } from 'lucide-react';
import { useState } from 'react';
import { useForm, useWatch } from 'react-hook-form';
import * as z from 'zod';
import { useCurrentTeam } from '~/providers/team';
import { getDistributeErrorMessage } from '~/utils/toast-error-messages';
import { StackAvatar } from '../general/stack-avatar';
const FORM_ID = 'resend-email';
export type DocumentResendDialogProps = {
document: Pick<Document, 'id' | 'userId' | 'teamId' | 'status'> & {
user: Pick<User, 'id' | 'name' | 'email'>;
recipients: TRecipientLite[];
team: Pick<Team, 'id' | 'url'> | null;
};
recipients: TRecipientLite[];
};
export const ZResendDocumentFormSchema = z.object({
recipients: z.array(z.number()).min(1, {
message: 'You must select at least one item.',
}),
});
export type TResendDocumentFormSchema = z.infer<typeof ZResendDocumentFormSchema>;
export const DocumentResendDialog = ({ document, recipients }: DocumentResendDialogProps) => {
const { user } = useSession();
const team = useCurrentTeam();
const { toast } = useToast();
const { _ } = useLingui();
const [isOpen, setIsOpen] = useState(false);
const isOwner = document.userId === user.id;
const isCurrentTeamDocument = team && document.team?.url === team.url;
const isDisabled =
(!isOwner && !isCurrentTeamDocument) ||
document.status !== 'PENDING' ||
!recipients.some((r) => r.signingStatus === SigningStatus.NOT_SIGNED);
const { mutateAsync: resendDocument } = trpcReact.document.redistribute.useMutation();
const form = useForm<TResendDocumentFormSchema>({
resolver: zodResolver(ZResendDocumentFormSchema),
defaultValues: {
recipients: [],
},
});
const {
handleSubmit,
formState: { isSubmitting },
} = form;
const selectedRecipients = useWatch({
control: form.control,
name: 'recipients',
});
const onFormSubmit = async ({ recipients }: TResendDocumentFormSchema) => {
try {
await resendDocument({ documentId: document.id, recipients });
toast({
title: _(msg`Document re-sent`),
description: _(msg`Your document has been re-sent successfully.`),
duration: 5000,
});
setIsOpen(false);
} catch (err) {
const error = AppError.parseError(err);
const errorMessage = getDistributeErrorMessage(error.code);
toast({
title: _(errorMessage.title),
description: _(errorMessage.description),
variant: 'destructive',
duration: 7500,
});
}
};
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
<DropdownMenuItem disabled={isDisabled} onSelect={(e) => e.preventDefault()}>
<History className="mr-2 h-4 w-4" />
<Trans>Resend</Trans>
</DropdownMenuItem>
</DialogTrigger>
<DialogContent className="sm:max-w-sm" hideClose>
<DialogHeader>
<DialogTitle asChild>
<h1 className="text-center text-xl">
<Trans>Who do you want to remind?</Trans>
</h1>
</DialogTitle>
</DialogHeader>
<Form {...form}>
<form id={FORM_ID} onSubmit={handleSubmit(onFormSubmit)} className="px-3">
<FormField
control={form.control}
name="recipients"
render={({ field: { value, onChange } }) => (
<>
{recipients.map((recipient) => (
<FormItem key={recipient.id} className="flex flex-row items-center justify-between gap-x-3">
<FormLabel
className={cn('my-2 flex items-center gap-2 font-normal', {
'opacity-50': !value.includes(recipient.id),
})}
>
<StackAvatar
key={recipient.id}
type={getRecipientType(recipient)}
fallbackText={recipientAbbreviation(recipient)}
/>
{recipient.email}
</FormLabel>
<FormControl>
<Checkbox
className="h-5 w-5 rounded-full border border-neutral-400"
value={recipient.id}
checked={value.includes(recipient.id)}
onCheckedChange={(checked: boolean) =>
checked
? onChange([...value, recipient.id])
: onChange(value.filter((v) => v !== recipient.id))
}
/>
</FormControl>
</FormItem>
))}
</>
)}
/>
</form>
</Form>
<DialogFooter>
<div className="flex w-full flex-1 flex-nowrap gap-4">
<DialogClose asChild>
<Button
type="button"
className="flex-1 bg-black/5 hover:bg-black/10 dark:bg-muted dark:hover:bg-muted/80"
variant="secondary"
disabled={isSubmitting}
>
<Trans>Cancel</Trans>
</Button>
</DialogClose>
<Button
className="flex-1"
loading={isSubmitting}
type="submit"
form={FORM_ID}
disabled={isSubmitting || selectedRecipients.length === 0}
>
<Trans>Send reminder</Trans>
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
@@ -3,12 +3,13 @@ import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/org
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
import { AppError } from '@documenso/lib/errors/app-error';
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
import { hasOverlappingFields } from '@documenso/lib/utils/fields-overlap';
import { getRecipientsWithMissingFields } from '@documenso/lib/utils/recipients';
import { zEmail } from '@documenso/lib/utils/zod';
import { trpc, trpc as trpcReact } from '@documenso/trpc/react';
import { DocumentSendEmailMessageHelper } from '@documenso/ui/components/document/document-send-email-message-helper';
import { cn } from '@documenso/ui/lib/utils';
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
@@ -32,7 +33,7 @@ import { zodResolver } from '@hookform/resolvers/zod';
import { Trans, useLingui } from '@lingui/react/macro';
import { DocumentDistributionMethod, DocumentStatus, EnvelopeType } from '@prisma/client';
import { AnimatePresence, motion } from 'framer-motion';
import { InfoIcon } from 'lucide-react';
import { AlertTriangleIcon, InfoIcon } from 'lucide-react';
import { useEffect, useMemo, useState } from 'react';
import { useForm } from 'react-hook-form';
import { useNavigate } from 'react-router';
@@ -138,6 +139,27 @@ export const EnvelopeDistributeDialog = ({
});
}, [recipientsWithIndex, envelope.authOptions]);
/**
* Whether any fields significantly overlap each other. This is surfaced as a
* non-blocking warning since overlapping fields still allow sending, but can
* complicate the signing process or cause fields to behave unexpectedly.
*/
const hasOverlappingEnvelopeFields = useMemo(
() =>
hasOverlappingFields(
envelope.fields.map((field) => ({
id: field.id,
envelopeItemId: field.envelopeItemId,
page: field.page,
positionX: Number(field.positionX),
positionY: Number(field.positionY),
width: Number(field.width),
height: Number(field.height),
})),
),
[envelope.fields],
);
const invalidEnvelopeCode = useMemo(() => {
if (recipientsMissingSignatureFields.length > 0) {
return 'MISSING_SIGNATURES';
@@ -206,6 +228,11 @@ export const EnvelopeDistributeDialog = ({
};
useEffect(() => {
// Default the distribution method tab to the envelope's configured setting.
if (isOpen && envelope.documentMeta) {
setValue('meta.distributionMethod', envelope.documentMeta.distributionMethod);
}
// Resync the whole envelope if the envelope is mid saving.
if (isOpen && (isAutosaving || autosaveError)) {
void handleSync();
@@ -235,6 +262,24 @@ export const EnvelopeDistributeDialog = ({
<Form {...form}>
<form onSubmit={handleSubmit(onFormSubmit)}>
<fieldset disabled={isSubmitting}>
{hasOverlappingEnvelopeFields && (
<Alert variant="warning" className="mb-4 flex flex-row items-start gap-3">
<AlertTriangleIcon className="mt-0.5 h-5 w-5 flex-shrink-0" />
<div className="flex flex-col gap-1">
<AlertTitle>
<Trans>Overlapping fields detected</Trans>
</AlertTitle>
<AlertDescription>
<Trans>
Some fields are placed on top of each other. This may complicate the signing process or cause
fields to not work as expected.
</Trans>
</AlertDescription>
</div>
</Alert>
)}
<Tabs
onValueChange={(value) =>
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
@@ -1,6 +1,7 @@
import { formatDocumentsPath, formatTemplatesPath } from '@documenso/lib/utils/teams';
import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import { Checkbox } from '@documenso/ui/primitives/checkbox';
import {
Dialog,
DialogClose,
@@ -11,10 +12,12 @@ import {
DialogTitle,
DialogTrigger,
} from '@documenso/ui/primitives/dialog';
import { Label } from '@documenso/ui/primitives/label';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { Trans, useLingui } from '@lingui/react/macro';
import { EnvelopeType } from '@prisma/client';
import { useState } from 'react';
import { Controller, useForm } from 'react-hook-form';
import { useNavigate } from 'react-router';
import { useCurrentTeam } from '~/providers/team';
@@ -37,6 +40,15 @@ export const EnvelopeDuplicateDialog = ({ envelopeId, envelopeType, trigger }: E
const isDocument = envelopeType === EnvelopeType.DOCUMENT;
const form = useForm({
defaultValues: {
includeRecipients: true,
includeFields: true,
},
});
const includeRecipients = form.watch('includeRecipients');
const { mutateAsync: duplicateEnvelope, isPending: isDuplicating } = trpc.envelope.duplicate.useMutation({
onSuccess: async ({ id }) => {
toast({
@@ -55,8 +67,14 @@ export const EnvelopeDuplicateDialog = ({ envelopeId, envelopeType, trigger }: E
});
const onDuplicate = async () => {
const { includeRecipients, includeFields } = form.getValues();
try {
await duplicateEnvelope({ envelopeId });
await duplicateEnvelope({
envelopeId,
includeRecipients,
includeFields: includeRecipients && includeFields,
});
} catch {
toast({
title: t`Something went wrong`,
@@ -70,7 +88,20 @@ export const EnvelopeDuplicateDialog = ({ envelopeId, envelopeType, trigger }: E
};
return (
<Dialog open={open} onOpenChange={(value) => !isDuplicating && setOpen(value)}>
<Dialog
open={open}
onOpenChange={(value) => {
if (isDuplicating) {
return;
}
setOpen(value);
if (!value) {
form.reset();
}
}}
>
{trigger && <DialogTrigger asChild>{trigger}</DialogTrigger>}
<DialogContent>
@@ -87,6 +118,49 @@ export const EnvelopeDuplicateDialog = ({ envelopeId, envelopeType, trigger }: E
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<Controller
control={form.control}
name="includeRecipients"
render={({ field }) => (
<div className="flex items-center space-x-2">
<Checkbox
id="envelopeDuplicateIncludeRecipients"
checked={field.value}
onCheckedChange={(checked) => {
field.onChange(checked === true);
if (!checked) {
form.setValue('includeFields', false);
}
}}
/>
<Label htmlFor="envelopeDuplicateIncludeRecipients">
<Trans>Include Recipients</Trans>
</Label>
</div>
)}
/>
<Controller
control={form.control}
name="includeFields"
render={({ field }) => (
<div className="flex items-center space-x-2">
<Checkbox
id="envelopeDuplicateIncludeFields"
checked={field.value}
disabled={!includeRecipients}
onCheckedChange={(checked) => field.onChange(checked === true)}
/>
<Label htmlFor="envelopeDuplicateIncludeFields" className={!includeRecipients ? 'opacity-50' : ''}>
<Trans>Include Fields</Trans>
</Label>
</div>
)}
/>
</div>
<DialogFooter>
<DialogClose asChild>
<Button type="button" variant="secondary" disabled={isDuplicating}>
@@ -25,14 +25,16 @@ import { Trans, useLingui } from '@lingui/react/macro';
import { DocumentStatus, EnvelopeType, SigningStatus } from '@prisma/client';
import { useEffect, useState } from 'react';
import { useForm } from 'react-hook-form';
import { match } from 'ts-pattern';
import * as z from 'zod';
import { getDistributeErrorMessage } from '~/utils/toast-error-messages';
import { StackAvatar } from '../general/stack-avatar';
export type EnvelopeRedistributeDialogProps = {
envelope: Pick<TEnvelope, 'id' | 'userId' | 'teamId' | 'status' | 'type' | 'documentMeta'> & {
envelope: Pick<TEnvelope, 'id' | 'status' | 'type'> & {
recipients: TEnvelopeRecipientLite[];
};
envelopeType?: EnvelopeType;
trigger?: React.ReactNode;
};
@@ -44,7 +46,7 @@ export const ZEnvelopeRedistributeFormSchema = z.object({
export type TEnvelopeRedistributeFormSchema = z.infer<typeof ZEnvelopeRedistributeFormSchema>;
export const EnvelopeRedistributeDialog = ({ envelope, trigger }: EnvelopeRedistributeDialogProps) => {
export const EnvelopeRedistributeDialog = ({ envelope, envelopeType, trigger }: EnvelopeRedistributeDialogProps) => {
const recipients = envelope.recipients;
const { toast } = useToast();
@@ -70,9 +72,23 @@ export const EnvelopeRedistributeDialog = ({ envelope, trigger }: EnvelopeRedist
try {
await redistributeEnvelope({ envelopeId: envelope.id, recipients });
const successMessage = match(envelopeType)
.with(EnvelopeType.DOCUMENT, () => ({
title: t`Document resent`,
description: t`Your document has been resent successfully.`,
}))
.with(EnvelopeType.TEMPLATE, () => ({
title: t`Template resent`,
description: t`Your template has been resent successfully.`,
}))
.otherwise(() => ({
title: t`Envelope resent`,
description: t`Your envelope has been resent successfully.`,
}));
toast({
title: t`Envelope resent`,
description: t`Your envelope has been resent successfully.`,
title: successMessage.title,
description: successMessage.description,
duration: 5000,
});
@@ -28,7 +28,7 @@ export type EnvelopesBulkMoveDialogProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
currentFolderId?: string;
onSuccess?: () => void;
onSuccess?: (folderId: string | null) => Promise<void> | void;
} & Omit<DialogPrimitive.DialogProps, 'children'>;
const ZBulkMoveFormSchema = z.object({
@@ -99,11 +99,12 @@ export const EnvelopesBulkMoveDialog = ({
await trpcUtils.template.findTemplates.invalidate();
}
await onSuccess?.(data.folderId);
toast({
description: t`Selected items have been moved.`,
});
onSuccess?.();
onOpenChange(false);
} catch (err) {
const error = AppError.parseError(err);
@@ -16,6 +16,17 @@ import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { useState } from 'react';
import { match } from 'ts-pattern';
/**
* The reason a team member cannot be removed from the team. When set, the delete
* dialog explains the reason instead of offering a confirm button.
*/
export type TeamMemberDeleteDisableReason =
| 'TEAM_OWNER'
| 'HIGHER_ROLE'
| 'INHERIT_MEMBER_ENABLED'
| 'INHERITED_MEMBER';
export type TeamMemberDeleteDialogProps = {
teamId: number;
@@ -23,7 +34,7 @@ export type TeamMemberDeleteDialogProps = {
memberId: string;
memberName: string;
memberEmail: string;
isInheritMemberEnabled: boolean | null;
disableReason?: TeamMemberDeleteDisableReason | null;
trigger?: React.ReactNode;
};
@@ -34,7 +45,7 @@ export const TeamMemberDeleteDialog = ({
memberId,
memberName,
memberEmail,
isInheritMemberEnabled,
disableReason,
}: TeamMemberDeleteDialogProps) => {
const [open, setOpen] = useState(false);
@@ -86,10 +97,19 @@ export const TeamMemberDeleteDialog = ({
</DialogDescription>
</DialogHeader>
{isInheritMemberEnabled ? (
{disableReason ? (
<Alert variant="neutral">
<AlertDescription>
<Trans>You cannot remove members from this team if the inherit member feature is enabled.</Trans>
{match(disableReason)
.with('TEAM_OWNER', () => <Trans>You cannot remove the organisation owner from the team.</Trans>)
.with('HIGHER_ROLE', () => <Trans>You cannot remove a member with a role higher than your own.</Trans>)
.with('INHERIT_MEMBER_ENABLED', () => (
<Trans>You cannot remove members from this team while the inherit member feature is enabled.</Trans>
))
.with('INHERITED_MEMBER', () => (
<Trans>This member is inherited from a group and cannot be removed from the team directly.</Trans>
))
.exhaustive()}
</AlertDescription>
</Alert>
) : (
@@ -109,11 +129,10 @@ export const TeamMemberDeleteDialog = ({
<Trans>Close</Trans>
</Button>
{!isInheritMemberEnabled && (
{!disableReason && (
<Button
type="submit"
variant="destructive"
disabled={Boolean(isInheritMemberEnabled)}
loading={isDeletingTeamMember}
onClick={async () => deleteTeamMember({ teamId, memberId })}
>
@@ -1,232 +0,0 @@
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { FolderType } from '@documenso/lib/types/folder-type';
import { formatTemplatesPath } from '@documenso/lib/utils/teams';
import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@documenso/ui/primitives/dialog';
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';
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import type * as DialogPrimitive from '@radix-ui/react-dialog';
import { FolderIcon, HomeIcon, Loader2, Search } from 'lucide-react';
import { useEffect, useState } from 'react';
import { useForm } from 'react-hook-form';
import { useNavigate } from 'react-router';
import { z } from 'zod';
import { useCurrentTeam } from '~/providers/team';
export type TemplateMoveToFolderDialogProps = {
templateId: number;
templateTitle: string;
isOpen: boolean;
onOpenChange: (open: boolean) => void;
currentFolderId?: string | null;
} & Omit<DialogPrimitive.DialogProps, 'children'>;
const ZMoveTemplateFormSchema = z.object({
folderId: z.string().nullable().optional(),
});
type TMoveTemplateFormSchema = z.infer<typeof ZMoveTemplateFormSchema>;
export function TemplateMoveToFolderDialog({
templateId,
templateTitle,
isOpen,
onOpenChange,
currentFolderId,
...props
}: TemplateMoveToFolderDialogProps) {
const { _ } = useLingui();
const { toast } = useToast();
const navigate = useNavigate();
const team = useCurrentTeam();
const [searchTerm, setSearchTerm] = useState('');
const form = useForm<TMoveTemplateFormSchema>({
resolver: zodResolver(ZMoveTemplateFormSchema),
defaultValues: {
folderId: currentFolderId ?? null,
},
});
const { data: folders, isLoading: isFoldersLoading } = trpc.folder.findFoldersInternal.useQuery(
{
parentId: currentFolderId ?? null,
type: FolderType.TEMPLATE,
},
{
enabled: isOpen,
},
);
const { mutateAsync: updateTemplate } = trpc.template.updateTemplate.useMutation();
useEffect(() => {
if (!isOpen) {
form.reset();
setSearchTerm('');
} else {
form.reset({ folderId: currentFolderId ?? null });
}
}, [isOpen, currentFolderId, form]);
const onSubmit = async (data: TMoveTemplateFormSchema) => {
try {
await updateTemplate({
templateId,
data: {
folderId: data.folderId ?? null,
},
});
toast({
title: _(msg`Template moved`),
description: _(msg`The template has been moved successfully.`),
variant: 'default',
});
onOpenChange(false);
const templatesPath = formatTemplatesPath(team.url);
if (data.folderId) {
void navigate(`${templatesPath}/f/${data.folderId}`);
} else {
void navigate(templatesPath);
}
} catch (err) {
const error = AppError.parseError(err);
if (error.code === AppErrorCode.NOT_FOUND) {
toast({
title: _(msg`Error`),
description: _(msg`The folder you are trying to move the template to does not exist.`),
variant: 'destructive',
});
return;
}
toast({
title: _(msg`Error`),
description: _(msg`An error occurred while moving the template.`),
variant: 'destructive',
});
}
};
const filteredFolders = folders?.data?.filter((folder) =>
folder.name.toLowerCase().includes(searchTerm.toLowerCase()),
);
return (
<Dialog {...props} open={isOpen} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>
<Trans>Move Template to Folder</Trans>
</DialogTitle>
<DialogDescription>
<Trans>Move &quot;{templateTitle}&quot; to a folder</Trans>
</DialogDescription>
</DialogHeader>
<div className="relative">
<Search className="absolute top-3 left-2 h-4 w-4 text-muted-foreground" />
<Input
placeholder={_(msg`Search folders...`)}
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-8"
/>
</div>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="mt-4 flex flex-col gap-y-4">
<FormField
control={form.control}
name="folderId"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Folder</Trans>
</FormLabel>
<FormControl>
<div className="max-h-96 space-y-2 overflow-y-auto">
{isFoldersLoading ? (
<div className="flex h-10 items-center justify-center">
<Loader2 className="h-4 w-4 animate-spin" />
</div>
) : (
<>
<Button
type="button"
variant={field.value === null ? 'default' : 'outline'}
className="w-full justify-start"
onClick={() => field.onChange(null)}
disabled={currentFolderId === null}
>
<HomeIcon className="mr-2 h-4 w-4" />
<Trans>Home (No Folder)</Trans>
</Button>
{filteredFolders?.map((folder) => (
<Button
key={folder.id}
type="button"
variant={field.value === folder.id ? 'default' : 'outline'}
className="w-full justify-start"
onClick={() => field.onChange(folder.id)}
disabled={currentFolderId === folder.id}
>
<FolderIcon className="mr-2 h-4 w-4" />
{folder.name}
</Button>
))}
{searchTerm && filteredFolders?.length === 0 && (
<div className="px-2 py-2 text-center text-muted-foreground text-sm">
<Trans>No folders found</Trans>
</div>
)}
</>
)}
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)}>
<Trans>Cancel</Trans>
</Button>
<Button type="submit" disabled={isFoldersLoading || form.formState.isSubmitting}>
<Trans>Move</Trans>
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
);
}
@@ -19,6 +19,7 @@ import {
Download,
Edit,
FileOutputIcon,
History,
Loader,
MoreHorizontal,
Pencil,
@@ -29,10 +30,10 @@ import {
import { useState } from 'react';
import { Link, useNavigate } from 'react-router';
import { DocumentResendDialog } from '~/components/dialogs/document-resend-dialog';
import { EnvelopeDeleteDialog } from '~/components/dialogs/envelope-delete-dialog';
import { EnvelopeDownloadDialog } from '~/components/dialogs/envelope-download-dialog';
import { EnvelopeDuplicateDialog } from '~/components/dialogs/envelope-duplicate-dialog';
import { EnvelopeRedistributeDialog } from '~/components/dialogs/envelope-redistribute-dialog';
import { EnvelopeRenameDialog } from '~/components/dialogs/envelope-rename-dialog';
import { EnvelopeSaveAsTemplateDialog } from '~/components/dialogs/envelope-save-as-template-dialog';
import { DocumentRecipientLinkCopyDialog } from '~/components/general/document/document-recipient-link-copy-dialog';
@@ -67,8 +68,6 @@ export const DocumentPageViewDropdown = ({ envelope }: DocumentPageViewDropdownP
const documentsPath = formatDocumentsPath(team.url);
const nonSignedRecipients = envelope.recipients.filter((item) => item.signingStatus !== 'SIGNED');
return (
<DropdownMenu>
<DropdownMenuTrigger data-testid="document-page-view-action-btn">
@@ -172,13 +171,20 @@ export const DocumentPageViewDropdown = ({ envelope }: DocumentPageViewDropdownP
/>
)}
<DocumentResendDialog
document={{
...envelope,
id: mapSecondaryIdToDocumentId(envelope.secondaryId),
}}
recipients={nonSignedRecipients}
/>
{canManageDocument && (
<EnvelopeRedistributeDialog
envelope={envelope}
envelopeType={EnvelopeType.DOCUMENT}
trigger={
<DropdownMenuItem asChild onSelect={(e) => e.preventDefault()}>
<div>
<History className="mr-2 h-4 w-4" />
<Trans>Resend</Trans>
</div>
</DropdownMenuItem>
}
/>
)}
<DocumentShareButton
documentId={mapSecondaryIdToDocumentId(envelope.secondaryId)}
@@ -1,3 +1,4 @@
import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value';
import type { TLocalField } from '@documenso/lib/client-only/hooks/use-editor-fields';
import { usePageRenderer } from '@documenso/lib/client-only/hooks/use-page-renderer';
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
@@ -13,6 +14,7 @@ import {
} from '@documenso/lib/universal/field-renderer/field-renderer';
import { renderField } from '@documenso/lib/universal/field-renderer/render-field';
import { getClientSideFieldTranslations } from '@documenso/lib/utils/fields';
import { getOverlappingFieldPairs } from '@documenso/lib/utils/fields-overlap';
import { canRecipientFieldsBeModified } from '@documenso/lib/utils/recipients';
import {
Command,
@@ -62,6 +64,36 @@ export const EnvelopeEditorFieldsPageRenderer = ({ pageData }: { pageData: PageR
[editorFields.localFields, pageNumber, currentEnvelopeItem?.id],
);
/**
* Debounce the fields used for overlap highlighting so we don't recompute on every
* small drag/resize tick. Overlaps only occur within the same page and envelope
* item, so computing from this page's fields alone is sufficient.
*/
const debouncedPageFields = useDebouncedValue(localPageFields, 300);
const overlappingFieldFormIds = useMemo(() => {
const formIds = new Set<string>();
const pairs = getOverlappingFieldPairs(
debouncedPageFields.map((field) => ({
id: field.formId,
envelopeItemId: field.envelopeItemId,
page: field.page,
positionX: field.positionX,
positionY: field.positionY,
width: field.width,
height: field.height,
})),
);
for (const pair of pairs) {
formIds.add(pair.fieldA.id);
formIds.add(pair.fieldB.id);
}
return formIds;
}, [debouncedPageFields]);
const handleResizeOrMove = (event: KonvaEventObject<Event>) => {
const isDragEvent = event.type === 'dragend';
@@ -113,6 +145,62 @@ export const EnvelopeEditorFieldsPageRenderer = ({ pageData }: { pageData: PageR
pageLayer.current?.batchDraw();
};
/**
* Draws (or removes) a dashed warning outline over a field that significantly
* overlaps another field. The highlight is a child of the field group so it moves
* and resizes with the field, and sits on top of the field's own rect (which is
* re-styled on every render and would otherwise clobber a direct stroke change).
*/
const syncOverlapHighlight = (fieldGroup: Konva.Group, isOverlapping: boolean) => {
const existingHighlight = fieldGroup.findOne('.field-overlap-highlight');
// Skip while a field is actively being dragged/resized. The highlight is driven
// by debounced field data, so it would lag behind and distort during the gesture.
// It is repainted once the gesture settles (the effect re-runs on isFieldChanging).
if (isFieldChanging) {
existingHighlight?.destroy();
return;
}
if (!isOverlapping) {
existingHighlight?.destroy();
return;
}
const fieldRect = fieldGroup.findOne('.field-rect');
if (!fieldRect) {
return;
}
const highlightAttrs = {
x: 0,
y: 0,
width: fieldRect.width(),
height: fieldRect.height(),
stroke: '#f59e0b',
strokeWidth: 2,
dash: [6, 4],
cornerRadius: 2,
strokeScaleEnabled: false,
listening: false,
} satisfies Partial<Konva.RectConfig>;
if (existingHighlight instanceof Konva.Rect) {
existingHighlight.setAttrs(highlightAttrs);
existingHighlight.moveToTop();
return;
}
const highlight = new Konva.Rect({
name: 'field-overlap-highlight',
...highlightAttrs,
});
fieldGroup.add(highlight);
highlight.moveToTop();
};
const unsafeRenderFieldOnLayer = (field: TLocalField) => {
if (!pageLayer.current) {
return;
@@ -139,6 +227,8 @@ export const EnvelopeEditorFieldsPageRenderer = ({ pageData }: { pageData: PageR
mode: 'edit',
});
syncOverlapHighlight(fieldGroup, overlappingFieldFormIds.has(field.formId));
if (!isFieldEditable) {
return;
}
@@ -435,7 +525,7 @@ export const EnvelopeEditorFieldsPageRenderer = ({ pageData }: { pageData: PageR
interactiveTransformer.current?.forceUpdate();
pageLayer.current.batchDraw();
}, [localPageFields, selectedKonvaFieldGroups]);
}, [localPageFields, selectedKonvaFieldGroups, overlappingFieldFormIds, isFieldChanging]);
const setSelectedFields = (nodes: Konva.Node[]) => {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
@@ -1,3 +1,4 @@
import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value';
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-provider';
import { PDF_VIEWER_ERROR_MESSAGES } from '@documenso/lib/constants/pdf-viewer-i18n';
@@ -17,6 +18,7 @@ import {
type TTextFieldMeta,
} from '@documenso/lib/types/field-meta';
import { getEnvelopeItemPermissions } from '@documenso/lib/utils/envelope';
import { getOverlappingFieldPairs } from '@documenso/lib/utils/fields-overlap';
import { canRecipientFieldsBeModified } from '@documenso/lib/utils/recipients';
import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animate-generic-fade-in-out';
import { cn } from '@documenso/ui/lib/utils';
@@ -28,7 +30,7 @@ import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { DocumentStatus, FieldType, RecipientRole } from '@prisma/client';
import { FileTextIcon, PencilIcon, SparklesIcon } from 'lucide-react';
import { AlertTriangleIcon, FileTextIcon, PencilIcon, SparklesIcon } from 'lucide-react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { useRevalidator, useSearchParams } from 'react-router';
import { isDeepEqual } from 'remeda';
@@ -78,7 +80,7 @@ export const EnvelopeEditorFieldsPage = () => {
const { envelope, editorFields, navigateToStep, editorConfig } = useCurrentEnvelopeEditor();
const { currentEnvelopeItem } = useCurrentEnvelopeRender();
const { currentEnvelopeItem, setCurrentEnvelopeItem } = useCurrentEnvelopeRender();
const { _ } = useLingui();
@@ -93,6 +95,53 @@ export const EnvelopeEditorFieldsPage = () => {
const selectedField = useMemo(() => structuredClone(editorFields.selectedField), [editorFields.selectedField]);
/**
* Debounce the fields used for overlap detection so we don't recompute on every
* small drag/resize movement, which is expensive on large field counts and can
* bog down lower-end devices.
*/
const debouncedLocalFields = useDebouncedValue(editorFields.localFields, 300);
/**
* Fields that significantly overlap each other. Overlapping fields render poorly in
* the editor and can behave unexpectedly during signing, so we warn the author here.
*/
const overlappingFieldPairs = useMemo(
() =>
getOverlappingFieldPairs(
debouncedLocalFields.map((field) => ({
id: field.formId,
envelopeItemId: field.envelopeItemId,
page: field.page,
positionX: field.positionX,
positionY: field.positionY,
width: field.width,
height: field.height,
})),
),
[debouncedLocalFields],
);
const handleReviewOverlappingField = () => {
const firstPair = overlappingFieldPairs[0];
if (!firstPair) {
return;
}
const targetField = editorFields.localFields.find((field) => field.formId === firstPair.fieldA.id);
if (!targetField) {
return;
}
if (targetField.envelopeItemId !== currentEnvelopeItem?.id) {
setCurrentEnvelopeItem(targetField.envelopeItemId);
}
editorFields.setSelectedField(targetField.formId);
};
const updateSelectedFieldMeta = (fieldMeta: TFieldMetaSchema) => {
if (!selectedField) {
return;
@@ -211,6 +260,29 @@ export const EnvelopeEditorFieldsPage = () => {
</Alert>
)}
{overlappingFieldPairs.length > 0 && (
<Alert
variant="warning"
className="mt-20 mb-4 flex w-full max-w-[800px] flex-row items-center justify-between space-y-0 rounded-sm"
>
<div className="flex flex-row items-start gap-3">
<AlertTriangleIcon className="mt-0.5 h-5 w-5 flex-shrink-0" />
<div className="flex flex-col gap-1">
<AlertTitle>
<Trans>Overlapping fields detected</Trans>
</AlertTitle>
<AlertDescription>
<Trans>
Some fields are placed on top of each other. This may complicate the signing process or cause
fields to not work as expected.
</Trans>
</AlertDescription>
</div>
</div>
</Alert>
)}
{currentEnvelopeItem !== null ? (
<EnvelopePdfViewer
customPageRenderer={EnvelopeEditorFieldsPageRenderer}
@@ -25,6 +25,7 @@ import {
EyeIcon,
FileOutputIcon,
FolderInput,
History,
Loader,
MoreHorizontal,
Pencil,
@@ -35,10 +36,10 @@ import {
import { useState } from 'react';
import { Link } from 'react-router';
import { DocumentResendDialog } from '~/components/dialogs/document-resend-dialog';
import { EnvelopeCancelDialog } from '~/components/dialogs/envelope-cancel-dialog';
import { EnvelopeDeleteDialog } from '~/components/dialogs/envelope-delete-dialog';
import { EnvelopeDuplicateDialog } from '~/components/dialogs/envelope-duplicate-dialog';
import { EnvelopeRedistributeDialog } from '~/components/dialogs/envelope-redistribute-dialog';
import { EnvelopeSaveAsTemplateDialog } from '~/components/dialogs/envelope-save-as-template-dialog';
import { DocumentRecipientLinkCopyDialog } from '~/components/general/document/document-recipient-link-copy-dialog';
import { useCurrentTeam } from '~/providers/team';
@@ -95,8 +96,6 @@ export const DocumentsTableActionDropdown = ({ row, onMoveDocument }: DocumentsT
const documentsPath = formatDocumentsPath(team.url);
const formatPath = `${documentsPath}/${row.envelopeId}/edit`;
const nonSignedRecipients = row.recipients.filter((item) => item.signingStatus !== 'SIGNED');
return (
<DropdownMenu>
<DropdownMenuTrigger data-testid="document-table-action-btn">
@@ -244,7 +243,25 @@ export const DocumentsTableActionDropdown = ({ row, onMoveDocument }: DocumentsT
/>
)}
<DocumentResendDialog document={row} recipients={nonSignedRecipients} />
{canManageDocument && (
<EnvelopeRedistributeDialog
envelope={{
id: row.envelopeId,
status: row.status,
type: EnvelopeType.DOCUMENT,
recipients: row.recipients,
}}
envelopeType={EnvelopeType.DOCUMENT}
trigger={
<DropdownMenuItem asChild onSelect={(e) => e.preventDefault()}>
<div>
<History className="mr-2 h-4 w-4" />
<Trans>Resend</Trans>
</div>
</DropdownMenuItem>
}
/>
)}
<DocumentShareButton
documentId={row.id}
@@ -29,7 +29,7 @@ export type DocumentsTableProps = {
data?: TFindDocumentsResponse;
isLoading?: boolean;
isLoadingError?: boolean;
onMoveDocument?: (documentId: number) => void;
onMoveDocument?: (envelopeId: string) => void;
enableSelection?: boolean;
rowSelection?: RowSelectionState;
onRowSelectionChange?: (selection: RowSelectionState) => void;
@@ -117,7 +117,7 @@ export const DocumentsTable = ({
<DocumentsTableActionButton row={row.original} />
<DocumentsTableActionDropdown
row={row.original}
onMoveDocument={onMoveDocument ? () => onMoveDocument(row.original.id) : undefined}
onMoveDocument={onMoveDocument ? () => onMoveDocument(row.original.envelopeId) : undefined}
/>
</div>
),
@@ -29,7 +29,7 @@ import { useSearchParams } from 'react-router';
import { useCurrentTeam } from '~/providers/team';
import { TeamMemberDeleteDialog } from '../dialogs/team-member-delete-dialog';
import { TeamMemberDeleteDialog, type TeamMemberDeleteDisableReason } from '../dialogs/team-member-delete-dialog';
import { TeamMemberUpdateDialog } from '../dialogs/team-member-update-dialog';
import { TeamInheritMemberAlert } from '../general/teams/team-inherit-member-alert';
@@ -86,6 +86,39 @@ export const TeamMembersTable = () => {
);
const columns = useMemo(() => {
// A member is a direct team member when they belong to one of the team's
// INTERNAL_TEAM groups. Otherwise they are inherited from an organisation or
// custom group and cannot be managed directly from this team.
const isMemberPartOfInternalTeamGroup = (memberId: string) =>
groups.some(
(group) =>
group.organisationGroupType === OrganisationGroupType.INTERNAL_TEAM &&
group.members.some((member) => member.id === memberId),
);
// Determine why a member can't be removed from the team (if at all). The delete
// dialog uses this to explain the reason instead of attempting a removal that
// would fail.
const getDeleteDisableReason = (member: (typeof results)['data'][number]): TeamMemberDeleteDisableReason | null => {
if (organisation.ownerUserId === member.userId) {
return 'TEAM_OWNER';
}
if (!isTeamRoleWithinUserHierarchy(team.currentTeamRole, member.teamRole)) {
return 'HIGHER_ROLE';
}
if (memberAccessTeamGroup !== undefined) {
return 'INHERIT_MEMBER_ENABLED';
}
if (!isMemberPartOfInternalTeamGroup(member.id)) {
return 'INHERITED_MEMBER';
}
return null;
};
return [
{
header: _(msg`Team Member`),
@@ -111,15 +144,7 @@ export const TeamMembersTable = () => {
},
{
header: _(msg`Source`),
cell: ({ row }) => {
const internalTeamGroupFound = groups.find(
(group) =>
group.organisationGroupType === OrganisationGroupType.INTERNAL_TEAM &&
group.members.some((member) => member.id === row.original.id),
);
return internalTeamGroupFound ? _(msg`Member`) : _(msg`Group`);
},
cell: ({ row }) => (isMemberPartOfInternalTeamGroup(row.original.id) ? _(msg`Member`) : _(msg`Group`)),
},
{
header: _(msg`Actions`),
@@ -161,16 +186,9 @@ export const TeamMembersTable = () => {
memberId={row.original.id}
memberName={row.original.name ?? ''}
memberEmail={row.original.email}
isInheritMemberEnabled={memberAccessTeamGroup !== undefined}
disableReason={getDeleteDisableReason(row.original)}
trigger={
<DropdownMenuItem
onSelect={(e) => e.preventDefault()}
disabled={
organisation.ownerUserId === row.original.userId ||
!isTeamRoleWithinUserHierarchy(team.currentTeamRole, row.original.teamRole)
}
title={_(msg`Remove team member`)}
>
<DropdownMenuItem onSelect={(e) => e.preventDefault()} title={_(msg`Remove team member`)}>
<Trash2Icon className="mr-2 h-4 w-4" />
<Trans>Remove</Trans>
</DropdownMenuItem>
@@ -11,15 +11,15 @@ import { Trans } from '@lingui/react/macro';
import { DocumentStatus, EnvelopeType, type TemplateDirectLink } from '@prisma/client';
import { Copy, Download, Edit, FolderIcon, MoreHorizontal, Pencil, Share2Icon, Trash2, Upload } from 'lucide-react';
import { useState } from 'react';
import { Link } from 'react-router';
import { Link, useNavigate } from 'react-router';
import { EnvelopeDeleteDialog } from '../dialogs/envelope-delete-dialog';
import { EnvelopeDownloadDialog } from '../dialogs/envelope-download-dialog';
import { EnvelopeDuplicateDialog } from '../dialogs/envelope-duplicate-dialog';
import { EnvelopeRenameDialog } from '../dialogs/envelope-rename-dialog';
import { EnvelopesBulkMoveDialog } from '../dialogs/envelopes-bulk-move-dialog';
import { TemplateBulkSendDialog } from '../dialogs/template-bulk-send-dialog';
import { TemplateDirectLinkDialog } from '../dialogs/template-direct-link-dialog';
import { TemplateMoveToFolderDialog } from '../dialogs/template-move-to-folder-dialog';
export type TemplatesTableActionDropdownProps = {
row: {
@@ -44,6 +44,7 @@ export const TemplatesTableActionDropdown = ({
onDelete,
}: TemplatesTableActionDropdownProps) => {
const trpcUtils = trpcReact.useUtils();
const navigate = useNavigate();
const [isRenameDialogOpen, setRenameDialogOpen] = useState(false);
const [isMoveToFolderDialogOpen, setMoveToFolderDialogOpen] = useState(false);
@@ -153,12 +154,13 @@ export const TemplatesTableActionDropdown = ({
)}
</DropdownMenuContent>
<TemplateMoveToFolderDialog
templateId={row.id}
templateTitle={row.title}
isOpen={isMoveToFolderDialogOpen}
<EnvelopesBulkMoveDialog
envelopeIds={[row.envelopeId]}
envelopeType={EnvelopeType.TEMPLATE}
open={isMoveToFolderDialogOpen}
onOpenChange={setMoveToFolderDialogOpen}
currentFolderId={row.folderId}
currentFolderId={row.folderId ?? undefined}
onSuccess={(folderId) => navigate(folderId ? `${templateRootPath}/f/${folderId}` : templateRootPath)}
/>
<EnvelopeRenameDialog
@@ -16,10 +16,9 @@ import { msg } from '@lingui/core/macro';
import { Trans } from '@lingui/react/macro';
import { EnvelopeType, FolderType, OrganisationType } from '@prisma/client';
import { useEffect, useMemo, useState } from 'react';
import { Link, useParams, useSearchParams } from 'react-router';
import { Link, useNavigate, useParams, useSearchParams } from 'react-router';
import { z } from 'zod';
import { DocumentMoveToFolderDialog } from '~/components/dialogs/document-move-to-folder-dialog';
import { EnvelopesBulkCancelDialog } from '~/components/dialogs/envelopes-bulk-cancel-dialog';
import { EnvelopesBulkDeleteDialog } from '~/components/dialogs/envelopes-bulk-delete-dialog';
import { EnvelopesBulkMoveDialog } from '~/components/dialogs/envelopes-bulk-move-dialog';
@@ -55,9 +54,12 @@ export default function DocumentsPage() {
const { folderId } = useParams();
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const documentsPath = formatDocumentsPath(team.url);
const [isMovingDocument, setIsMovingDocument] = useState(false);
const [documentToMove, setDocumentToMove] = useState<number | null>(null);
const [documentToMove, setDocumentToMove] = useState<string | null>(null);
const [rowSelection, setRowSelection] = useSessionStorage<RowSelectionState>('documents-bulk-selection', {});
const [isBulkMoveDialogOpen, setIsBulkMoveDialogOpen] = useState(false);
@@ -200,8 +202,8 @@ export default function DocumentsPage() {
data={data}
isLoading={isLoading}
isLoadingError={isLoadingError}
onMoveDocument={(documentId) => {
setDocumentToMove(documentId);
onMoveDocument={(envelopeId) => {
setDocumentToMove(envelopeId);
setIsMovingDocument(true);
}}
enableSelection
@@ -213,8 +215,9 @@ export default function DocumentsPage() {
</div>
{documentToMove && (
<DocumentMoveToFolderDialog
documentId={documentToMove}
<EnvelopesBulkMoveDialog
envelopeIds={[documentToMove]}
envelopeType={EnvelopeType.DOCUMENT}
open={isMovingDocument}
currentFolderId={folderId}
onOpenChange={(open) => {
@@ -224,6 +227,9 @@ export default function DocumentsPage() {
setDocumentToMove(null);
}
}}
onSuccess={(destinationFolderId) =>
navigate(destinationFolderId ? `${documentsPath}/f/${destinationFolderId}` : documentsPath)
}
/>
)}
+1 -1
View File
@@ -11,7 +11,7 @@ export const OpenAPIV1 = Object.assign(
title: 'Documenso API',
version: '1.0.0',
description:
'API V1 is deprecated, but will continue to be supported. For more details, see https://docs.documenso.com/developers/public-api. \n\nThe Documenso API for retrieving, creating, updating and deleting documents.',
'API V1 has been deprecated. For more details, see https://docs.documenso.com/docs/developers/api/migrate-to-envelopes. \n\nThe Documenso API for retrieving, creating, updating and deleting documents.',
},
servers: [
{
@@ -0,0 +1,64 @@
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { createApiToken } from '@documenso/lib/server-only/public-api/create-api-token';
import { mapSecondaryIdToDocumentId } from '@documenso/lib/utils/envelope';
import { prisma } from '@documenso/prisma';
import { SendStatus, SigningStatus } from '@documenso/prisma/client';
import { seedPendingDocument } from '@documenso/prisma/seed/documents';
import { seedUser } from '@documenso/prisma/seed/users';
import { expect, test } from '@playwright/test';
import type { Team, User } from '@prisma/client';
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
const baseUrl = `${WEBAPP_BASE_URL}/api/v2-beta`;
test.describe.configure({
mode: 'parallel',
});
test.describe('Redistribute updates recipient send status', () => {
let user: User, team: Team, token: string;
test.beforeEach(async () => {
({ user, team } = await seedUser());
({ token } = await createApiToken({
userId: user.id,
teamId: team.id,
tokenName: 'test',
expiresIn: null,
}));
});
test('marks a NOT_SENT signer as SENT after a successful resend', async ({ request }) => {
const document = await seedPendingDocument(user, team.id, ['recipient@test.documenso.com']);
const [recipient] = document.recipients;
// Simulate a recipient that is stuck at NOT_SENT on a pending document
// (e.g. the initial send did not dispatch an email for them).
await prisma.recipient.update({
where: { id: recipient.id },
data: {
sendStatus: SendStatus.NOT_SENT,
signingStatus: SigningStatus.NOT_SIGNED,
sentAt: null,
},
});
const res = await request.post(`${baseUrl}/document/redistribute`, {
headers: { Authorization: `Bearer ${token}` },
data: {
documentId: mapSecondaryIdToDocumentId(document.secondaryId),
recipients: [recipient.id],
},
});
expect(res.ok(), `redistribute should succeed: ${await res.text()}`).toBeTruthy();
const updatedRecipient = await prisma.recipient.findFirstOrThrow({
where: { id: recipient.id },
});
expect(updatedRecipient.sendStatus).toBe(SendStatus.SENT);
expect(updatedRecipient.sentAt).not.toBeNull();
});
});
@@ -0,0 +1,260 @@
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { createApiToken } from '@documenso/lib/server-only/public-api/create-api-token';
import { prisma } from '@documenso/prisma';
import { DocumentVisibility, SigningStatus, TeamMemberRole } from '@documenso/prisma/client';
import { seedPendingDocument } from '@documenso/prisma/seed/documents';
import { seedTeam, seedTeamMember } from '@documenso/prisma/seed/teams';
import { seedUser } from '@documenso/prisma/seed/users';
import type { TRejectEnvelopeRecipientOnBehalfOfRequest } from '@documenso/trpc/server/envelope-router/envelope-recipients/reject-envelope-recipient-on-behalf-of.types';
import { type APIRequestContext, expect, test } from '@playwright/test';
import type { Team, User } from '@prisma/client';
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
const baseUrl = `${WEBAPP_BASE_URL}/api/v2-beta`;
test.describe.configure({
mode: 'parallel',
});
const rejectRecipient = (
request: APIRequestContext,
authToken: string,
envelopeId: string,
recipientId: number,
reason: string,
actAsEmail?: string,
) => {
return request.post(`${baseUrl}/envelope/recipient/${recipientId}/reject`, {
headers: { Authorization: `Bearer ${authToken}`, 'Content-Type': 'application/json' },
data: {
envelopeId,
recipientId,
reason,
actAsEmail,
} satisfies TRejectEnvelopeRecipientOnBehalfOfRequest,
});
};
test.describe('Reject recipient on behalf of', () => {
let user: User;
let team: Team;
let token: string;
test.beforeEach(async () => {
({ user, team } = await seedUser());
({ token } = await createApiToken({
userId: user.id,
teamId: team.id,
tokenName: 'test-reject-recipient',
expiresIn: null,
}));
});
test('should reject a recipient and record an external rejection audit log', async ({ request }) => {
const envelope = await seedPendingDocument(user, team.id, ['recipient@test.documenso.com']);
const recipient = envelope.recipients[0];
const res = await rejectRecipient(request, token, envelope.id, recipient.id, 'Declined out of band');
expect(res.ok()).toBeTruthy();
expect(res.status()).toBe(200);
const updatedRecipient = await prisma.recipient.findUniqueOrThrow({
where: { id: recipient.id },
});
expect(updatedRecipient.signingStatus).toBe(SigningStatus.REJECTED);
expect(updatedRecipient.rejectionReason).toBe('Declined out of band');
const auditLog = await prisma.documentAuditLog.findFirst({
where: {
envelopeId: envelope.id,
type: 'DOCUMENT_RECIPIENT_REJECTED',
},
orderBy: { createdAt: 'desc' },
});
expect(auditLog).not.toBeNull();
const auditData = auditLog!.data as Record<string, unknown>;
expect(auditData.recipientId).toBe(recipient.id);
expect(auditData.recipientEmail).toBe(recipient.email);
expect(auditData.reason).toBe('Declined out of band');
expect(auditData.isExternal).toBe(true);
// No actAsEmail supplied - the rejection defaults to the API user.
expect(auditLog!.userId).toBe(user.id);
expect(auditLog!.email).toBe(user.email);
expect(auditData.onBehalfOfUserEmail).toBeUndefined();
});
test('should attribute the rejection to the elected team member when actAsEmail is supplied', async ({ request }) => {
const member = await seedTeamMember({ teamId: team.id });
const envelope = await seedPendingDocument(user, team.id, ['recipient@test.documenso.com']);
const recipient = envelope.recipients[0];
const res = await rejectRecipient(request, token, envelope.id, recipient.id, 'Declined out of band', member.email);
expect(res.ok()).toBeTruthy();
expect(res.status()).toBe(200);
const auditLog = await prisma.documentAuditLog.findFirstOrThrow({
where: {
envelopeId: envelope.id,
type: 'DOCUMENT_RECIPIENT_REJECTED',
},
orderBy: { createdAt: 'desc' },
});
// The audit log actor must be the elected member, not the API user.
expect(auditLog.userId).toBe(member.id);
expect(auditLog.email).toBe(member.email);
const auditData = auditLog.data as Record<string, unknown>;
expect(auditData.isExternal).toBe(true);
expect(auditData.onBehalfOfUserEmail).toBe(member.email);
});
test('should reject when actAsEmail is not a member of the team', async ({ request }) => {
// A user that exists but belongs to a different team.
const { user: outsider } = await seedUser();
const envelope = await seedPendingDocument(user, team.id, ['recipient@test.documenso.com']);
const recipient = envelope.recipients[0];
const res = await rejectRecipient(
request,
token,
envelope.id,
recipient.id,
'Declined out of band',
outsider.email,
);
expect(res.ok()).toBeFalsy();
expect(res.status()).toBe(401);
// The recipient must remain untouched.
const untouchedRecipient = await prisma.recipient.findUniqueOrThrow({
where: { id: recipient.id },
});
expect(untouchedRecipient.signingStatus).toBe(SigningStatus.NOT_SIGNED);
expect(untouchedRecipient.rejectionReason).toBeNull();
});
test('should deny rejecting a recipient that has already actioned the document', async ({ request }) => {
const envelope = await seedPendingDocument(user, team.id, ['recipient@test.documenso.com']);
const recipient = envelope.recipients[0];
// Reject once - succeeds.
const firstRes = await rejectRecipient(request, token, envelope.id, recipient.id, 'First rejection');
expect(firstRes.ok()).toBeTruthy();
// Reject again - the recipient is no longer NOT_SIGNED.
const secondRes = await rejectRecipient(request, token, envelope.id, recipient.id, 'Second rejection');
expect(secondRes.ok()).toBeFalsy();
expect(secondRes.status()).toBe(400);
// The original rejection reason must remain unchanged.
const updatedRecipient = await prisma.recipient.findUniqueOrThrow({
where: { id: recipient.id },
});
expect(updatedRecipient.rejectionReason).toBe('First rejection');
});
test('should not allow rejecting a recipient in another team', async ({ request }) => {
// Seed a separate team/user that owns the document.
const { user: otherUser, team: otherTeam } = await seedUser();
const envelope = await seedPendingDocument(otherUser, otherTeam.id, ['recipient@test.documenso.com']);
const recipient = envelope.recipients[0];
// Use the original team's token - it must not be able to reject.
const res = await rejectRecipient(request, token, envelope.id, recipient.id, 'Should not work');
expect(res.ok()).toBeFalsy();
expect(res.status()).toBe(404);
// The recipient must remain untouched.
const untouchedRecipient = await prisma.recipient.findUniqueOrThrow({
where: { id: recipient.id },
});
expect(untouchedRecipient.signingStatus).toBe(SigningStatus.NOT_SIGNED);
expect(untouchedRecipient.rejectionReason).toBeNull();
});
test('should return 404 for a non-existent recipient', async ({ request }) => {
const envelope = await seedPendingDocument(user, team.id, ['recipient@test.documenso.com']);
const res = await rejectRecipient(request, token, envelope.id, 999999999, 'No such recipient');
expect(res.ok()).toBeFalsy();
expect(res.status()).toBe(404);
});
test('should return 404 when the recipient does not belong to the supplied envelope', async ({ request }) => {
const targetEnvelope = await seedPendingDocument(user, team.id, ['recipient@test.documenso.com']);
const otherEnvelope = await seedPendingDocument(user, team.id, ['other-recipient@test.documenso.com']);
const recipient = targetEnvelope.recipients[0];
// Valid recipient ID, but paired with the wrong envelope ID.
const res = await rejectRecipient(request, token, otherEnvelope.id, recipient.id, 'Mismatched envelope');
expect(res.ok()).toBeFalsy();
expect(res.status()).toBe(404);
// The recipient must remain untouched.
const untouchedRecipient = await prisma.recipient.findUniqueOrThrow({
where: { id: recipient.id },
});
expect(untouchedRecipient.signingStatus).toBe(SigningStatus.NOT_SIGNED);
expect(untouchedRecipient.rejectionReason).toBeNull();
});
test('should enforce document visibility: manager cannot reject on an ADMIN-only document', async ({ request }) => {
// The API token belongs to a MANAGER, who cannot see ADMIN-visibility docs.
const { team: visTeam, owner } = await seedTeam();
const manager = await seedTeamMember({ teamId: visTeam.id, role: TeamMemberRole.MANAGER });
const { token: managerToken } = await createApiToken({
userId: manager.id,
teamId: visTeam.id,
tokenName: 'manager-reject-token',
expiresIn: null,
});
// ADMIN-visibility document owned by the team owner.
const envelope = await seedPendingDocument(owner, visTeam.id, ['recipient@test.documenso.com'], {
createDocumentOptions: { visibility: DocumentVisibility.ADMIN },
});
const recipient = envelope.recipients[0];
const res = await rejectRecipient(
request,
managerToken,
envelope.id,
recipient.id,
'Should be hidden by visibility',
);
// Visibility failure surfaces as not-found, matching the canonical checks.
expect(res.ok()).toBeFalsy();
expect(res.status()).toBe(404);
const untouchedRecipient = await prisma.recipient.findUniqueOrThrow({
where: { id: recipient.id },
});
expect(untouchedRecipient.signingStatus).toBe(SigningStatus.NOT_SIGNED);
expect(untouchedRecipient.rejectionReason).toBeNull();
});
});
@@ -3,6 +3,7 @@ import path from 'node:path';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { createApiToken } from '@documenso/lib/server-only/public-api/create-api-token';
import { prisma } from '@documenso/prisma';
import { seedDraftDocument } from '@documenso/prisma/seed/documents';
import { seedTemplate } from '@documenso/prisma/seed/templates';
import { seedUser } from '@documenso/prisma/seed/users';
import type {
@@ -302,6 +303,95 @@ test.describe('document editor', () => {
expect(envelopes.length).toBeGreaterThanOrEqual(2);
});
test('duplicate document without recipients excludes recipients and fields', async ({ page }) => {
const { user, team } = await seedUser();
// Seed a draft document that has a recipient with a field.
const document = await seedDraftDocument(user, team.id, ['signer@test.documenso.com'], {
key: `dup-exclude-recipients-${Date.now()}`,
internalVersion: 2,
});
await apiSignin({
page,
email: user.email,
redirectPath: `/t/${team.url}/documents/${document.id}/edit`,
});
await expect(page.getByRole('heading', { name: 'Documents' })).toBeVisible();
// Open the duplicate dialog.
await page.locator('button[title="Duplicate Envelope"]').click();
await expect(page.getByRole('heading', { name: 'Duplicate Document' })).toBeVisible();
// Uncheck "Include Recipients" — this also disables and unchecks "Include Fields".
await page.getByLabel('Include Recipients').click();
await expect(page.getByLabel('Include Fields')).toBeDisabled();
// Duplicate.
await page.getByRole('button', { name: 'Duplicate' }).click();
await expectToastTextToBeVisible(page, 'Document Duplicated');
await expect(page).toHaveURL(/\/documents\/.*\/edit/);
// The duplicate should have neither recipients nor fields.
const duplicate = await prisma.envelope.findFirstOrThrow({
where: {
teamId: team.id,
type: EnvelopeType.DOCUMENT,
id: { not: document.id },
},
include: { recipients: true, fields: true },
orderBy: { createdAt: 'desc' },
});
expect(duplicate.recipients).toHaveLength(0);
expect(duplicate.fields).toHaveLength(0);
});
test('duplicate document without fields keeps recipients but excludes fields', async ({ page }) => {
const { user, team } = await seedUser();
// Seed a draft document that has a recipient with a field.
const document = await seedDraftDocument(user, team.id, ['signer@test.documenso.com'], {
key: `dup-exclude-fields-${Date.now()}`,
internalVersion: 2,
});
await apiSignin({
page,
email: user.email,
redirectPath: `/t/${team.url}/documents/${document.id}/edit`,
});
await expect(page.getByRole('heading', { name: 'Documents' })).toBeVisible();
// Open the duplicate dialog.
await page.locator('button[title="Duplicate Envelope"]').click();
await expect(page.getByRole('heading', { name: 'Duplicate Document' })).toBeVisible();
// Uncheck only "Include Fields" (recipients stay included).
await page.getByLabel('Include Fields').click();
// Duplicate.
await page.getByRole('button', { name: 'Duplicate' }).click();
await expectToastTextToBeVisible(page, 'Document Duplicated');
await expect(page).toHaveURL(/\/documents\/.*\/edit/);
// The duplicate should keep the recipient but have no fields.
const duplicate = await prisma.envelope.findFirstOrThrow({
where: {
teamId: team.id,
type: EnvelopeType.DOCUMENT,
id: { not: document.id },
},
include: { recipients: true, fields: true },
orderBy: { createdAt: 'desc' },
});
expect(duplicate.recipients).toHaveLength(1);
expect(duplicate.fields).toHaveLength(0);
});
test('download PDF dialog shows envelope items', async ({ page }) => {
await openDocumentEnvelopeEditor(page);
@@ -270,7 +270,7 @@ test('[ENVELOPE_EXPIRATION]: resending refreshes expiresAt', async ({ page }) =>
await page.getByLabel('test.documenso.com').first().click();
await page.getByRole('button', { name: 'Send reminder' }).click();
await expect(page.getByText('Document re-sent', { exact: true })).toBeVisible({
await expect(page.getByText('Document resent', { exact: true })).toBeVisible({
timeout: 10_000,
});
@@ -238,7 +238,7 @@ test('[TEAMS]: resend pending team document', async ({ page }) => {
await page.getByLabel('test.documenso.com').first().click();
await page.getByRole('button', { name: 'Send reminder' }).click();
await expectToastTextToBeVisible(page, 'Document re-sent');
await expectToastTextToBeVisible(page, 'Document resent');
});
test('[TEAMS]: delete draft team document', async ({ page }) => {
@@ -0,0 +1,105 @@
import { seedOrganisationMembers } from '@documenso/prisma/seed/organisations';
import { seedTeamMember } from '@documenso/prisma/seed/teams';
import { seedUser } from '@documenso/prisma/seed/users';
import { expect, test } from '@playwright/test';
import { OrganisationMemberRole, TeamMemberRole } from '@prisma/client';
import { apiSignin } from '../fixtures/authentication';
import { openDropdownMenu } from '../fixtures/generic';
/**
* Reproduces the "Team has no internal team groups" bug.
*
* When a team has member inheritance turned OFF, organisation admins/managers are
* still inherited into the team as team admins (shown with the "Group" source).
* These members are not part of the team's INTERNAL_TEAM group, so they cannot be
* removed via the team members page - attempting to do so threw a 500 ("Team has no
* internal team groups").
*
* Instead of crashing, the delete dialog must explain why the inherited member can't
* be removed and not offer a confirm button.
*/
test('[TEAMS]: explains why an inherited organisation member cannot be removed', async ({ page }) => {
// Team created with member inheritance OFF.
const { user: owner, organisation, team } = await seedUser({ inheritMembers: false });
const inheritedAdminEmail = `inherited-admin-${team.url}@test.documenso.com`;
// A second organisation admin is inherited into the team as a team admin (source "Group").
await seedOrganisationMembers({
organisationId: organisation.id,
members: [
{
name: 'Inherited Admin',
email: inheritedAdminEmail,
organisationRole: OrganisationMemberRole.ADMIN,
},
],
});
await apiSignin({
page,
email: owner.email,
redirectPath: `/t/${team.url}/settings/members`,
});
const inheritedMemberRow = page.getByRole('row').filter({ hasText: inheritedAdminEmail });
// Sanity check: the member is inherited from a group, not a direct team member.
await expect(inheritedMemberRow).toBeVisible();
await expect(inheritedMemberRow).toContainText('Group');
await openDropdownMenu(page, inheritedMemberRow.getByRole('button').last());
// The action stays enabled - opening it shows a dialog explaining why the inherited
// member can't be removed, rather than triggering the 500.
const removeMenuItem = page.getByRole('menuitem', { name: 'Remove' });
await expect(removeMenuItem).toBeEnabled();
await removeMenuItem.click();
await expect(page.getByText('inherited from a group').first()).toBeVisible();
// No confirm button is offered, so the broken removal can never be triggered.
await expect(page.getByRole('button', { name: 'Remove' })).toHaveCount(0);
});
/**
* Guards against over-disabling the remove action: a direct team member (one that
* belongs to the team's INTERNAL_TEAM group) must still be removable.
*/
test('[TEAMS]: can remove a direct team member', async ({ page }) => {
const { user: owner, team } = await seedUser({ inheritMembers: false });
const directMember = await seedTeamMember({
teamId: team.id,
name: 'Direct Member',
role: TeamMemberRole.MEMBER,
});
await apiSignin({
page,
email: owner.email,
redirectPath: `/t/${team.url}/settings/members`,
});
const directMemberRow = page.getByRole('row').filter({ hasText: directMember.email });
await expect(directMemberRow).toBeVisible();
await openDropdownMenu(page, directMemberRow.getByRole('button').last());
const removeMenuItem = page.getByRole('menuitem', { name: 'Remove' });
// The "Remove" action is enabled for direct members and removing them succeeds.
await expect(removeMenuItem).toBeEnabled();
await removeMenuItem.click();
await page.getByRole('button', { name: 'Remove' }).click();
await expect(page.getByText('You have successfully removed this user from the team.').first()).toBeVisible();
// The member is actually gone after reloading the members list.
await page.reload();
await expect(page.getByRole('row').filter({ hasText: owner.email })).toBeVisible();
await expect(page.getByRole('row').filter({ hasText: directMember.email })).toHaveCount(0);
});
+3 -2
View File
@@ -12,12 +12,13 @@
"index.ts"
],
"scripts": {
"dev": "email dev --port 3002 --dir templates",
"dev": "react-router dev --config preview/vite.config.ts",
"preview:build": "react-router build --config preview/vite.config.ts",
"clean": "rimraf node_modules"
},
"dependencies": {
"@documenso/tailwind-config": "*",
"@documenso/nodemailer-resend": "4.0.0",
"@documenso/tailwind-config": "*",
"@react-email/body": "0.2.0",
"@react-email/button": "0.2.0",
"@react-email/code-block": "0.2.0",
+2
View File
@@ -0,0 +1,2 @@
/.react-router/
/build/
+9
View File
@@ -0,0 +1,9 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
html,
body {
margin: 0;
padding: 0;
}
@@ -0,0 +1,337 @@
import { SUPPORTED_LANGUAGE_CODES } from '@documenso/lib/constants/locales';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useNavigate } from 'react-router';
import type { FieldConfig } from '../lib/templates';
import { templates } from '../lib/templates';
import { viewports } from '../lib/viewports';
import { PropFields } from './prop-fields';
type Theme = 'light' | 'dark';
const GROUP_ORDER = ['Documents', 'Recipients', 'Organisations', 'Teams', 'Account', 'Admin'] as const;
const LANGUAGE_LABELS: Record<string, string> = {
en: 'English',
de: 'German',
fr: 'French',
es: 'Spanish',
it: 'Italian',
nl: 'Dutch',
pl: 'Polish',
'pt-BR': 'Portuguese (Brazil)',
ja: 'Japanese',
ko: 'Korean',
zh: 'Chinese',
};
const DEFAULT_COLORS = {
primary: '#a2e771',
primaryForeground: '#162c07',
background: '#ffffff',
foreground: '#0f172a',
};
type PlaygroundProps = {
slug: string;
fields: Record<string, FieldConfig>;
defaultProps: Record<string, unknown>;
};
export const EmailPlayground = ({ slug, fields, defaultProps }: PlaygroundProps) => {
const navigate = useNavigate();
const [props, setProps] = useState(defaultProps);
const [html, setHtml] = useState('');
const [loading, setLoading] = useState(false);
const [theme, setTheme] = useState<Theme>('light');
const [viewportIndex, setViewportIndex] = useState(2);
const [lang, setLang] = useState('en');
const [brandingEnabled, setBrandingEnabled] = useState(false);
const [colors, setColors] = useState(DEFAULT_COLORS);
const debounceRef = useRef<ReturnType<typeof setTimeout>>(undefined);
const groupedTemplates = useMemo(() => {
const entries = Object.entries(templates);
return GROUP_ORDER.map((group) => ({
group,
entries: entries.filter(([, def]) => def.group === group),
})).filter((section) => section.entries.length > 0);
}, []);
const fetchHtml = useCallback(
async (currentProps: Record<string, unknown>, currentLang: string, brandColors: typeof colors | null) => {
setLoading(true);
try {
const response = await fetch('/api/render', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
slug,
props: currentProps,
lang: currentLang,
colors: brandColors,
assetBaseUrl: window.location.origin,
}),
});
if (response.ok) {
setHtml(await response.text());
}
} finally {
setLoading(false);
}
},
[slug],
);
// Reset props when navigating to a different template.
useEffect(() => {
setProps(defaultProps);
}, [defaultProps]);
// Re-render on any input change (debounced).
useEffect(() => {
clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => {
void fetchHtml(props, lang, brandingEnabled ? colors : null);
}, 250);
return () => clearTimeout(debounceRef.current);
}, [props, lang, brandingEnabled, colors, fetchHtml]);
const handlePropChange = (key: string, value: unknown) => {
setProps((prev) => ({ ...prev, [key]: value }));
};
const handleColorChange = (key: keyof typeof colors, value: string) => {
setColors((prev) => ({ ...prev, [key]: value }));
};
// Force dark mode inside the iframe by neutralising the prefers-color-scheme
// media query (color-scheme alone doesn't trigger it inside an iframe).
const displayHtml = theme === 'dark' && html ? html.replaceAll(/prefers-color-scheme:\s*dark/g, 'min-width:0') : html;
const viewport = viewports[viewportIndex];
return (
<div className="flex h-screen w-screen overflow-hidden bg-neutral-100 font-sans text-neutral-900">
{/* Sidebar */}
<aside className="flex h-full w-60 flex-shrink-0 flex-col overflow-y-auto border-neutral-200 border-r bg-white">
<div className="border-neutral-200 border-b px-4 py-3">
<h1 className="font-semibold text-sm">Email Preview</h1>
<p className="text-neutral-500 text-xs">{Object.keys(templates).length} templates</p>
</div>
<nav className="flex-1 px-2 py-2">
{groupedTemplates.map((section) => (
<div key={section.group} className="mb-3">
<div className="px-2 py-1 font-medium text-neutral-400 text-xs uppercase tracking-wide">
{section.group}
</div>
{section.entries.map(([id, def]) => (
<button
key={id}
type="button"
onClick={() => navigate(`/${id}`)}
className={`block w-full rounded-md px-2 py-1.5 text-left text-sm transition-colors ${
slug === id ? 'bg-neutral-900 text-white' : 'text-neutral-700 hover:bg-neutral-100'
}`}
>
{def.name}
</button>
))}
</div>
))}
</nav>
</aside>
{/* Props panel */}
<section className="flex h-full w-72 flex-shrink-0 flex-col overflow-y-auto border-neutral-200 border-r bg-white px-4 py-3">
<h2 className="mb-3 font-medium text-neutral-500 text-xs uppercase tracking-wide">Props</h2>
<PropFields fields={fields} values={props} onChange={handlePropChange} />
</section>
{/* Main */}
<main className="flex h-full flex-1 flex-col overflow-hidden">
<Toolbar
theme={theme}
setTheme={setTheme}
viewportIndex={viewportIndex}
setViewportIndex={setViewportIndex}
lang={lang}
setLang={setLang}
brandingEnabled={brandingEnabled}
setBrandingEnabled={setBrandingEnabled}
colors={colors}
onColorChange={handleColorChange}
loading={loading}
/>
<div
className={`flex flex-1 items-start justify-center overflow-auto p-6 ${
theme === 'dark' ? 'bg-neutral-800' : 'bg-neutral-200'
}`}
>
<div
className="flex-shrink-0 overflow-hidden rounded-lg bg-white shadow-lg"
style={{ width: viewport.width }}
>
<iframe
title={`${viewport.name} ${theme}`}
srcDoc={displayHtml}
className="h-[calc(100vh-8rem)] w-full border-0"
style={{ colorScheme: theme }}
/>
</div>
</div>
</main>
</div>
);
};
type ToolbarProps = {
theme: Theme;
setTheme: (theme: Theme) => void;
viewportIndex: number;
setViewportIndex: (index: number) => void;
lang: string;
setLang: (lang: string) => void;
brandingEnabled: boolean;
setBrandingEnabled: (enabled: boolean) => void;
colors: typeof DEFAULT_COLORS;
onColorChange: (key: keyof typeof DEFAULT_COLORS, value: string) => void;
loading: boolean;
};
const Toolbar = (props: ToolbarProps) => {
return (
<div className="flex flex-wrap items-center gap-4 border-neutral-200 border-b bg-white px-4 py-2">
<SegmentedControl
label="Theme"
value={props.theme}
options={[
{ value: 'light', label: 'Light' },
{ value: 'dark', label: 'Dark' },
]}
onChange={(value) => props.setTheme(value as Theme)}
/>
<SegmentedControl
label="Viewport"
value={String(props.viewportIndex)}
options={viewports.map((viewport, index) => ({ value: String(index), label: viewport.name }))}
onChange={(value) => props.setViewportIndex(Number(value))}
/>
<label className="flex items-center gap-1.5 text-neutral-600 text-xs">
<span className="font-medium">Language</span>
<select
value={props.lang}
onChange={(event) => props.setLang(event.target.value)}
className="rounded-md border border-neutral-300 bg-white px-2 py-1 text-neutral-900 text-xs"
>
{SUPPORTED_LANGUAGE_CODES.map((code) => (
<option key={code} value={code}>
{LANGUAGE_LABELS[code] ?? code}
</option>
))}
</select>
</label>
<label className="flex items-center gap-1.5 text-neutral-600 text-xs">
<input
type="checkbox"
checked={props.brandingEnabled}
onChange={(event) => props.setBrandingEnabled(event.target.checked)}
/>
<span className="font-medium">Brand colours</span>
</label>
{props.brandingEnabled && (
<div className="flex items-center gap-3">
<ColorInput
label="Primary"
value={props.colors.primary}
onChange={(value) => props.onColorChange('primary', value)}
/>
<ColorInput
label="On primary"
value={props.colors.primaryForeground}
onChange={(value) => props.onColorChange('primaryForeground', value)}
/>
<ColorInput
label="Background"
value={props.colors.background}
onChange={(value) => props.onColorChange('background', value)}
/>
<ColorInput
label="Text"
value={props.colors.foreground}
onChange={(value) => props.onColorChange('foreground', value)}
/>
</div>
)}
<span className="ml-auto text-neutral-400 text-xs">{props.loading ? 'Rendering…' : ''}</span>
</div>
);
};
type SegmentedControlProps = {
label: string;
value: string;
options: { value: string; label: string }[];
onChange: (value: string) => void;
};
const SegmentedControl = (props: SegmentedControlProps) => {
return (
<div className="flex items-center gap-1.5">
<span className="font-medium text-neutral-600 text-xs">{props.label}</span>
<div className="flex overflow-hidden rounded-md border border-neutral-300">
{props.options.map((option) => (
<button
key={option.value}
type="button"
onClick={() => props.onChange(option.value)}
className={`px-2.5 py-1 text-xs transition-colors ${
props.value === option.value
? 'bg-neutral-900 text-white'
: 'bg-white text-neutral-700 hover:bg-neutral-100'
}`}
>
{option.label}
</button>
))}
</div>
</div>
);
};
type ColorInputProps = {
label: string;
value: string;
onChange: (value: string) => void;
};
const ColorInput = (props: ColorInputProps) => {
return (
<label className="flex items-center gap-1 text-neutral-600 text-xs">
<span>{props.label}</span>
<input
type="color"
value={props.value}
onChange={(event) => props.onChange(event.target.value)}
className="h-6 w-6 cursor-pointer rounded border border-neutral-300 bg-white p-0"
/>
</label>
);
};
@@ -0,0 +1,113 @@
import type { FieldConfig } from '../lib/templates';
type PropFieldsProps = {
fields: Record<string, FieldConfig>;
values: Record<string, unknown>;
onChange: (key: string, value: unknown) => void;
};
export const PropFields = ({ fields, values, onChange }: PropFieldsProps) => {
const entries = Object.entries(fields);
if (entries.length === 0) {
return <p className="text-neutral-400 text-xs">No editable props.</p>;
}
return (
<div className="grid gap-3">
{entries.map(([key, field]) => (
<PropField key={key} name={key} field={field} value={values[key]} onChange={(value) => onChange(key, value)} />
))}
</div>
);
};
type PropFieldProps = {
name: string;
field: FieldConfig;
value: unknown;
onChange: (value: unknown) => void;
};
const inputClass =
'w-full rounded-md border border-neutral-300 bg-white px-2 py-1 text-neutral-900 text-xs focus:border-neutral-500 focus:outline-none';
const PropField = ({ name, field, value, onChange }: PropFieldProps) => {
const id = `prop-${name}`;
return (
<div className="grid gap-1">
<label htmlFor={id} className="font-medium text-neutral-600 text-xs">
{field.label}
</label>
{field.type === 'text' && (
<input
id={id}
className={inputClass}
value={String(value ?? '')}
placeholder={field.placeholder}
onChange={(event) => onChange(event.target.value)}
/>
)}
{field.type === 'textarea' && (
<textarea
id={id}
className={`${inputClass} min-h-16 resize-y font-mono`}
value={String(value ?? '')}
placeholder={field.placeholder}
onChange={(event) => onChange(event.target.value)}
/>
)}
{field.type === 'number' && (
<input
id={id}
type="number"
className={inputClass}
value={value === undefined || value === null ? '' : String(value)}
placeholder={field.placeholder}
onChange={(event) => onChange(event.target.value === '' ? undefined : Number(event.target.value))}
/>
)}
{field.type === 'boolean' && (
<input
id={id}
type="checkbox"
className="h-4 w-4"
checked={Boolean(value)}
onChange={(event) => onChange(event.target.checked)}
/>
)}
{field.type === 'list' && (
<textarea
id={id}
className={`${inputClass} min-h-16 resize-y font-mono`}
value={Array.isArray(value) ? value.join('\n') : ''}
placeholder={field.placeholder}
onChange={(event) => onChange(event.target.value === '' ? [] : event.target.value.split('\n'))}
/>
)}
{field.type === 'select' && field.options && (
<select
id={id}
className={inputClass}
value={String(value ?? '')}
onChange={(event) => onChange(event.target.value)}
>
{field.options.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
)}
{field.description && <p className="text-neutral-400 text-xs">{field.description}</p>}
</div>
);
};
@@ -0,0 +1,12 @@
import { StrictMode, startTransition } from 'react';
import { hydrateRoot } from 'react-dom/client';
import { HydratedRouter } from 'react-router/dom';
startTransition(() => {
hydrateRoot(
document,
<StrictMode>
<HydratedRouter />
</StrictMode>,
);
});
@@ -0,0 +1,56 @@
import { PassThrough } from 'node:stream';
import { createReadableStreamFromReadable } from '@react-router/node';
import { isbot } from 'isbot';
import type { RenderToPipeableStreamOptions } from 'react-dom/server';
import { renderToPipeableStream } from 'react-dom/server';
import type { AppLoadContext, EntryContext } from 'react-router';
import { ServerRouter } from 'react-router';
export const streamTimeout = 5_000;
export default function handleRequest(
request: Request,
responseStatusCode: number,
responseHeaders: Headers,
routerContext: EntryContext,
_loadContext: AppLoadContext,
) {
return new Promise((resolve, reject) => {
let shellRendered = false;
const userAgent = request.headers.get('user-agent');
const readyOption: keyof RenderToPipeableStreamOptions =
(userAgent && isbot(userAgent)) || routerContext.isSpaMode ? 'onAllReady' : 'onShellReady';
const { pipe, abort } = renderToPipeableStream(<ServerRouter context={routerContext} url={request.url} />, {
[readyOption]() {
shellRendered = true;
const body = new PassThrough();
const stream = createReadableStreamFromReadable(body);
responseHeaders.set('Content-Type', 'text/html');
resolve(
new Response(stream, {
headers: responseHeaders,
status: responseStatusCode,
}),
);
pipe(body);
},
onShellError(error: unknown) {
reject(error);
},
onError(error: unknown) {
responseStatusCode = 500;
if (shellRendered) {
console.error(error);
}
},
});
setTimeout(abort, streamTimeout + 1000);
});
}
@@ -0,0 +1,407 @@
import type { ComponentType } from 'react';
import { AccessAuth2FAEmailTemplate } from '../../../templates/access-auth-2fa';
import { AdminUserCreatedTemplate } from '../../../templates/admin-user-created';
import { BulkSendCompleteEmail } from '../../../templates/bulk-send-complete';
import { ConfirmEmailTemplate } from '../../../templates/confirm-email';
import { ConfirmTeamEmailTemplate } from '../../../templates/confirm-team-email';
import { DocumentCancelTemplate } from '../../../templates/document-cancel';
import { DocumentCompletedEmailTemplate } from '../../../templates/document-completed';
import { DocumentCreatedFromDirectTemplateEmailTemplate } from '../../../templates/document-created-from-direct-template';
import { DocumentInviteEmailTemplate } from '../../../templates/document-invite';
import { DocumentPendingEmailTemplate } from '../../../templates/document-pending';
import { DocumentRecipientSignedEmailTemplate } from '../../../templates/document-recipient-signed';
import { DocumentRejectedEmail } from '../../../templates/document-rejected';
import { DocumentRejectionConfirmedEmail } from '../../../templates/document-rejection-confirmed';
import { DocumentReminderEmailTemplate } from '../../../templates/document-reminder';
import { DocumentSelfSignedEmailTemplate } from '../../../templates/document-self-signed';
import { DocumentSuperDeleteEmailTemplate } from '../../../templates/document-super-delete';
import { ForgotPasswordTemplate } from '../../../templates/forgot-password';
import { OrganisationAccountLinkConfirmationTemplate } from '../../../templates/organisation-account-link-confirmation';
import { OrganisationDeleteEmailTemplate } from '../../../templates/organisation-delete';
import { OrganisationInviteEmailTemplate } from '../../../templates/organisation-invite';
import { OrganisationJoinEmailTemplate } from '../../../templates/organisation-join';
import { OrganisationLeaveEmailTemplate } from '../../../templates/organisation-leave';
import { OrganisationLimitAlertEmailTemplate } from '../../../templates/organisation-limit-alert';
import { RecipientExpiredTemplate } from '../../../templates/recipient-expired';
import { RecipientRemovedFromDocumentTemplate } from '../../../templates/recipient-removed-from-document';
import { ResetPasswordTemplate } from '../../../templates/reset-password';
import { TeamDeleteEmailTemplate } from '../../../templates/team-delete';
import { TeamEmailRemovedTemplate } from '../../../templates/team-email-removed';
export type FieldType = 'text' | 'textarea' | 'number' | 'boolean' | 'select' | 'list';
export type FieldConfig = {
type: FieldType;
label: string;
description?: string;
placeholder?: string;
default: unknown;
options?: { label: string; value: string }[];
};
export type TemplateDefinition = {
/** Human label for the sidebar. */
name: string;
/** Loose grouping for the sidebar. */
group: 'Documents' | 'Recipients' | 'Organisations' | 'Teams' | 'Account' | 'Admin';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
component: ComponentType<any>;
/** Editable props surfaced in the preview UI. */
fields: Record<string, FieldConfig>;
};
// --- Reusable field presets ---
const documentNameField: FieldConfig = {
type: 'text',
label: 'Document name',
default: 'Open Source Pledge.pdf',
};
const recipientNameField: FieldConfig = {
type: 'text',
label: 'Recipient name',
default: 'Lucas Smith',
};
const roleField: FieldConfig = {
type: 'select',
label: 'Recipient role',
default: 'SIGNER',
options: [
{ label: 'Signer', value: 'SIGNER' },
{ label: 'Viewer', value: 'VIEWER' },
{ label: 'Approver', value: 'APPROVER' },
{ label: 'CC', value: 'CC' },
{ label: 'Assistant', value: 'ASSISTANT' },
],
};
/**
* Explicit template registry. Each entry maps a slug → component + editable
* `fields`. The slug is the route param (`/:slug`) and matches the source
* filename (sans extension).
*
* `fields` drives both the default preview values AND the editable inputs in
* the UI, so production templates stay free of preview-only defaults.
*/
export const templates: Record<string, TemplateDefinition> = {
// ---- Documents ----
'document-invite': {
name: 'Document invite',
group: 'Documents',
component: DocumentInviteEmailTemplate,
fields: {
inviterName: { type: 'text', label: 'Inviter name', default: 'Lucas Smith' },
inviterEmail: { type: 'text', label: 'Inviter email', default: 'lucas@documenso.com' },
documentName: documentNameField,
role: roleField,
customBody: {
type: 'textarea',
label: 'Custom message',
default: '',
description: 'Leave blank to use the default invite copy.',
},
},
},
'document-completed': {
name: 'Document completed',
group: 'Documents',
component: DocumentCompletedEmailTemplate,
fields: {
documentName: documentNameField,
customBody: { type: 'textarea', label: 'Custom message', default: '' },
},
},
'document-self-signed': {
name: 'Document self-signed',
group: 'Documents',
component: DocumentSelfSignedEmailTemplate,
fields: {
documentName: documentNameField,
},
},
'document-pending': {
name: 'Document pending',
group: 'Documents',
component: DocumentPendingEmailTemplate,
fields: {
documentName: documentNameField,
},
},
'document-reminder': {
name: 'Document reminder',
group: 'Documents',
component: DocumentReminderEmailTemplate,
fields: {
recipientName: recipientNameField,
documentName: documentNameField,
role: roleField,
customBody: { type: 'textarea', label: 'Custom message', default: '' },
},
},
'document-cancel': {
name: 'Document cancelled',
group: 'Documents',
component: DocumentCancelTemplate,
fields: {
inviterName: { type: 'text', label: 'Inviter name', default: 'Lucas Smith' },
documentName: documentNameField,
cancellationReason: {
type: 'textarea',
label: 'Cancellation reason',
default: '',
description: 'Optional. Blank renders no reason block.',
},
},
},
'document-rejected': {
name: 'Document rejected',
group: 'Documents',
component: DocumentRejectedEmail,
fields: {
recipientName: recipientNameField,
documentName: documentNameField,
documentUrl: { type: 'text', label: 'Document URL', default: 'https://documenso.com' },
rejectionReason: {
type: 'textarea',
label: 'Rejection reason',
default: 'The pledge amount is incorrect.',
description: 'Optional in production; blank renders no reason block.',
},
},
},
'document-rejection-confirmed': {
name: 'Document rejection confirmed',
group: 'Documents',
component: DocumentRejectionConfirmedEmail,
fields: {
recipientName: recipientNameField,
documentName: documentNameField,
documentOwnerName: { type: 'text', label: 'Document owner', default: 'Timur Ercan' },
reason: {
type: 'textarea',
label: 'Rejection reason',
default: 'The pledge amount is incorrect.',
description: 'Optional in production; blank renders no reason block.',
},
},
},
'document-created-from-direct-template': {
name: 'Document created (direct template)',
group: 'Documents',
component: DocumentCreatedFromDirectTemplateEmailTemplate,
fields: {
documentName: documentNameField,
},
},
'document-super-delete': {
name: 'Document deleted (admin)',
group: 'Documents',
component: DocumentSuperDeleteEmailTemplate,
fields: {
documentName: documentNameField,
},
},
'bulk-send-complete': {
name: 'Bulk send complete',
group: 'Documents',
component: BulkSendCompleteEmail,
fields: {
userName: { type: 'text', label: 'User name', default: 'Lucas Smith' },
templateName: { type: 'text', label: 'Template name', default: 'NDA Template' },
totalProcessed: { type: 'number', label: 'Total processed', default: 50 },
successCount: { type: 'number', label: 'Success count', default: 48 },
failedCount: { type: 'number', label: 'Failed count', default: 2 },
errors: {
type: 'list',
label: 'Errors',
default: ['Row 12: invalid email', 'Row 30: missing name'],
description: 'One error per line. Rendered when failed count > 0.',
},
},
},
// ---- Recipients ----
'document-recipient-signed': {
name: 'Recipient signed',
group: 'Recipients',
component: DocumentRecipientSignedEmailTemplate,
fields: {
documentName: documentNameField,
recipientName: recipientNameField,
},
},
'recipient-expired': {
name: 'Recipient expired',
group: 'Recipients',
component: RecipientExpiredTemplate,
fields: {
documentName: documentNameField,
recipientName: recipientNameField,
},
},
'recipient-removed-from-document': {
name: 'Recipient removed',
group: 'Recipients',
component: RecipientRemovedFromDocumentTemplate,
fields: {
documentName: documentNameField,
},
},
// ---- Organisations ----
'organisation-invite': {
name: 'Organisation invite',
group: 'Organisations',
component: OrganisationInviteEmailTemplate,
fields: {
senderName: { type: 'text', label: 'Sender name', default: 'Lucas Smith' },
organisationName: { type: 'text', label: 'Organisation name', default: 'Documenso' },
},
},
'organisation-join': {
name: 'Organisation join',
group: 'Organisations',
component: OrganisationJoinEmailTemplate,
fields: {
memberName: { type: 'text', label: 'Member name', default: 'Lucas Smith' },
organisationName: { type: 'text', label: 'Organisation name', default: 'Documenso' },
},
},
'organisation-leave': {
name: 'Organisation leave',
group: 'Organisations',
component: OrganisationLeaveEmailTemplate,
fields: {
memberName: { type: 'text', label: 'Member name', default: 'Lucas Smith' },
organisationName: { type: 'text', label: 'Organisation name', default: 'Documenso' },
},
},
'organisation-delete': {
name: 'Organisation delete',
group: 'Organisations',
component: OrganisationDeleteEmailTemplate,
fields: {
organisationName: { type: 'text', label: 'Organisation name', default: 'Documenso' },
},
},
'organisation-limit-alert': {
name: 'Organisation limit alert',
group: 'Organisations',
component: OrganisationLimitAlertEmailTemplate,
fields: {
organisationName: { type: 'text', label: 'Organisation name', default: 'Documenso' },
},
},
'organisation-account-link-confirmation': {
name: 'Account link confirmation',
group: 'Organisations',
component: OrganisationAccountLinkConfirmationTemplate,
fields: {
organisationName: { type: 'text', label: 'Organisation name', default: 'Documenso' },
},
},
// ---- Teams ----
'confirm-team-email': {
name: 'Confirm team email',
group: 'Teams',
component: ConfirmTeamEmailTemplate,
fields: {
teamName: { type: 'text', label: 'Team name', default: 'Documenso' },
},
},
'team-delete': {
name: 'Team delete',
group: 'Teams',
component: TeamDeleteEmailTemplate,
fields: {},
},
'team-email-removed': {
name: 'Team email removed',
group: 'Teams',
component: TeamEmailRemovedTemplate,
fields: {
teamName: { type: 'text', label: 'Team name', default: 'Documenso' },
teamEmail: { type: 'text', label: 'Team email', default: 'team@documenso.com' },
},
},
// ---- Account ----
'confirm-email': {
name: 'Confirm email',
group: 'Account',
component: ConfirmEmailTemplate,
fields: {
confirmationLink: {
type: 'text',
label: 'Confirmation link',
default: 'https://documenso.com/confirm',
},
},
},
'forgot-password': {
name: 'Forgot password',
group: 'Account',
component: ForgotPasswordTemplate,
fields: {
resetPasswordLink: {
type: 'text',
label: 'Reset link',
default: 'https://documenso.com/reset',
},
},
},
'reset-password': {
name: 'Reset password',
group: 'Account',
component: ResetPasswordTemplate,
fields: {
userName: { type: 'text', label: 'User name', default: 'Lucas Smith' },
userEmail: { type: 'text', label: 'User email', default: 'lucas@documenso.com' },
},
},
'access-auth-2fa': {
name: 'Access auth 2FA',
group: 'Account',
component: AccessAuth2FAEmailTemplate,
fields: {
documentTitle: { type: 'text', label: 'Document title', default: 'Open Source Pledge.pdf' },
code: { type: 'text', label: 'Code', default: '123456' },
userEmail: { type: 'text', label: 'User email', default: 'lucas@documenso.com' },
userName: { type: 'text', label: 'User name', default: 'Lucas Smith' },
expiresInMinutes: { type: 'number', label: 'Expires in (min)', default: 10 },
},
},
// ---- Admin ----
'admin-user-created': {
name: 'Admin user created',
group: 'Admin',
component: AdminUserCreatedTemplate,
fields: {
resetPasswordLink: {
type: 'text',
label: 'Reset link',
default: 'https://documenso.com/reset',
},
},
},
};
export type TemplateId = keyof typeof templates;
/** Extract the default prop values from a template's field config. */
export const getDefaultProps = (fields: Record<string, FieldConfig>): Record<string, unknown> => {
const props: Record<string, unknown> = {};
for (const [key, field] of Object.entries(fields)) {
props[key] = field.default;
}
return props;
};
export const getTemplate = (slug: string): TemplateDefinition | undefined => templates[slug];
@@ -0,0 +1,10 @@
export type Viewport = {
name: string;
width: number;
};
export const viewports: Viewport[] = [
{ name: 'Mobile', width: 390 },
{ name: 'Tablet', width: 768 },
{ name: 'Desktop', width: 1024 },
];
+30
View File
@@ -0,0 +1,30 @@
import { Links, Meta, Outlet, Scripts, ScrollRestoration } from 'react-router';
import type { Route } from './+types/root';
import stylesheet from './app.css?url';
export const links: Route.LinksFunction = () => [{ rel: 'stylesheet', href: stylesheet }];
export const Layout = ({ children }: { children: React.ReactNode }) => {
return (
<html lang="en">
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<Meta />
<Links />
</head>
<body>
{children}
<ScrollRestoration />
<Scripts />
</body>
</html>
);
};
const App = () => {
return <Outlet />;
};
export default App;
+7
View File
@@ -0,0 +1,7 @@
import { index, type RouteConfig, route } from '@react-router/dev/routes';
export default [
index('routes/_index.tsx'),
route('api/render', 'routes/api.render.tsx'),
route(':slug', 'routes/$slug.tsx'),
] satisfies RouteConfig;
@@ -0,0 +1,35 @@
import { data } from 'react-router';
import { EmailPlayground } from '../components/playground';
import { getDefaultProps, getTemplate } from '../lib/templates';
import type { Route } from './+types/$slug';
export const loader = ({ params }: Route.LoaderArgs) => {
const { slug } = params;
const template = getTemplate(slug);
if (!template) {
throw data(`Unknown template: ${slug}`, { status: 404 });
}
return {
slug,
templateName: template.name,
fields: template.fields,
defaultProps: getDefaultProps(template.fields),
};
};
export const meta = ({ data: loaderData }: Route.MetaArgs) => {
if (!loaderData) {
return [{ title: 'Not found — Email Preview' }];
}
return [{ title: `${loaderData.templateName} — Email Preview` }];
};
const TemplatePage = ({ loaderData }: Route.ComponentProps) => {
return <EmailPlayground slug={loaderData.slug} fields={loaderData.fields} defaultProps={loaderData.defaultProps} />;
};
export default TemplatePage;
@@ -0,0 +1,13 @@
import { redirect } from 'react-router';
import { templates } from '../lib/templates';
/**
* The index has no UI of its own — redirect to the first template so the
* preview always opens on something.
*/
export const loader = () => {
const firstSlug = Object.keys(templates)[0];
return redirect(`/${firstSlug}`);
};
@@ -0,0 +1,61 @@
import { resolveEmailBrandingColors } from '@documenso/lib/utils/email-branding-colors';
import { renderEmailWithI18N } from '@documenso/lib/utils/render-email-with-i18n';
import { getTemplate } from '../lib/templates';
import type { Route } from './+types/api.render';
type RenderRequestBody = {
slug: string;
props: Record<string, unknown>;
lang?: string;
colors?: Record<string, string> | null;
assetBaseUrl: string;
};
/**
* POST /api/render — render an email template to HTML via the REAL production
* pipeline (`renderEmailWithI18N`), so i18n and brand-colour injection match a
* live send. Returns `text/html` for the client to drop into an iframe srcDoc.
*/
export const action = async ({ request }: Route.ActionArgs) => {
const body = (await request.json()) as RenderRequestBody;
const template = getTemplate(body.slug);
if (!template) {
return new Response(JSON.stringify({ error: `Unknown template: ${body.slug}` }), {
status: 404,
headers: { 'Content-Type': 'application/json' },
});
}
// Resolve brand colours through the same resolver production uses, so the
// preview applies the same per-token fallbacks as a live send.
const brandingColors =
body.colors && Object.keys(body.colors).length > 0 ? resolveEmailBrandingColors(body.colors) : null;
const Component = template.component;
const element = <Component {...body.props} assetBaseUrl={body.assetBaseUrl} />;
const html = await renderEmailWithI18N(element, {
lang: body.lang ?? 'en',
branding: brandingColors
? {
brandingEnabled: true,
brandingUrl: '',
brandingLogo: '',
brandingCompanyDetails: '',
brandingHidePoweredBy: false,
brandingColors,
}
: undefined,
});
return new Response(html, {
status: 200,
headers: {
'Content-Type': 'text/html; charset=utf-8',
'Cache-Control': 'no-store',
},
});
};
@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: { config: './tailwind.config.cjs' },
autoprefixer: {},
},
};
@@ -0,0 +1,6 @@
import type { Config } from '@react-router/dev/config';
export default {
appDirectory: 'app',
ssr: true,
} satisfies Config;
@@ -0,0 +1,24 @@
const path = require('node:path');
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [path.join(__dirname, 'app/**/*.{ts,tsx}')],
theme: {
extend: {
fontFamily: {
sans: [
'Inter',
'ui-sans-serif',
'system-ui',
'-apple-system',
'Segoe UI',
'Roboto',
'Helvetica Neue',
'Arial',
'sans-serif',
],
},
},
},
plugins: [],
};
+30
View File
@@ -0,0 +1,30 @@
{
"include": ["**/*", ".react-router/types/**/*"],
"compilerOptions": {
"lib": ["DOM", "DOM.Iterable", "ES2022"],
"types": ["node", "vite/client"],
"target": "ES2022",
"module": "ES2022",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"rootDirs": [".", "./.react-router/types"],
"baseUrl": ".",
"paths": {
"@documenso/email/*": ["../*"],
"@documenso/lib": ["../../lib"],
"@documenso/lib/*": ["../../lib/*"],
"@documenso/prisma": ["../../prisma"],
"@documenso/tailwind-config": ["../../tailwind-config"],
"@documenso/ui": ["../../ui"]
},
"esModuleInterop": true,
"verbatimModuleSyntax": true,
"noEmit": true,
"moduleDetection": "force",
"resolveJsonModule": true,
"isolatedModules": true,
"skipLibCheck": true,
"strict": true,
"useUnknownInCatchVariables": false
}
}
+72
View File
@@ -0,0 +1,72 @@
import path from 'node:path';
import { lingui } from '@lingui/vite-plugin';
import { reactRouter } from '@react-router/dev/vite';
import autoprefixer from 'autoprefixer';
import tailwindcss from 'tailwindcss';
import { defineConfig } from 'vite';
import macrosPlugin from 'vite-plugin-babel-macros';
import { viteStaticCopy } from 'vite-plugin-static-copy';
import tsconfigPaths from 'vite-tsconfig-paths';
/**
* Standalone Vite app for previewing Documenso emails.
*
* Emails render server-side through the real `renderEmailWithI18N` pipeline
* (see `app/routes/preview.tsx`), so the SSR config mirrors the main Remix app:
* Prisma, the tailwind config, and native modules stay external.
*/
export default defineConfig({
root: __dirname,
css: {
postcss: {
plugins: [tailwindcss(path.join(__dirname, 'tailwind.config.cjs')), autoprefixer],
},
},
server: {
port: parseInt(process.env.PORT || '3002', 10),
strictPort: true,
},
plugins: [
// Serve the email static assets (logo, icons) under `/static` so templates'
// `assetBaseUrl="/static"` resolves to the same images production uses.
viteStaticCopy({
targets: [
{
src: path.join(__dirname, '../static') + '/*',
dest: 'static',
},
],
}),
reactRouter(),
macrosPlugin(),
lingui(),
tsconfigPaths(),
],
ssr: {
noExternal: ['@documenso/email'],
external: [
'@napi-rs/canvas',
'@node-rs/bcrypt',
'@prisma/client',
'@documenso/tailwind-config',
'playwright',
'playwright-core',
'@playwright/browser-chromium',
'pdfjs-dist',
'@google-cloud/kms',
'@google-cloud/secret-manager',
],
},
optimizeDeps: {
exclude: [
'@napi-rs/canvas',
'@node-rs/bcrypt',
'sharp',
'playwright',
'playwright-core',
'@playwright/browser-chromium',
'lightningcss',
'fsevents',
],
},
});
+2
View File
@@ -1,3 +1,4 @@
import type { EmailBrandingColors } from '@documenso/lib/utils/email-branding-colors';
import { createContext, useContext } from 'react';
type BrandingContextValue = {
@@ -6,6 +7,7 @@ type BrandingContextValue = {
brandingLogo: string;
brandingCompanyDetails: string;
brandingHidePoweredBy: boolean;
brandingColors?: EmailBrandingColors;
};
const BrandingContext = createContext<BrandingContextValue | undefined>(undefined);
+52 -5
View File
@@ -1,4 +1,6 @@
import config from '@documenso/tailwind-config';
import { DEFAULT_BRAND_COLORS } from '@documenso/lib/constants/theme';
import type { EmailBrandingColors } from '@documenso/lib/utils/email-branding-colors';
import { resolveEmailBrandingColors } from '@documenso/lib/utils/email-branding-colors';
import type { I18n } from '@lingui/core';
import { I18nProvider } from '@lingui/react';
import * as ReactEmail from '@react-email/render';
@@ -11,19 +13,62 @@ export type RenderOptions = ReactEmail.Options & {
i18n?: I18n;
};
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const colors = (config.theme?.extend?.colors || {}) as Record<string, string>;
/**
* The default email token set: the shadcn theme tokens, sourced as hex from
* `DEFAULT_BRAND_COLORS` (which mirrors `theme.css`). Emails can't use CSS
* variables, so these are concrete hex values baked into the Tailwind config.
*
* Resolved through the same `resolveEmailBrandingColors` pipeline as tenant
* colours so the default values live in exactly one place (`DEFAULT_BRAND_COLORS`)
* and the default + tenant paths can't drift. Used when a tenant has no
* (entitled) brand colours.
*/
const DEFAULT_EMAIL_BRANDING_COLORS: EmailBrandingColors =
resolveEmailBrandingColors(DEFAULT_BRAND_COLORS) ?? DEFAULT_BRAND_COLORS;
/**
* Map the resolved colour set to flat semantic Tailwind tokens. Templates use
* these directly (`bg-primary`, `text-muted-foreground`, `border-border`, …),
* mirroring the app's shadcn tokens, instead of bespoke `slate-*`/`documenso-*`
* scale classes.
*
* Always defined: falls back to `DEFAULT_EMAIL_BRANDING_COLORS` when no tenant
* colours are supplied, so the tokens resolve whether or not custom branding is
* in play.
*/
const buildEmailColors = (brandingColors?: EmailBrandingColors): Record<string, string> => {
const c = brandingColors ?? DEFAULT_EMAIL_BRANDING_COLORS;
return {
background: c.background,
foreground: c.foreground,
muted: c.muted,
'muted-foreground': c.mutedForeground,
primary: c.primary,
'primary-foreground': c.primaryForeground,
secondary: c.secondary,
'secondary-foreground': c.secondaryForeground,
accent: c.accent,
'accent-foreground': c.accentForeground,
destructive: c.destructive,
'destructive-foreground': c.destructiveForeground,
warning: c.warning,
border: c.border,
};
};
export const render = async (element: React.ReactNode, options?: RenderOptions) => {
const { branding, ...otherOptions } = options ?? {};
const tailwindColors = buildEmailColors(branding?.brandingColors);
return ReactEmail.render(
<BrandingProvider branding={branding}>
<Tailwind
config={{
theme: {
extend: {
colors,
colors: tailwindColors,
},
},
}}
@@ -42,6 +87,8 @@ export const renderWithI18N = async (element: React.ReactNode, options?: RenderO
throw new Error('i18n is required');
}
const tailwindColors = buildEmailColors(branding?.brandingColors);
return ReactEmail.render(
<I18nProvider i18n={i18n}>
<BrandingProvider branding={branding}>
@@ -49,7 +96,7 @@ export const renderWithI18N = async (element: React.ReactNode, options?: RenderO
config={{
theme: {
extend: {
colors,
colors: tailwindColors,
},
},
}}
@@ -27,24 +27,24 @@ export const TemplateAccessAuth2FA = ({
<Img src={getAssetUrl('/static/document.png')} alt="Document" className="mx-auto h-12 w-12" />
<Section className="mt-8">
<Heading className="text-center font-semibold text-lg text-slate-900">
<Heading className="text-center font-semibold text-foreground text-lg">
<Trans>Verification Code Required</Trans>
</Heading>
<Text className="mt-2 text-center text-slate-700">
<Text className="mt-2 text-center text-foreground">
<Trans>
Hi {userName}, you need to enter a verification code to complete the document "{documentTitle}".
</Trans>
</Text>
<Section className="mt-6 rounded-lg bg-slate-50 p-6 text-center">
<Text className="mb-2 font-medium text-slate-600 text-sm">
<Section className="mt-6 rounded-lg bg-muted p-6 text-center">
<Text className="mb-2 font-medium text-muted-foreground text-sm">
<Trans>Your verification code:</Trans>
</Text>
<Text className="font-bold text-2xl text-slate-900 tracking-wider">{code}</Text>
<Text className="font-bold text-2xl text-foreground tracking-wider">{code}</Text>
</Section>
<Text className="mt-4 text-center text-slate-600 text-sm">
<Text className="mt-4 text-center text-muted-foreground text-sm">
<Plural
value={expiresInMinutes}
one="This code will expire in # minute."
@@ -52,7 +52,7 @@ export const TemplateAccessAuth2FA = ({
/>
</Text>
<Text className="mt-4 text-center text-slate-500 text-sm">
<Text className="mt-4 text-center text-muted-foreground text-sm">
<Trans>If you didn't request this verification code, you can safely ignore this email.</Trans>
</Text>
</Section>
@@ -14,26 +14,26 @@ export const TemplateAdminUserCreated = ({ resetPasswordLink, assetBaseUrl }: Te
<TemplateDocumentImage className="mt-6" assetBaseUrl={assetBaseUrl} />
<Section className="flex-row items-center justify-center">
<Text className="mx-auto mb-0 max-w-[80%] text-center font-semibold text-lg text-primary">
<Text className="mx-auto mb-0 max-w-[80%] text-center font-semibold text-foreground text-lg">
<Trans>Welcome to Documenso!</Trans>
</Text>
<Text className="my-1 text-center text-base text-slate-400">
<Text className="my-1 text-center text-base text-muted-foreground">
<Trans>An administrator has created a Documenso account for you.</Trans>
</Text>
<Text className="my-1 text-center text-base text-slate-400">
<Text className="my-1 text-center text-base text-muted-foreground">
<Trans>To get started, please set your password by clicking the button below:</Trans>
</Text>
<Section className="mt-8 mb-6 text-center">
<Button
className="inline-flex items-center justify-center rounded-lg bg-documenso-500 px-6 py-3 text-center font-medium text-black text-sm no-underline"
className="inline-flex items-center justify-center rounded-lg bg-primary px-6 py-3 text-center font-medium text-primary-foreground text-sm no-underline"
href={resetPasswordLink}
>
<Trans>Set Password</Trans>
</Button>
<Text className="mt-8 text-center text-slate-400 text-sm italic">
<Text className="mt-8 text-center text-muted-foreground text-sm italic">
<Trans>
You can also copy and paste this link into your browser: {resetPasswordLink} (link expires in 24 hours)
</Trans>
@@ -41,10 +41,10 @@ export const TemplateAdminUserCreated = ({ resetPasswordLink, assetBaseUrl }: Te
</Section>
<Section className="mt-8">
<Text className="text-center text-slate-400 text-sm">
<Text className="text-center text-muted-foreground text-sm">
<Trans>
If you didn't expect this account or have any questions, please{' '}
<Link href="mailto:support@documenso.com" className="text-documenso-500">
<Link href="mailto:support@documenso.com" className="text-primary">
contact support
</Link>
.
@@ -14,22 +14,22 @@ export const TemplateConfirmationEmail = ({ confirmationLink, assetBaseUrl }: Te
<TemplateDocumentImage className="mt-6" assetBaseUrl={assetBaseUrl} />
<Section className="flex-row items-center justify-center">
<Text className="mx-auto mb-0 max-w-[80%] text-center font-semibold text-lg text-primary">
<Text className="mx-auto mb-0 max-w-[80%] text-center font-semibold text-foreground text-lg">
<Trans>Welcome to Documenso!</Trans>
</Text>
<Text className="my-1 text-center text-base text-slate-400">
<Text className="my-1 text-center text-base text-muted-foreground">
<Trans>Before you get started, please confirm your email address by clicking the button below:</Trans>
</Text>
<Section className="mt-8 mb-6 text-center">
<Button
className="inline-flex items-center justify-center rounded-lg bg-documenso-500 px-6 py-3 text-center font-medium text-black text-sm no-underline"
className="inline-flex items-center justify-center rounded-lg bg-primary px-6 py-3 text-center font-medium text-primary-foreground text-sm no-underline"
href={confirmationLink}
>
<Trans>Confirm email</Trans>
</Button>
<Text className="mt-8 text-center text-slate-400 text-sm italic">
<Text className="mt-8 text-center text-muted-foreground text-sm italic">
<Trans>
You can also copy and paste this link into your browser: {confirmationLink} (link expires in 1 hour)
</Trans>
@@ -18,7 +18,7 @@ export const TemplateCustomMessageBody = ({ text }: TemplateCustomMessageBodyPro
const paragraphs = normalized.split('\n\n');
return paragraphs.map((paragraph, i) => (
<p key={`p-${i}`} className="whitespace-pre-line break-words font-sans text-base text-slate-400">
<p key={`p-${i}`} className="whitespace-pre-line break-words font-sans text-base text-muted-foreground">
{paragraph.split('\n').map((line, j) => (
<React.Fragment key={`line-${i}-${j}`}>
{j > 0 && <br />}
@@ -22,18 +22,18 @@ export const TemplateDocumentCancel = ({
<TemplateDocumentImage className="mt-6" assetBaseUrl={assetBaseUrl} />
<Section>
<Text className="mx-auto mb-0 max-w-[80%] text-center font-semibold text-lg text-primary">
<Text className="mx-auto mb-0 max-w-[80%] text-center font-semibold text-foreground text-lg">
<Trans>
{inviterName} has cancelled the document
<br />"{documentName}"
</Trans>
</Text>
<Text className="my-1 text-center text-base text-slate-400">
<Text className="my-1 text-center text-base text-muted-foreground">
<Trans>All signatures have been voided.</Trans>
</Text>
<Text className="my-1 text-center text-base text-slate-400">
<Text className="my-1 text-center text-base text-muted-foreground">
<Trans>You don't need to sign it anymore.</Trans>
</Text>
@@ -27,24 +27,24 @@ export const TemplateDocumentCompleted = ({
<Section>
<Section className="mb-4">
<Column align="center">
<Text className="font-semibold text-[#7AC455] text-base">
<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" />
<Trans>Completed</Trans>
</Text>
</Column>
</Section>
<Text className="mb-0 text-center font-semibold text-lg text-primary">
<Text className="mb-0 text-center font-semibold text-foreground text-lg">
{customBody || <Trans>{documentName} was signed by all signers</Trans>}
</Text>
<Text className="my-1 text-center text-base text-slate-400">
<Text className="my-1 text-center text-base text-muted-foreground">
<Trans>Continue by downloading the document.</Trans>
</Text>
<Section className="mt-8 mb-6 text-center">
<Button
className="rounded-lg border border-slate-200 border-solid px-4 py-2 text-center font-medium text-black text-sm no-underline"
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" />
@@ -40,7 +40,7 @@ export const TemplateDocumentInvite = ({
<TemplateDocumentImage className="mt-6" assetBaseUrl={assetBaseUrl} />
<Section>
<Text className="mx-auto mb-0 max-w-[80%] text-center font-semibold text-lg text-primary">
<Text className="mx-auto mb-0 max-w-[80%] text-center font-semibold text-foreground text-lg">
{match({ selfSigner, organisationType, includeSenderDetails, teamName })
.with({ selfSigner: true }, () => (
<Trans>
@@ -75,7 +75,7 @@ export const TemplateDocumentInvite = ({
))}
</Text>
<Text className="my-1 text-center text-base text-slate-400">
<Text className="my-1 text-center text-base text-muted-foreground">
{match(role)
.with(RecipientRole.SIGNER, () => <Trans>Continue by signing the document.</Trans>)
.with(RecipientRole.VIEWER, () => <Trans>Continue by viewing the document.</Trans>)
@@ -87,7 +87,7 @@ export const TemplateDocumentInvite = ({
<Section className="mt-8 mb-6 text-center">
<Button
className="inline-flex items-center justify-center rounded-lg bg-documenso-500 px-6 py-3 text-center font-medium text-black text-sbase no-underline"
className="inline-flex items-center justify-center rounded-lg bg-primary px-6 py-3 text-center font-medium text-primary-foreground text-sbase no-underline"
href={signDocumentLink}
>
{match(role)
@@ -20,18 +20,18 @@ export const TemplateDocumentPending = ({ documentName, assetBaseUrl }: Template
<Section>
<Section className="mb-4">
<Column align="center">
<Text className="font-semibold text-base text-blue-500">
<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" />
<Trans>Waiting for others</Trans>
</Text>
</Column>
</Section>
<Text className="mb-0 text-center font-semibold text-lg text-primary">
<Text className="mb-0 text-center font-semibold text-foreground text-lg">
<Trans>{documentName} has been signed</Trans>
</Text>
<Text className="mx-auto mt-1 mb-6 max-w-[80%] text-center text-base text-slate-400">
<Text className="mx-auto mt-1 mb-6 max-w-[80%] text-center text-base text-muted-foreground">
<Trans>
We're still waiting for other signers to sign this document.
<br />
@@ -29,20 +29,20 @@ export const TemplateDocumentRecipientSigned = ({
<Section>
<Section className="mb-4">
<Column align="center">
<Text className="font-semibold text-[#7AC455] text-base">
<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" />
<Trans>Completed</Trans>
</Text>
</Column>
</Section>
<Text className="mb-0 text-center font-semibold text-lg text-primary">
<Text className="mb-0 text-center font-semibold text-foreground text-lg">
<Trans>
{recipientReference} has signed "{documentName}"
</Trans>
</Text>
<Text className="mx-auto mt-1 mb-6 max-w-[80%] text-center text-base text-slate-400">
<Text className="mx-auto mt-1 mb-6 max-w-[80%] text-center text-base text-muted-foreground">
<Trans>{recipientReference} has completed signing the document.</Trans>
</Text>
</Section>
@@ -17,7 +17,7 @@ export function TemplateDocumentRejected({
}: TemplateDocumentRejectedProps) {
return (
<div className="mt-4">
<Heading className="mb-4 text-center font-semibold text-2xl text-slate-800">
<Heading className="mb-4 text-center font-semibold text-2xl text-foreground">
<Trans>Document Rejected</Trans>
</Heading>
@@ -28,7 +28,7 @@ export function TemplateDocumentRejected({
</Text>
{rejectionReason && (
<Text className="mb-4 text-base text-slate-400">
<Text className="mb-4 text-base text-muted-foreground">
<Trans>Reason for rejection: {rejectionReason}</Trans>
</Text>
)}
@@ -39,7 +39,7 @@ export function TemplateDocumentRejected({
<Button
href={documentUrl}
className="inline-flex items-center justify-center rounded-lg bg-documenso-500 px-6 py-3 text-center font-medium text-black text-sm no-underline"
className="inline-flex items-center justify-center rounded-lg bg-primary px-6 py-3 text-center font-medium text-primary-foreground text-sm no-underline"
>
<Trans>View Document</Trans>
</Button>
@@ -22,7 +22,7 @@ export function TemplateDocumentRejectionConfirmed({
<Trans>Rejection Confirmed</Trans>
</Heading>
<Text className="text-base text-primary">
<Text className="text-base text-foreground">
<Trans>
This email confirms that you have rejected the document{' '}
<strong className="font-bold">"{documentName}"</strong> sent by {documentOwnerName}.
@@ -30,7 +30,7 @@ export function TemplateDocumentRejectionConfirmed({
</Text>
{reason && (
<Text className="font-medium text-base text-slate-400">
<Text className="font-medium text-base text-muted-foreground">
<Trans>Rejection reason: {reason}</Trans>
</Text>
)}
@@ -31,18 +31,18 @@ export const TemplateDocumentReminder = ({
<TemplateDocumentImage className="mt-6" assetBaseUrl={assetBaseUrl} />
<Section>
<Text className="mx-auto mb-0 max-w-[80%] text-center font-semibold text-lg text-primary">
<Text className="mx-auto mb-0 max-w-[80%] text-center font-semibold text-foreground text-lg">
<Trans>
Reminder: Please {_(actionVerb).toLowerCase()} your document
<br />"{documentName}"
</Trans>
</Text>
<Text className="my-1 text-center text-base text-slate-400">
<Text className="my-1 text-center text-base text-muted-foreground">
<Trans>Hi {recipientName},</Trans>
</Text>
<Text className="my-1 text-center text-base text-slate-400">
<Text className="my-1 text-center text-base text-muted-foreground">
{match(role)
.with(RecipientRole.SIGNER, () => <Trans>Continue by signing the document.</Trans>)
.with(RecipientRole.VIEWER, () => <Trans>Continue by viewing the document.</Trans>)
@@ -54,7 +54,7 @@ export const TemplateDocumentReminder = ({
<Section className="mt-8 mb-6 text-center">
<Button
className="inline-flex items-center justify-center rounded-lg bg-documenso-500 px-6 py-3 text-center font-medium text-black text-sm no-underline"
className="inline-flex items-center justify-center rounded-lg bg-primary px-6 py-3 text-center font-medium text-primary-foreground text-sm no-underline"
href={signDocumentLink}
>
{match(role)
@@ -25,25 +25,21 @@ export const TemplateDocumentSelfSigned = ({ documentName, assetBaseUrl }: Templ
<Section className="flex-row items-center justify-center">
<Section>
<Column align="center">
<Text className="font-semibold text-[#7AC455] text-base">
<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" />
<Trans>Completed</Trans>
</Text>
</Column>
</Section>
<Text className="mt-6 mb-0 text-center font-semibold text-lg text-primary">
<Text className="mt-6 mb-0 text-center font-semibold text-foreground text-lg">
<Trans>You have signed {documentName}</Trans>
</Text>
<Text className="mx-auto mt-1 mb-6 max-w-[80%] text-center text-base text-slate-400">
<Text className="mx-auto mt-1 mb-6 max-w-[80%] text-center text-base text-muted-foreground">
<Trans>
Create a{' '}
<Link
href={signUpUrl}
target="_blank"
className="whitespace-nowrap text-documenso-700 hover:text-documenso-600"
>
<Link href={signUpUrl} target="_blank" className="whitespace-nowrap text-primary hover:text-primary">
free account
</Link>{' '}
to access your signed documents at any time.
@@ -53,14 +49,14 @@ export const TemplateDocumentSelfSigned = ({ documentName, assetBaseUrl }: Templ
<Section className="mt-8 mb-6 text-center">
<Button
href={signUpUrl}
className="mr-4 rounded-lg border border-slate-200 border-solid px-4 py-2 text-center font-medium text-black text-sm no-underline"
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" />
<Trans>Create account</Trans>
</Button>
<Button
className="rounded-lg border border-slate-200 border-solid px-4 py-2 text-center font-medium text-black text-sm no-underline"
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" />
@@ -15,26 +15,26 @@ export const TemplateDocumentDelete = ({ reason, documentName, assetBaseUrl }: T
<TemplateDocumentImage className="mt-6" assetBaseUrl={assetBaseUrl} />
<Section>
<Text className="mt-6 mb-0 text-left font-semibold text-lg text-primary">
<Text className="mt-6 mb-0 text-left font-semibold text-foreground text-lg">
<Trans>Your document has been deleted by an admin!</Trans>
</Text>
<Text className="mx-auto mt-1 mb-6 text-left text-base text-slate-400">
<Text className="mx-auto mt-1 mb-6 text-left text-base text-muted-foreground">
<Trans>"{documentName}" has been deleted by an admin.</Trans>
</Text>
<Text className="mx-auto mt-1 mb-6 text-left text-base text-slate-400">
<Text className="mx-auto mt-1 mb-6 text-left text-base text-muted-foreground">
<Trans>
This document can not be recovered, if you would like to dispute the reason for future documents please
contact support.
</Trans>
</Text>
<Text className="mx-auto mt-1 text-left text-base text-slate-400">
<Text className="mx-auto mt-1 text-left text-base text-muted-foreground">
<Trans>The reason provided for deletion is the following:</Trans>
</Text>
<Text className="mx-auto mt-1 mb-6 text-left text-base text-slate-400 italic">{reason}</Text>
<Text className="mx-auto mt-1 mb-6 text-left text-base text-muted-foreground italic">{reason}</Text>
</Section>
</>
);
@@ -1,4 +1,5 @@
import { Trans } from '@lingui/react/macro';
import { Fragment } from 'react';
import { Link, Section, Text } from '../components';
import { useBranding } from '../providers/branding';
@@ -17,10 +18,10 @@ export const TemplateFooter = ({ isDocument = true, reportUrl }: TemplateFooterP
return (
<Section>
{reportUrl && (
<Text className="my-4 text-base text-slate-400">
<Text className="my-4 text-base text-muted-foreground">
<Trans>
Did not expect this email?{' '}
<Link className="text-[#7AC455]" href={reportUrl}>
<Link className="text-primary" href={reportUrl}>
Click here to report the sender
</Link>
. Never sign a document you don't recognize or weren't expecting.
@@ -29,10 +30,10 @@ export const TemplateFooter = ({ isDocument = true, reportUrl }: TemplateFooterP
)}
{isDocument && !branding.brandingHidePoweredBy && (
<Text className="my-4 text-base text-slate-400">
<Text className="my-4 text-base text-muted-foreground">
<Trans>
This document was sent using{' '}
<Link className="text-[#7AC455]" href="https://documen.so/mail-footer">
<Link className="text-primary" href="https://documen.so/mail-footer">
Documenso
</Link>
.
@@ -41,20 +42,20 @@ export const TemplateFooter = ({ isDocument = true, reportUrl }: TemplateFooterP
)}
{branding.brandingEnabled && branding.brandingCompanyDetails && (
<Text className="my-8 text-slate-400 text-sm">
<Text className="my-8 text-muted-foreground text-sm">
{branding.brandingCompanyDetails.split('\n').map((line, idx) => {
return (
<>
<Fragment key={idx}>
{idx > 0 && <br />}
{line}
</>
</Fragment>
);
})}
</Text>
)}
{branding.brandingEnabled && safeBrandingUrl && (
<Text className="my-8 text-slate-400 text-sm">
<Text className="my-8 text-muted-foreground text-sm">
<Link href={safeBrandingUrl} target="_blank">
{safeBrandingUrl}
</Link>
@@ -62,7 +63,7 @@ export const TemplateFooter = ({ isDocument = true, reportUrl }: TemplateFooterP
)}
{!branding.brandingEnabled && (
<Text className="my-8 text-slate-400 text-sm">
<Text className="my-8 text-muted-foreground text-sm">
Documenso, Inc.
<br />
2261 Market Street, #5211, San Francisco, CA 94114, USA
@@ -14,17 +14,17 @@ export const TemplateForgotPassword = ({ resetPasswordLink, assetBaseUrl }: Temp
<TemplateDocumentImage className="mt-6" assetBaseUrl={assetBaseUrl} />
<Section className="flex-row items-center justify-center">
<Text className="mx-auto mb-0 max-w-[80%] text-center font-semibold text-lg text-primary">
<Text className="mx-auto mb-0 max-w-[80%] text-center font-semibold text-foreground text-lg">
<Trans>Forgot your password?</Trans>
</Text>
<Text className="my-1 text-center text-base text-slate-400">
<Text className="my-1 text-center text-base text-muted-foreground">
<Trans>That's okay, it happens! Click the button below to reset your password.</Trans>
</Text>
<Section className="mt-8 mb-6 text-center">
<Button
className="inline-flex items-center justify-center rounded-lg bg-documenso-500 px-6 py-3 text-center font-medium text-black text-sm no-underline"
className="inline-flex items-center justify-center rounded-lg bg-primary px-6 py-3 text-center font-medium text-primary-foreground text-sm no-underline"
href={resetPasswordLink}
>
<Trans>Reset Password</Trans>
@@ -25,13 +25,13 @@ export const TemplateRecipientExpired = ({
<TemplateDocumentImage className="mt-6" assetBaseUrl={assetBaseUrl} />
<Section>
<Text className="mx-auto mb-0 max-w-[80%] text-center font-semibold text-lg text-primary">
<Text className="mx-auto mb-0 max-w-[80%] text-center font-semibold text-foreground text-lg">
<Trans>
Signing window expired for "{displayName}" on "{documentName}"
</Trans>
</Text>
<Text className="my-1 text-center text-base text-slate-400">
<Text className="my-1 text-center text-base text-muted-foreground">
<Trans>
The signing window for {displayName} on document "{documentName}" has expired. You can resend the document
to extend their deadline or cancel the document.
@@ -40,7 +40,7 @@ export const TemplateRecipientExpired = ({
<Section className="my-4 text-center">
<Button
className="inline-flex items-center justify-center rounded-lg bg-documenso-500 px-6 py-3 text-center font-medium text-sm text-white no-underline"
className="inline-flex items-center justify-center rounded-lg bg-primary px-6 py-3 text-center font-medium text-primary-foreground text-sm no-underline"
href={documentLink}
>
<Trans>View Document</Trans>
@@ -18,17 +18,17 @@ export const TemplateResetPassword = ({ assetBaseUrl }: TemplateResetPasswordPro
<TemplateDocumentImage className="mt-6" assetBaseUrl={assetBaseUrl} />
<Section className="flex-row items-center justify-center">
<Text className="mx-auto mb-0 max-w-[80%] text-center font-semibold text-lg text-primary">
<Text className="mx-auto mb-0 max-w-[80%] text-center font-semibold text-foreground text-lg">
<Trans>Password updated!</Trans>
</Text>
<Text className="my-1 text-center text-base text-slate-400">
<Text className="my-1 text-center text-base text-muted-foreground">
<Trans>Your password has been updated.</Trans>
</Text>
<Section className="mt-8 mb-6 text-center">
<Button
className="inline-flex items-center justify-center rounded-lg bg-documenso-500 px-6 py-3 text-center font-medium text-black text-sm no-underline"
className="inline-flex items-center justify-center rounded-lg bg-primary px-6 py-3 text-center font-medium text-primary-foreground text-sm no-underline"
href={`${NEXT_PUBLIC_WEBAPP_URL ?? 'http://localhost:3000'}/signin`}
>
<Trans>Sign In</Trans>
+2 -2
View File
@@ -32,9 +32,9 @@ export const AccessAuth2FAEmailTemplate = ({
<Head />
<Preview>{_(previewText)}</Preview>
<Body className="mx-auto my-auto bg-white font-sans">
<Body className="mx-auto my-auto bg-background font-sans">
<Section>
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-slate-200 border-solid p-4 backdrop-blur-sm">
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-border border-solid p-4 backdrop-blur-sm">
<Section>
<TemplateBrandingLogo assetBaseUrl={assetBaseUrl} className="mb-4 h-6" />
@@ -22,9 +22,9 @@ export const AdminUserCreatedTemplate = ({
<Html>
<Head />
<Preview>{_(previewText)}</Preview>
<Body className="mx-auto my-auto bg-white font-sans">
<Body className="mx-auto my-auto bg-background font-sans">
<Section>
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-slate-200 border-solid p-4 backdrop-blur-sm">
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-border border-solid p-4 backdrop-blur-sm">
<Section>
<Img src={getAssetUrl('/static/logo.png')} alt="Documenso Logo" className="mb-4 h-6" />
@@ -28,9 +28,9 @@ export const BulkSendCompleteEmail = ({
<Html>
<Head />
<Preview>{_(msg`Bulk send operation complete for template "${templateName}"`)}</Preview>
<Body className="mx-auto my-auto bg-white font-sans">
<Body className="mx-auto my-auto bg-background font-sans">
<Section>
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-slate-200 border-solid p-4 backdrop-blur-sm">
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-border border-solid p-4 backdrop-blur-sm">
<Section>
<Text className="text-sm">
<Trans>Hi {userName},</Trans>
@@ -56,7 +56,7 @@ export const BulkSendCompleteEmail = ({
</li>
</ul>
{failedCount > 0 && (
{errors && errors.length > 0 && (
<Section className="mt-4">
<Text className="font-semibold text-lg">
<Trans>The following errors occurred:</Trans>
@@ -64,7 +64,7 @@ export const BulkSendCompleteEmail = ({
<ul className="my-2 ml-4 list-inside list-disc">
{errors.map((error, index) => (
<li key={index} className="mt-1 text-destructive text-slate-400 text-sm">
<li key={index} className="mt-1 text-destructive text-sm">
{error}
</li>
))}
+2 -2
View File
@@ -19,9 +19,9 @@ export const ConfirmEmailTemplate = ({
<Html>
<Head />
<Preview>{_(previewText)}</Preview>
<Body className="mx-auto my-auto bg-white font-sans">
<Body className="mx-auto my-auto bg-background font-sans">
<Section>
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-slate-200 border-solid p-4 backdrop-blur-sm">
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-border border-solid p-4 backdrop-blur-sm">
<Section>
<TemplateBrandingLogo assetBaseUrl={assetBaseUrl} className="mb-4 h-6" />
@@ -33,16 +33,16 @@ export const ConfirmTeamEmailTemplate = ({
<Preview>{_(previewText)}</Preview>
<Body className="mx-auto my-auto font-sans">
<Section className="bg-white">
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-slate-200 border-solid px-2 pt-2 backdrop-blur-sm">
<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" />
<Section>
<TemplateImage className="mx-auto" assetBaseUrl={assetBaseUrl} staticAsset="mail-open.png" />
</Section>
<Section className="p-2 text-slate-500">
<Text className="text-center font-medium text-black text-lg">
<Section className="p-2 text-muted-foreground">
<Text className="text-center font-medium text-foreground text-lg">
<Trans>Verify your team email address</Trans>
</Text>
@@ -53,7 +53,7 @@ export const ConfirmTeamEmailTemplate = ({
</Trans>
</Text>
<div className="mx-auto mt-6 w-fit rounded-lg bg-gray-50 px-4 py-2 font-medium text-base text-slate-600">
<div className="mx-auto mt-6 w-fit rounded-lg bg-muted px-4 py-2 font-medium text-base text-muted-foreground">
{formatTeamUrl(teamUrl, baseUrl)}
</div>
@@ -86,7 +86,7 @@ export const ConfirmTeamEmailTemplate = ({
<Section className="mt-8 mb-6 text-center">
<Button
className="inline-flex items-center justify-center rounded-lg bg-documenso-500 px-6 py-3 text-center font-medium text-black text-sm no-underline"
className="inline-flex items-center justify-center rounded-lg bg-primary px-6 py-3 text-center font-medium text-primary-foreground text-sm no-underline"
href={`${baseUrl}/team/verify/email/${token}`}
>
<Trans>Accept</Trans>
@@ -94,7 +94,7 @@ export const ConfirmTeamEmailTemplate = ({
</Section>
</Section>
<Text className="text-center text-slate-500 text-xs">
<Text className="text-center text-muted-foreground text-xs">
<Trans>Link expires in 1 hour.</Trans>
</Text>
</Container>
+2 -2
View File
@@ -25,9 +25,9 @@ export const DocumentCancelTemplate = ({
<Head />
<Preview>{_(previewText)}</Preview>
<Body className="mx-auto my-auto bg-white font-sans">
<Body className="mx-auto my-auto bg-background font-sans">
<Section>
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-slate-200 border-solid p-4 backdrop-blur-sm">
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-border border-solid p-4 backdrop-blur-sm">
<Section>
<TemplateBrandingLogo assetBaseUrl={assetBaseUrl} className="mb-4 h-6" />
@@ -29,8 +29,8 @@ export const DocumentCompletedEmailTemplate = ({
<Preview>{_(previewText)}</Preview>
<Body className="mx-auto my-auto font-sans">
<Section className="bg-white">
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-slate-200 border-solid p-2 backdrop-blur-sm">
<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">
<TemplateBrandingLogo assetBaseUrl={assetBaseUrl} className="mb-4 h-6" />
@@ -36,27 +36,27 @@ export const DocumentCreatedFromDirectTemplateEmailTemplate = ({
<Preview>{_(previewText)}</Preview>
<Body className="mx-auto my-auto font-sans">
<Section className="bg-white">
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-slate-200 border-solid p-2 backdrop-blur-sm">
<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">
<TemplateBrandingLogo assetBaseUrl={assetBaseUrl} className="mb-4 h-6" />
<TemplateDocumentImage className="mt-6" assetBaseUrl={assetBaseUrl} />
<Section>
<Text className="mb-0 text-center font-semibold text-lg text-primary">
<Text className="mb-0 text-center font-semibold text-foreground text-lg">
<Trans>
{recipientName} {action} a document by using one of your direct links
</Trans>
</Text>
<div className="mx-auto my-2 w-fit rounded-lg bg-gray-50 px-4 py-2 text-slate-600 text-sm">
<div className="mx-auto my-2 w-fit rounded-lg bg-muted px-4 py-2 text-muted-foreground text-sm">
{documentName}
</div>
<Section className="my-6 text-center">
<Button
className="inline-flex items-center justify-center rounded-lg bg-documenso-500 px-6 py-3 text-center font-medium text-black text-sm no-underline"
className="inline-flex items-center justify-center rounded-lg bg-primary px-6 py-3 text-center font-medium text-primary-foreground text-sm no-underline"
href={documentLink}
>
<Trans>View document</Trans>
+4 -4
View File
@@ -58,9 +58,9 @@ export const DocumentInviteEmailTemplate = ({
<Head />
<Preview>{_(previewText)}</Preview>
<Body className="mx-auto my-auto bg-white font-sans">
<Body className="mx-auto my-auto bg-background font-sans">
<Section>
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-slate-200 border-solid p-4 backdrop-blur-sm">
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-border border-solid p-4 backdrop-blur-sm">
<Section>
<TemplateBrandingLogo assetBaseUrl={assetBaseUrl} className="mb-4 h-6" />
@@ -85,14 +85,14 @@ export const DocumentInviteEmailTemplate = ({
<Text className="my-4 font-semibold text-base">
<Trans>
{inviterName}{' '}
<Link className="font-normal text-slate-400" href="mailto:{inviterEmail}">
<Link className="font-normal text-muted-foreground" href="mailto:{inviterEmail}">
({inviterEmail})
</Link>
</Trans>
</Text>
)}
<Text className="mt-2 text-base text-slate-400">
<Text className="mt-2 text-base text-muted-foreground">
{customBody ? (
<TemplateCustomMessageBody text={customBody} />
) : (
@@ -23,8 +23,8 @@ export const DocumentPendingEmailTemplate = ({
<Preview>{_(previewText)}</Preview>
<Body className="mx-auto my-auto font-sans">
<Section className="bg-white">
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-slate-200 border-solid p-4 backdrop-blur-sm">
<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>
<TemplateBrandingLogo assetBaseUrl={assetBaseUrl} className="mb-4 h-6" />
@@ -31,8 +31,8 @@ export const DocumentRecipientSignedEmailTemplate = ({
<Preview>{_(previewText)}</Preview>
<Body className="mx-auto my-auto font-sans">
<Section className="bg-white">
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-slate-200 border-solid p-2 backdrop-blur-sm">
<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">
<TemplateBrandingLogo assetBaseUrl={assetBaseUrl} className="mb-4 h-6" />
@@ -30,9 +30,9 @@ export function DocumentRejectedEmail({
<Head />
<Preview>{previewText}</Preview>
<Body className="mx-auto my-auto bg-white font-sans">
<Body className="mx-auto my-auto bg-background font-sans">
<Section>
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-slate-200 border-solid p-4 backdrop-blur-sm">
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-border border-solid p-4 backdrop-blur-sm">
<Section>
<TemplateBrandingLogo assetBaseUrl={assetBaseUrl} className="mb-4 h-6" />
@@ -30,9 +30,9 @@ export function DocumentRejectionConfirmedEmail({
<Head />
<Preview>{previewText}</Preview>
<Body className="mx-auto my-auto bg-white font-sans">
<Body className="mx-auto my-auto bg-background font-sans">
<Section>
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-slate-200 border-solid p-4 backdrop-blur-sm">
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-border border-solid p-4 backdrop-blur-sm">
<Section>
<TemplateBrandingLogo assetBaseUrl={assetBaseUrl} className="mb-4 h-6" />
@@ -39,9 +39,9 @@ export const DocumentReminderEmailTemplate = ({
<Head />
<Preview>{_(previewText)}</Preview>
<Body className="mx-auto my-auto bg-white font-sans">
<Body className="mx-auto my-auto bg-background font-sans">
<Section>
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-slate-200 border-solid p-4 backdrop-blur-sm">
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-border border-solid p-4 backdrop-blur-sm">
<Section>
<TemplateBrandingLogo assetBaseUrl={assetBaseUrl} className="mb-4 h-6" />
@@ -58,7 +58,7 @@ export const DocumentReminderEmailTemplate = ({
{customBody && (
<Container className="mx-auto mt-12 max-w-xl">
<Section>
<Text className="mt-2 text-base text-slate-400">
<Text className="mt-2 text-base text-muted-foreground">
<TemplateCustomMessageBody text={customBody} />
</Text>
</Section>
@@ -23,8 +23,8 @@ export const DocumentSelfSignedEmailTemplate = ({
<Preview>{_(previewText)}</Preview>
<Body className="mx-auto my-auto font-sans">
<Section className="bg-white">
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-slate-200 border-solid p-2 backdrop-blur-sm">
<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">
<TemplateBrandingLogo assetBaseUrl={assetBaseUrl} className="mb-4 h-6" />
@@ -25,9 +25,9 @@ export const DocumentSuperDeleteEmailTemplate = ({
<Head />
<Preview>{_(previewText)}</Preview>
<Body className="mx-auto my-auto bg-white font-sans">
<Body className="mx-auto my-auto bg-background font-sans">
<Section>
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-slate-200 border-solid p-4 backdrop-blur-sm">
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-border border-solid p-4 backdrop-blur-sm">
<Section>
<TemplateBrandingLogo assetBaseUrl={assetBaseUrl} className="mb-4 h-6" />
+2 -2
View File
@@ -22,9 +22,9 @@ export const ForgotPasswordTemplate = ({
<Head />
<Preview>{_(previewText)}</Preview>
<Body className="mx-auto my-auto bg-white font-sans">
<Body className="mx-auto my-auto bg-background font-sans">
<Section>
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-slate-200 border-solid p-4 backdrop-blur-sm">
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-border border-solid p-4 backdrop-blur-sm">
<Section>
<TemplateBrandingLogo assetBaseUrl={assetBaseUrl} className="mb-4 h-6" />
@@ -32,16 +32,16 @@ export const OrganisationAccountLinkConfirmationTemplate = ({
<Head />
<Preview>{_(previewText)}</Preview>
<Body className="mx-auto my-auto font-sans">
<Section className="bg-white">
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-slate-200 border-solid px-2 pt-2 backdrop-blur-sm">
<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" />
<Section>
<TemplateImage className="mx-auto h-12 w-12" assetBaseUrl={assetBaseUrl} staticAsset="building-2.png" />
</Section>
<Section className="p-2 text-slate-500">
<Text className="text-center font-medium text-black text-lg">
<Section className="p-2 text-muted-foreground">
<Text className="text-center font-medium text-foreground text-lg">
{type === 'create' ? (
<Trans>Account creation request</Trans>
) : (
@@ -94,7 +94,7 @@ export const OrganisationAccountLinkConfirmationTemplate = ({
<Section className="mt-8 mb-6 text-center">
<Button
className="inline-flex items-center justify-center rounded-lg bg-documenso-500 px-6 py-3 text-center font-medium text-black text-sm no-underline"
className="inline-flex items-center justify-center rounded-lg bg-primary px-6 py-3 text-center font-medium text-primary-foreground text-sm no-underline"
href={confirmationLink}
>
<Trans>Review request</Trans>
@@ -102,7 +102,7 @@ export const OrganisationAccountLinkConfirmationTemplate = ({
</Section>
</Section>
<Text className="text-center text-slate-500 text-xs">
<Text className="text-center text-muted-foreground text-xs">
<Trans>Link expires in 30 minutes.</Trans>
</Text>
</Container>
@@ -37,20 +37,20 @@ export const OrganisationDeleteEmailTemplate = ({
<Preview>{_(previewText)}</Preview>
<Body className="mx-auto my-auto font-sans">
<Section className="bg-white text-slate-500">
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-slate-200 border-solid p-2 backdrop-blur-sm">
<Section className="bg-background text-muted-foreground">
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-border border-solid p-2 backdrop-blur-sm">
<TemplateBrandingLogo assetBaseUrl={assetBaseUrl} className="mb-4 h-6 p-2" />
<Section>
<TemplateImage className="mx-auto" assetBaseUrl={assetBaseUrl} staticAsset="delete-team.png" />
</Section>
<Section className="p-2 text-slate-500">
<Text className="text-center font-medium text-black text-lg">{_(title)}</Text>
<Section className="p-2 text-muted-foreground">
<Text className="text-center font-medium text-foreground text-lg">{_(title)}</Text>
<Text className="my-1 text-center text-base">{_(description)}</Text>
<div className="mx-auto my-2 w-fit rounded-lg bg-gray-50 px-4 py-2 font-medium text-base text-slate-600">
<div className="mx-auto my-2 w-fit rounded-lg bg-muted px-4 py-2 font-medium text-base text-muted-foreground">
{organisationName}
</div>
</Section>
@@ -32,16 +32,16 @@ export const OrganisationInviteEmailTemplate = ({
<Preview>{_(previewText)}</Preview>
<Body className="mx-auto my-auto font-sans">
<Section className="bg-white text-slate-500">
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-slate-200 border-solid p-2 backdrop-blur-sm">
<Section className="bg-background text-muted-foreground">
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-border border-solid p-2 backdrop-blur-sm">
<TemplateBrandingLogo assetBaseUrl={assetBaseUrl} className="mb-4 h-6 p-2" />
<Section>
<TemplateImage className="mx-auto" assetBaseUrl={assetBaseUrl} staticAsset="add-user.png" />
</Section>
<Section className="p-2 text-slate-500">
<Text className="text-center font-medium text-black text-lg">
<Section className="p-2 text-muted-foreground">
<Text className="text-center font-medium text-foreground text-lg">
<Trans>Join {organisationName} on Documenso</Trans>
</Text>
@@ -49,25 +49,25 @@ export const OrganisationInviteEmailTemplate = ({
<Trans>You have been invited to join the following organisation</Trans>
</Text>
<div className="mx-auto my-2 w-fit rounded-lg bg-gray-50 px-4 py-2 font-medium text-base text-slate-600">
<div className="mx-auto my-2 w-fit rounded-lg bg-muted px-4 py-2 font-medium text-base text-muted-foreground">
{organisationName}
</div>
<Text className="my-1 text-center text-base">
<Trans>
by <span className="text-slate-900">{senderName}</span>
by <span className="text-foreground">{senderName}</span>
</Trans>
</Text>
<Section className="mt-6 mb-6 text-center">
<Button
className="inline-flex items-center justify-center rounded-lg bg-documenso-500 px-6 py-3 text-center font-medium text-black text-sm no-underline"
className="inline-flex items-center justify-center rounded-lg bg-primary px-6 py-3 text-center font-medium text-primary-foreground text-sm no-underline"
href={`${baseUrl}/organisation/invite/${token}`}
>
<Trans>Accept</Trans>
</Button>
<Button
className="ml-4 inline-flex items-center justify-center rounded-lg bg-gray-50 px-6 py-3 text-center font-medium text-slate-600 text-sm no-underline"
className="ml-4 inline-flex items-center justify-center rounded-lg bg-muted px-6 py-3 text-center font-medium text-muted-foreground text-sm no-underline"
href={`${baseUrl}/organisation/decline/${token}`}
>
<Trans>Decline</Trans>
@@ -34,20 +34,20 @@ export const OrganisationJoinEmailTemplate = ({
<Preview>{_(previewText)}</Preview>
<Body className="mx-auto my-auto font-sans">
<Section className="bg-white text-slate-500">
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-slate-200 border-solid p-2 backdrop-blur-sm">
<Section className="bg-background text-muted-foreground">
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-border border-solid p-2 backdrop-blur-sm">
<TemplateBrandingLogo assetBaseUrl={assetBaseUrl} className="mb-4 h-6 p-2" />
<Section>
<TemplateImage className="mx-auto" assetBaseUrl={assetBaseUrl} staticAsset="add-user.png" />
</Section>
<Section className="p-2 text-slate-500">
<Text className="text-center font-medium text-black text-lg">
<Section className="p-2 text-muted-foreground">
<Text className="text-center font-medium text-foreground text-lg">
<Trans>A new member has joined your organisation {organisationName}</Trans>
</Text>
<div className="mx-auto my-2 w-fit rounded-lg bg-gray-50 px-4 py-2 font-medium text-base text-slate-600">
<div className="mx-auto my-2 w-fit rounded-lg bg-muted px-4 py-2 font-medium text-base text-muted-foreground">
{memberName || memberEmail}
</div>
</Section>

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