diff --git a/.env.example b/.env.example
index f723e1c66..aa6565f8c 100644
--- a/.env.example
+++ b/.env.example
@@ -48,7 +48,7 @@ NEXT_PRIVATE_DATABASE_URL="postgres://documenso:password@127.0.0.1:54320/documen
NEXT_PRIVATE_DIRECT_DATABASE_URL="postgres://documenso:password@127.0.0.1:54320/documenso"
# [[SIGNING]]
-# The transport to use for document signing. Available options: local (default) | gcloud-hsm
+# The transport to use for document signing. Available options: local (default) | gcloud-hsm | csc
NEXT_PRIVATE_SIGNING_TRANSPORT="local"
# OPTIONAL: The passphrase to use for the local file-based signing transport.
NEXT_PRIVATE_SIGNING_PASSPHRASE=
@@ -70,6 +70,14 @@ NEXT_PRIVATE_SIGNING_GCLOUD_HSM_CERT_CHAIN_FILE_PATH=
NEXT_PRIVATE_SIGNING_GCLOUD_HSM_CERT_CHAIN_CONTENTS=
# OPTIONAL: The Google Secret Manager path to retrieve the certificate for the gcloud-hsm signing transport.
NEXT_PRIVATE_SIGNING_GCLOUD_HSM_SECRET_MANAGER_CERT_PATH=
+# OPTIONAL: The base URL of the Cloud Signature Consortium (CSC) provider for the csc signing transport.
+NEXT_PRIVATE_SIGNING_CSC_PROVIDER_BASE_URL=
+# OPTIONAL: The OAuth client ID registered with the CSC provider for the csc signing transport.
+NEXT_PRIVATE_SIGNING_CSC_OAUTH_CLIENT_ID=
+# OPTIONAL: The OAuth client secret registered with the CSC provider for the csc signing transport.
+NEXT_PRIVATE_SIGNING_CSC_OAUTH_CLIENT_SECRET=
+# OPTIONAL: Default signature level for envelopes created on a CSC instance when the caller doesn't specify one. Available options: AES (default) | QES. Explicit AES/QES requests always pass through unchanged.
+NEXT_PRIVATE_SIGNING_CSC_SIGNATURE_LEVEL=
# OPTIONAL: Comma-separated list of timestamp authority URLs for PDF signing (enables LTV and archival timestamps).
NEXT_PRIVATE_SIGNING_TIMESTAMP_AUTHORITY=
# OPTIONAL: Contact info to embed in PDF signatures. Defaults to the webapp URL.
diff --git a/apps/docs/content/docs/self-hosting/configuration/environment.mdx b/apps/docs/content/docs/self-hosting/configuration/environment.mdx
index 1d57b5062..33f723c31 100644
--- a/apps/docs/content/docs/self-hosting/configuration/environment.mdx
+++ b/apps/docs/content/docs/self-hosting/configuration/environment.mdx
@@ -186,9 +186,9 @@ Documenso requires a certificate to digitally sign documents.
### Transport Selection
-| Variable | Description | Default |
-| -------------------------------- | ---------------------------------------- | ------- |
-| `NEXT_PRIVATE_SIGNING_TRANSPORT` | Signing backend: `local` or `gcloud-hsm` | `local` |
+| Variable | Description | Default |
+| -------------------------------- | ------------------------------------------------- | ------- |
+| `NEXT_PRIVATE_SIGNING_TRANSPORT` | Signing backend: `local`, `gcloud-hsm`, or `csc` | `local` |
### Local Signing
@@ -210,11 +210,36 @@ Documenso requires a certificate to digitally sign documents.
| `NEXT_PRIVATE_SIGNING_GCLOUD_HSM_CERT_CHAIN_CONTENTS` | Base64-encoded certificate chain |
| `NEXT_PRIVATE_SIGNING_GCLOUD_HSM_SECRET_MANAGER_CERT_PATH` | Google Secret Manager path for certificate retrieval |
+### Cloud Signature Consortium (CSC)
+
+Routes signing through a third-party Trust Service Provider for Advanced and Qualified Electronic Signatures (AES/QES). Instance-wide; set `NEXT_PRIVATE_SIGNING_TRANSPORT=csc` to enable. See [CSC (AES / QES)](/docs/self-hosting/configuration/signing-certificate/csc-qes) for the full setup walkthrough.
+
+CSC mode requires an active [Enterprise Edition](/docs/policies/enterprise-edition) license. Without a valid license, the instance will refuse to start in `csc` mode.
+
+| Variable | Description | Default |
+| ---------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- | ------- |
+| `NEXT_PRIVATE_SIGNING_CSC_PROVIDER_BASE_URL` | Base URL of the CSC provider's API | |
+| `NEXT_PRIVATE_SIGNING_CSC_OAUTH_CLIENT_ID` | OAuth client ID registered with the CSC provider | |
+| `NEXT_PRIVATE_SIGNING_CSC_OAUTH_CLIENT_SECRET` | OAuth client secret registered with the CSC provider | |
+| `NEXT_PRIVATE_SIGNING_CSC_SIGNATURE_LEVEL` | Default legal tier for new envelopes when the caller doesn't specify one. `AES` or `QES`. Explicit requests pass through. | `AES` |
+
+The OAuth callback URL registered with the CSC provider is fixed at `${NEXT_PUBLIC_WEBAPP_URL}/api/csc/oauth/callback` — register this exact URL with the TSP.
+
+#### Derived Public Variables
+
+The following client-visible variable is **derived automatically** from the private transport at server startup. Do not set it manually — any value set in the environment is overwritten on boot.
+
+| Variable | Derived from | Value |
+| ------------------------------------- | -------------------------------------------------- | ------------------------------------------------- |
+| `NEXT_PUBLIC_SIGNING_TRANSPORT_IS_CSC` | `NEXT_PRIVATE_SIGNING_TRANSPORT === 'csc'` | `'true'` when CSC mode is active, else `'false'` |
+
+The authoring UI uses this flag to gate features that AES/QES envelopes cannot support (parallel signing, assistant role, dictate next signer). Deriving it from the private transport prevents the client-side flag from drifting from the real server-side configuration.
+
### Signature Options
| Variable | Description | Default |
| ------------------------------------------- | ----------------------------------------------------------- | ---------- |
-| `NEXT_PRIVATE_SIGNING_TIMESTAMP_AUTHORITY` | Comma-separated timestamp authority URLs for LTV signatures | |
+| `NEXT_PRIVATE_SIGNING_TIMESTAMP_AUTHORITY` | Comma-separated timestamp authority URLs for LTV signatures. Optional for `local` / `gcloud-hsm` (signatures omit the timestamp when unset). **Required** when `NEXT_PRIVATE_SIGNING_TRANSPORT=csc` — the instance refuses to start without it. See [CSC (AES / QES)](/docs/self-hosting/configuration/signing-certificate/csc-qes#timestamp-authority-resolution). | |
| `NEXT_PUBLIC_SIGNING_CONTACT_INFO` | Contact info embedded in PDF signatures | Webapp URL |
| `NEXT_PRIVATE_USE_LEGACY_SIGNING_SUBFILTER` | Use `adbe.pkcs7.detached` instead of `ETSI.CAdES.detached` | `false` |
diff --git a/apps/docs/content/docs/self-hosting/configuration/signing-certificate/csc-qes.mdx b/apps/docs/content/docs/self-hosting/configuration/signing-certificate/csc-qes.mdx
new file mode 100644
index 000000000..0495430d7
--- /dev/null
+++ b/apps/docs/content/docs/self-hosting/configuration/signing-certificate/csc-qes.mdx
@@ -0,0 +1,213 @@
+---
+title: CSC (AES / QES)
+description: Configure Cloud Signature Consortium signing for Advanced and Qualified Electronic Signatures via a third-party Trust Service Provider.
+---
+
+import { Callout } from 'fumadocs-ui/components/callout';
+import { Step, Steps } from 'fumadocs-ui/components/steps';
+
+The `csc` signing transport routes signatures through a third-party Trust Service Provider (TSP) using the [Cloud Signature Consortium API v1.0.4.0](https://cloudsignatureconsortium.org/). Each recipient authenticates directly with the TSP (Strong Customer Authentication) and the TSP returns a per-recipient signature bound to the document hash. Documenso assembles the resulting PAdES signature inside the PDF.
+
+This transport enables **Advanced Electronic Signatures (AES)** and **Qualified Electronic Signatures (QES)** under eIDAS. See [Signature Levels](/docs/compliance/signature-levels) for the legal framework.
+
+
+ CSC mode is **instance-wide**: one CSC provider per Documenso install. All envelopes created
+ while the instance runs in `csc` mode use AES or QES. Switching `NEXT_PRIVATE_SIGNING_TRANSPORT`
+ is a one-way operational migration — see [Switching Transports](#switching-transports).
+
+
+
+ CSC mode requires an active [Enterprise Edition](/docs/policies/enterprise-edition) license. The
+ instance refuses to start in `csc` mode without it.
+
+
+## Prerequisites
+
+{/* prettier-ignore */}
+
+
+
+### A TSP account
+
+Establish a relationship with a CSC-compatible Trust Service Provider. The TSP issues qualified or advanced certificates to your signers, holds the private keys in its HSM, and exposes a CSC v1.0.4.0-compliant API.
+
+
+
+
+### OAuth client credentials
+
+Register Documenso as an OAuth client with the TSP. You will receive a client ID and client secret, and must supply Documenso's callback URL when registering:
+
+```
+${NEXT_PUBLIC_WEBAPP_URL}/api/csc/oauth/callback
+```
+
+The callback URL is fixed — Documenso derives it from `NEXT_PUBLIC_WEBAPP_URL` and the route mount path. There is no env var to override it; ensuring the registered URL matches your instance's webapp URL exactly is the operator's responsibility.
+
+
+
+
+### Enterprise Edition license
+
+CSC mode is gated by the `instanceCscSigning` license flag. Without a valid Enterprise license, the transport refuses to start (`CSC_UNLICENSED`).
+
+
+
+
+### S3 storage (strongly recommended)
+
+CSC produces multiple `DocumentData` rows per envelope item (one per recipient signature, plus the materialised and source rows). Database-backed storage base64-inflates each row by ~33% and is impractical at meaningful PDF sizes. Configure [S3 storage](/docs/self-hosting/configuration/storage) before enabling CSC.
+
+
+
+
+## Environment Variables
+
+| Variable | Description | Default |
+| ---------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- | ------- |
+| `NEXT_PRIVATE_SIGNING_TRANSPORT` | Set to `csc` | |
+| `NEXT_PRIVATE_SIGNING_CSC_PROVIDER_BASE_URL` | Base URL of the CSC provider's API | |
+| `NEXT_PRIVATE_SIGNING_CSC_OAUTH_CLIENT_ID` | OAuth client ID registered with the CSC provider | |
+| `NEXT_PRIVATE_SIGNING_CSC_OAUTH_CLIENT_SECRET` | OAuth client secret registered with the CSC provider | |
+| `NEXT_PRIVATE_SIGNING_CSC_SIGNATURE_LEVEL` | Default legal tier for new envelopes when the caller does not specify one. `AES` or `QES`. Explicit requests always pass through. | `AES` |
+| `NEXT_PRIVATE_SIGNING_TIMESTAMP_AUTHORITY` | **Required.** Comma-separated RFC 3161 TSA URLs. Always used for B-LTA archival timestamps at seal time, and also serves as the B-T sign-time fallback when the TSP does not expose `signatures/timestamp`. The instance refuses to start in CSC mode without it. See [Timestamp Authority Resolution](#timestamp-authority-resolution). | |
+
+
+ `NEXT_PUBLIC_SIGNING_TRANSPORT_IS_CSC` is set automatically from
+ `NEXT_PRIVATE_SIGNING_TRANSPORT` at server startup. Do not set it manually — see
+ [Environment Variables](/docs/self-hosting/configuration/environment#derived-public-variables).
+
+
+## Configuration Example
+
+```bash
+NEXT_PRIVATE_SIGNING_TRANSPORT=csc
+NEXT_PRIVATE_SIGNING_CSC_PROVIDER_BASE_URL=https://api.example-tsp.com/csc/v1
+NEXT_PRIVATE_SIGNING_CSC_OAUTH_CLIENT_ID=documenso-prod
+NEXT_PRIVATE_SIGNING_CSC_OAUTH_CLIENT_SECRET=...
+NEXT_PRIVATE_SIGNING_CSC_SIGNATURE_LEVEL=QES
+NEXT_PRIVATE_SIGNING_TIMESTAMP_AUTHORITY=http://timestamp.example.com
+```
+
+Register `${NEXT_PUBLIC_WEBAPP_URL}/api/csc/oauth/callback` (e.g. `https://sign.example.com/api/csc/oauth/callback`) as the OAuth callback URL with the TSP.
+
+## Default Signature Level
+
+`NEXT_PRIVATE_SIGNING_CSC_SIGNATURE_LEVEL` selects the legal tier applied to envelopes that do not specify one explicitly. It is a default, not a capability gate: callers may still create AES or QES envelopes explicitly regardless of this setting.
+
+| Configured value | Caller passes nothing | Caller passes `AES` | Caller passes `QES` |
+| ---------------- | --------------------- | ------------------- | ------------------- |
+| `AES` (default) | Envelope is `AES` | Envelope is `AES` | Envelope is `QES` |
+| `QES` | Envelope is `QES` | Envelope is `AES` | Envelope is `QES` |
+
+Any value other than `AES` or `QES` causes the instance to refuse to start. This prevents silent qualified-to-advanced downgrades from a typo.
+
+## Timestamp Authority Resolution
+
+AES/QES envelopes use TSA-attested timestamps in two distinct phases. Resolution differs per phase.
+
+### Sign time — PAdES B-T per recipient
+
+Each recipient's CMS embeds a signature timestamp (CMS unsigned attribute) so proven time is bound to the recipient's signature itself. Resolution order:
+
+1. If the TSP advertises `signatures/timestamp` in its `info` response (CSC §11.10), the TSP endpoint is used. The call is authorised with **this recipient's** service-scope bearer token — the same one authorising the `signatures/signHash` call alongside it.
+2. Otherwise, the first URL from `NEXT_PRIVATE_SIGNING_TIMESTAMP_AUTHORITY` is used (RFC 3161 over HTTP).
+
+Selection is made at boot from the discovered transport, not at runtime; there is no try-then-fall-through. If the chosen source fails, the recipient's sign attempt fails.
+
+### Seal time — PAdES B-LTA archival
+
+The seal-document job emits a single archival `/DocTimeStamp` over the fully-signed envelope (plus DSS for the existing signatures and the timestamp's own chain). This phase is **env-only**: the first URL from `NEXT_PRIVATE_SIGNING_TIMESTAMP_AUTHORITY` is always used.
+
+The archival anchor is the operator's long-term trust anchor and SHOULD point at a dedicated qualified archival TSA (e.g. DigiCert) independent of the per-recipient TSP. We deliberately do not fall back to the TSP at seal time: archive longevity should not be coupled to a TSP that may rotate or revoke, and the seal-document job has no recipient context to carry a service-scope bearer.
+
+### Boot-time guard
+
+The instance refuses to start in CSC mode unless `NEXT_PRIVATE_SIGNING_TIMESTAMP_AUTHORITY` is set (`CSC_PROVIDER_NO_TSA` at transport construction). The env var is required unconditionally — even when the TSP advertises its own `signatures/timestamp`, seal-time B-LTA archival uses the env TSA. Catching this at boot prevents the failure mode where an envelope signs successfully at B-T and then hangs in `WAITING_FOR_SIGNATURE_COMPLETION` when the seal job throws.
+
+## Switching Transports
+
+`NEXT_PRIVATE_SIGNING_TRANSPORT` is a one-way operational migration. Existing envelopes route per the `signatureLevel` column they were created with — the runtime branching looks at the envelope, not the env var. After a switch:
+
+- Envelopes already at `SES` continue to use the new transport for sealing, but the new transport's signer must produce SES-compatible signatures (only `local` and `gcloud-hsm` qualify).
+- Envelopes already at `AES` / `QES` will fail at sign or seal time if the new transport is not `csc`.
+
+Plan migrations during a quiet window with no in-flight envelopes.
+
+## Behavioural Notes
+
+CSC mode changes a number of envelope-authoring behaviours that operators should communicate to users.
+
+### Mutation lock at distribution
+
+For AES/QES envelopes, all authoring routes refuse mutations once the envelope leaves DRAFT. This locks the PDF before any recipient begins Strong Customer Authentication, closing the PDF-swap window that would otherwise allow an owner to replace the PDF between view and sign and break the legal "what you see is what you sign" guarantee.
+
+In practice: edit envelope, recipients, fields, and items freely while DRAFT; once sent, no changes are accepted (including from the API).
+
+### Sequential signing only
+
+Parallel signing produces conflicting incremental updates over the same base PDF, breaking the per-recipient `/ByteRange` invariant. The signing order is forced to `SEQUENTIAL` on AES/QES envelopes — at the schema layer, at send time, and in the UI (the parallel-signing toggle is hidden).
+
+### Assistant role and Dictate Next Signer disabled
+
+Both features modify the recipient set after the envelope is sent, which is incompatible with the AES/QES mutation lock. They are hidden in the UI and rejected at the server schema layer.
+
+### Sidecar PDFs at download
+
+The signed PDF must remain byte-identical to what each recipient's TSP signature authorised — Documenso cannot decorate it after signing. Audit logs and the Certificate of Completion are generated on demand and delivered as separate PDFs:
+
+- `GET /sign/{token}/download` returns the signed PDF only (or a ZIP for multi-item envelopes).
+- `GET /sign/{token}/download?version=bundle` returns a ZIP containing the signed PDFs, audit log PDF, and Certificate of Completion.
+- The completion email attaches all three.
+
+## Recipient Flow
+
+For context when supporting end users, here is what a recipient experiences on an AES/QES envelope:
+
+1. Opens the email link, lands on the signing page.
+2. Documenso redirects to the TSP for Strong Customer Authentication (first visit only; cached for the session lifetime).
+3. Fills fields as normal.
+4. Clicks Sign → redirected to the TSP for a second authentication round (issues a per-document Signature Activation Data token).
+5. Returns to Documenso; the signing call completes within ~15 seconds.
+6. Sees the standard completion screen.
+
+If the TSP returns no eligible credentials for the recipient (e.g. they have not enrolled), they see a blocking page directing them to enrol with the TSP and retry.
+
+## Error Codes
+
+CSC-specific error codes surfaced through the standard error channels:
+
+| Code | Meaning | Recovery |
+| -------------------------- | ------------------------------------------------ | ----------------------------------------------------------------------- |
+| `CSC_UNLICENSED` | License flag absent at transport-create | Operator: enable Enterprise Edition, restart |
+| `CSC_PROVIDER_INFO_FAILED` | `info` discovery failed at startup | Operator: check TSP availability and `NEXT_PRIVATE_SIGNING_CSC_PROVIDER_BASE_URL` |
+| `CSC_PROVIDER_NO_TSA` | `NEXT_PRIVATE_SIGNING_TIMESTAMP_AUTHORITY` is unset | Operator: configure `NEXT_PRIVATE_SIGNING_TIMESTAMP_AUTHORITY` |
+| `CSC_CREDENTIAL_LIST_EMPTY`| TSP returned no credentials for the user | Recipient: enrol with the TSP |
+| `CSC_CERT_INVALID` | Certificate refused at credential validation | Recipient: contact the TSP |
+| `CSC_ALGORITHM_REFUSED` | Signature algorithm fails policy | Operator/recipient: TSP does not meet policy (see below) |
+| `CSC_SAD_EXPIRED_PRE_SIGN` | Signature Activation Data expired before signing | Recipient: retry from Sign |
+| `CSC_TSP_TIMEOUT` | 15-second synchronous timeout reached | Recipient: retry (idempotent — the TSP enforces single-use SAD binding) |
+| `CSC_EMBED_FAILED` | Sign-time digest diverged from prep capture | Recipient: retry from Sign |
+| `CSC_BASE_DOCUMENT_MUTATED`| Document data changed between prep and sign | Operator: investigate (structural guard violation) |
+| `CSC_INSTANCE_MODE_MISMATCH`| Envelope created with wrong level for transport | Caller: use a level matching the instance transport |
+| `CSC_REQUEST_FAILED` | TSP HTTP transport failure — network error, non-2xx, or malformed response | Operator: check TSP availability; carries the TSP HTTP status and error in the message |
+
+## Algorithm Policy
+
+Documenso refuses TSP credentials that do not meet the following minimums, at the OAuth callback boundary and again at sign time:
+
+| Class | Allowed | Refused |
+| ----- | ---------------------------------- | ------------------------------------------------------ |
+| RSA | `key.len >= 2048` | Missing `key.len`, `key.len < 2048` |
+| ECDSA | P-256, P-384, P-521 | Missing `key.curve`, P-192, P-224, other curves |
+| Hash | SHA-256, SHA-384, SHA-512 | SHA-1, MD5 |
+| Other | — | DSA |
+
+This is the union of CSC v1.0.4.0 §11.5 requirements and current cryptographic guidance.
+
+## Related
+
+- [Signature Levels](/docs/compliance/signature-levels) — AES / QES legal framework
+- [Signing Certificate](/docs/self-hosting/configuration/signing-certificate) — overview of all signing transports
+- [Environment Variables](/docs/self-hosting/configuration/environment) — full env reference
+- [Enterprise Edition](/docs/policies/enterprise-edition) — license requirements
diff --git a/apps/docs/content/docs/self-hosting/configuration/signing-certificate/index.mdx b/apps/docs/content/docs/self-hosting/configuration/signing-certificate/index.mdx
index 89a12a327..a2d5a0a7c 100644
--- a/apps/docs/content/docs/self-hosting/configuration/signing-certificate/index.mdx
+++ b/apps/docs/content/docs/self-hosting/configuration/signing-certificate/index.mdx
@@ -24,6 +24,11 @@ Self-hosted Documenso instances require a signing certificate. You can generate
description="Hardware-based key protection with Google Cloud KMS."
href="/docs/self-hosting/configuration/signing-certificate/google-cloud-hsm"
/>
+
+
A self-signed certificate is sufficient for most use cases where your industry has no special signing regulations.
@@ -79,6 +84,18 @@ For organisations requiring hardware-based key protection, Documenso supports Go
See [Google Cloud HSM](/docs/self-hosting/configuration/signing-certificate/google-cloud-hsm) for setup instructions.
+
+
+
+For Advanced and Qualified Electronic Signatures under eIDAS, Documenso integrates with third-party Trust Service Providers via the Cloud Signature Consortium API. Each recipient authenticates directly with the TSP, which holds the private key and issues the signature.
+
+- Per-recipient identity verification by an accredited TSP
+- Legally equivalent to a handwritten signature within the EU (QES)
+- Requires an [Enterprise Edition](/docs/policies/enterprise-edition) license
+- Instance-wide setting; one CSC provider per Documenso install
+
+See [CSC (AES / QES)](/docs/self-hosting/configuration/signing-certificate/csc-qes) for setup instructions.
+
diff --git a/apps/docs/content/docs/self-hosting/configuration/signing-certificate/meta.json b/apps/docs/content/docs/self-hosting/configuration/signing-certificate/meta.json
index d37038af3..b52f83f9e 100644
--- a/apps/docs/content/docs/self-hosting/configuration/signing-certificate/meta.json
+++ b/apps/docs/content/docs/self-hosting/configuration/signing-certificate/meta.json
@@ -1,4 +1,4 @@
{
"title": "Signing Certificate",
- "pages": ["...index", "local", "google-cloud-hsm", "timestamp-server", "troubleshooting"]
+ "pages": ["...index", "local", "google-cloud-hsm", "csc-qes", "timestamp-server", "troubleshooting"]
}
diff --git a/apps/remix/app/components/dialogs/document-move-to-folder-dialog.tsx b/apps/remix/app/components/dialogs/document-move-to-folder-dialog.tsx
deleted file mode 100644
index 694f2d56a..000000000
--- a/apps/remix/app/components/dialogs/document-move-to-folder-dialog.tsx
+++ /dev/null
@@ -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;
-
-const ZMoveDocumentFormSchema = z.object({
- folderId: z.string().nullable().optional(),
-});
-
-type TMoveDocumentFormSchema = z.infer;
-
-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({
- 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 (
-
- );
-};
diff --git a/apps/remix/app/components/dialogs/document-resend-dialog.tsx b/apps/remix/app/components/dialogs/document-resend-dialog.tsx
deleted file mode 100644
index 493d07138..000000000
--- a/apps/remix/app/components/dialogs/document-resend-dialog.tsx
+++ /dev/null
@@ -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 & {
- user: Pick;
- recipients: TRecipientLite[];
- team: Pick | 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;
-
-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({
- 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 (
-
- );
-};
diff --git a/apps/remix/app/components/dialogs/envelope-cancel-dialog.tsx b/apps/remix/app/components/dialogs/envelope-cancel-dialog.tsx
new file mode 100644
index 000000000..8cb90cfa0
--- /dev/null
+++ b/apps/remix/app/components/dialogs/envelope-cancel-dialog.tsx
@@ -0,0 +1,134 @@
+import { trpc as trpcReact } from '@documenso/trpc/react';
+import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
+import { Button } from '@documenso/ui/primitives/button';
+import {
+ Dialog,
+ DialogClose,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from '@documenso/ui/primitives/dialog';
+import { Label } from '@documenso/ui/primitives/label';
+import { Textarea } from '@documenso/ui/primitives/textarea';
+import { useToast } from '@documenso/ui/primitives/use-toast';
+import { Trans, useLingui } from '@lingui/react/macro';
+import { useEffect, useState } from 'react';
+
+export type EnvelopeCancelDialogProps = {
+ id: string;
+ title: string;
+ trigger?: React.ReactNode;
+ onCancel?: () => Promise | void;
+};
+
+export const EnvelopeCancelDialog = ({ id, title, trigger, onCancel }: EnvelopeCancelDialogProps) => {
+ const { toast } = useToast();
+ const { t } = useLingui();
+ const trpcUtils = trpcReact.useUtils();
+
+ const [open, setOpen] = useState(false);
+ const [reason, setReason] = useState('');
+
+ const { mutateAsync: cancelEnvelope, isPending } = trpcReact.envelope.cancel.useMutation({
+ onSuccess: async () => {
+ toast({
+ title: t`Document cancelled`,
+ description: t`"${title}" has been successfully cancelled`,
+ duration: 5000,
+ });
+
+ await trpcUtils.document.findDocumentsInternal.invalidate();
+
+ await onCancel?.();
+
+ setOpen(false);
+ },
+ onError: () => {
+ toast({
+ title: t`Something went wrong`,
+ description: t`This document could not be cancelled at this time. Please try again.`,
+ variant: 'destructive',
+ duration: 7500,
+ });
+ },
+ });
+
+ useEffect(() => {
+ if (open) {
+ setReason('');
+ }
+ }, [open]);
+
+ return (
+
+ );
+};
diff --git a/apps/remix/app/components/dialogs/envelope-delete-dialog.tsx b/apps/remix/app/components/dialogs/envelope-delete-dialog.tsx
index 1130a1450..aea4c766c 100644
--- a/apps/remix/app/components/dialogs/envelope-delete-dialog.tsx
+++ b/apps/remix/app/components/dialogs/envelope-delete-dialog.tsx
@@ -166,7 +166,7 @@ export const EnvelopeDeleteDialog = ({
))
- .with(P.union(DocumentStatus.COMPLETED, DocumentStatus.REJECTED), () => (
+ .with(P.union(DocumentStatus.COMPLETED, DocumentStatus.REJECTED, DocumentStatus.CANCELLED), () => (
By deleting this document, the following will occur:
diff --git a/apps/remix/app/components/dialogs/envelope-distribute-dialog.tsx b/apps/remix/app/components/dialogs/envelope-distribute-dialog.tsx
index 750ab38f2..ed495f83a 100644
--- a/apps/remix/app/components/dialogs/envelope-distribute-dialog.tsx
+++ b/apps/remix/app/components/dialogs/envelope-distribute-dialog.tsx
@@ -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 = ({
-
-
- );
-}
diff --git a/apps/remix/app/components/general/direct-template/direct-template-page.tsx b/apps/remix/app/components/general/direct-template/direct-template-page.tsx
index 24753f7e0..9f6ea47dc 100644
--- a/apps/remix/app/components/general/direct-template/direct-template-page.tsx
+++ b/apps/remix/app/components/general/direct-template/direct-template-page.tsx
@@ -13,7 +13,7 @@ import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import type { Field, Recipient } from '@prisma/client';
import { useState } from 'react';
-import { useNavigate, useSearchParams } from 'react-router';
+import { useSearchParams } from 'react-router';
import { useRequiredDocumentSigningAuthContext } from '~/components/general/document-signing/document-signing-auth-provider';
import { useRequiredDocumentSigningContext } from '~/components/general/document-signing/document-signing-provider';
@@ -37,7 +37,6 @@ export const DirectTemplatePageView = ({
directTemplateRecipient,
directTemplateToken,
}: DirectTemplatePageViewProps) => {
- const navigate = useNavigate();
const [searchParams] = useSearchParams();
const { _ } = useLingui();
@@ -119,7 +118,7 @@ export const DirectTemplatePageView = ({
if (redirectUrl) {
window.location.href = redirectUrl;
} else {
- await navigate(`/sign/${token}/complete`);
+ window.location.href = `/sign/${token}/complete`;
}
} catch (err) {
const error = AppError.parseError(err);
diff --git a/apps/remix/app/components/general/document-signing/csc-recipient-blocked-page.tsx b/apps/remix/app/components/general/document-signing/csc-recipient-blocked-page.tsx
new file mode 100644
index 000000000..0e2bb6fb2
--- /dev/null
+++ b/apps/remix/app/components/general/document-signing/csc-recipient-blocked-page.tsx
@@ -0,0 +1,68 @@
+import { AppErrorCode } from '@documenso/lib/errors/app-error';
+import { Button } from '@documenso/ui/primitives/button';
+import { Trans } from '@lingui/react/macro';
+import { AlertTriangleIcon } from 'lucide-react';
+
+export type CscRecipientBlockedPageProps = {
+ code: string;
+ recipientToken: string;
+};
+
+/**
+ * Terminal page rendered when the service-scope CSC OAuth callback surfaces a
+ * hard error the recipient can't resolve themselves (empty credential list,
+ * invalid cert, refused algorithm). The blocking-error cookie is read +
+ * cleared by the loader; this page only renders the message + retry CTA.
+ *
+ * The retry link kicks a fresh service-scope OAuth round-trip — useful when
+ * the TSP-side issue is transient (e.g. the recipient's admin has since
+ * provisioned a credential).
+ */
+export const CscRecipientBlockedPage = ({ code, recipientToken }: CscRecipientBlockedPageProps) => {
+ const retryUrl = `/api/csc/oauth/authorize?scope=service&token=${encodeURIComponent(recipientToken)}`;
+
+ return (
+
+
+
+
+ {code === AppErrorCode.CSC_CREDENTIAL_LIST_EMPTY ? (
+ No signing credentials available
+ ) : code === AppErrorCode.CSC_CERT_INVALID ? (
+ Signing certificate is invalid
+ ) : code === AppErrorCode.CSC_ALGORITHM_REFUSED ? (
+ Signing algorithm is not supported
+ ) : (
+ Unable to start the signing flow
+ )}
+
+
+
+ {code === AppErrorCode.CSC_CREDENTIAL_LIST_EMPTY ? (
+
+ Your signing provider returned no usable credentials for this account. Contact your administrator or signing
+ provider for assistance.
+
+ ) : code === AppErrorCode.CSC_CERT_INVALID ? (
+
+ Your signing certificate is invalid, expired, or missing a required key. Contact your administrator or
+ signing provider for assistance.
+
+ ) : code === AppErrorCode.CSC_ALGORITHM_REFUSED ? (
+
+ Your signing provider does not advertise a signing algorithm this document accepts. Contact your
+ administrator or signing provider for assistance.
+
+ ) : (
+ Something went wrong while preparing the remote signature. Please try again.
+ )}
+
+
+
+
+ );
+};
diff --git a/apps/remix/app/components/general/document-signing/csc-recipient-signing-in-progress-page.tsx b/apps/remix/app/components/general/document-signing/csc-recipient-signing-in-progress-page.tsx
new file mode 100644
index 000000000..1aa091b35
--- /dev/null
+++ b/apps/remix/app/components/general/document-signing/csc-recipient-signing-in-progress-page.tsx
@@ -0,0 +1,105 @@
+import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
+import { trpc } from '@documenso/trpc/react';
+import { Button } from '@documenso/ui/primitives/button';
+import { Trans } from '@lingui/react/macro';
+import { AlertTriangleIcon, Loader2Icon } from 'lucide-react';
+import { useEffect, useRef, useState } from 'react';
+
+export type CscRecipientSigningInProgressPageProps = {
+ sessionId: string;
+ recipientToken: string;
+};
+
+/**
+ * Rendered when the credential-scope OAuth callback has attached a SAD to the
+ * server-side `CscSession` and set the `csc_sad_session` cookie. The page
+ * auto-fires `enterprise.csc.signEnvelope` on mount and navigates to the
+ * completion page on success. On failure, it surfaces an error message and
+ * a retry CTA pointing at a fresh credential-scope OAuth round-trip.
+ */
+export const CscRecipientSigningInProgressPage = ({
+ sessionId,
+ recipientToken,
+}: CscRecipientSigningInProgressPageProps) => {
+ const { mutateAsync: signEnvelope } = trpc.enterprise.csc.signEnvelope.useMutation();
+
+ const [error, setError] = useState(null);
+
+ // Ref rather than state for the fire-once guard. Refs mutate synchronously,
+ // so React StrictMode's double-invoke of the effect sees the updated value
+ // on the second pass and short-circuits. A useState guard would still let
+ // the second effect fire because the queued setState from the first run
+ // hasn't been committed yet when the second one reads it — that double-fire
+ // races two signEnvelope calls; whichever loses sees the SAD already
+ // consumed and flashes "Signing failed" before the winning call's
+ // navigation kicks in.
+ const hasFiredRef = useRef(false);
+
+ useEffect(() => {
+ if (hasFiredRef.current) {
+ return;
+ }
+
+ hasFiredRef.current = true;
+
+ const run = async () => {
+ try {
+ await signEnvelope({ sessionId, recipientToken });
+
+ window.location.href = `/sign/${recipientToken}/complete`;
+ } catch (err) {
+ const parsed = AppError.parseError(err);
+ setError(parsed.code || AppErrorCode.UNKNOWN_ERROR);
+ }
+ };
+
+ void run();
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+
+ const retryUrl = `/api/csc/oauth/authorize?scope=credential&session=${encodeURIComponent(sessionId)}`;
+
+ return (
+
+ {error ? (
+ <>
+
+
+
+ Signing failed
+
+
+
+ {error === AppErrorCode.CSC_TSP_TIMEOUT ? (
+ The signing provider did not respond in time. Please retry.
+ ) : error === AppErrorCode.CSC_SAD_EXPIRED_PRE_SIGN ? (
+
+ Your signing authorisation expired before the signature could be applied. Please reauthorise to retry.
+
+ ) : (
+ Something went wrong while applying your signature. Please retry.
+ )}
+
+
+
+ >
+ ) : (
+ <>
+
+
+
+ Applying your signature
+
+
+
+ Please don't close this tab. The signing provider is finalising your signature.
+
+
+ }
+ />
+ )}
icon: XCircle,
color: 'text-red-500 dark:text-red-300',
},
+ CANCELLED: {
+ label: msg`Cancelled`,
+ labelExtended: msg`Document cancelled`,
+ icon: XCircle,
+ color: 'text-red-500 dark:text-red-300',
+ },
INBOX: {
label: msg`Inbox`,
labelExtended: msg`Document inbox`,
diff --git a/apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page-renderer.tsx b/apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page-renderer.tsx
index a5676dc60..db8f6ffbb 100644
--- a/apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page-renderer.tsx
+++ b/apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page-renderer.tsx
@@ -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();
+
+ 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) => {
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;
+
+ 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
diff --git a/apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page.tsx b/apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page.tsx
index a5ec4ed16..945d3c308 100644
--- a/apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page.tsx
+++ b/apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page.tsx
@@ -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 = () => {
)}
+ {overlappingFieldPairs.length > 0 && (
+
+
+
+
+
+
+ Overlapping fields detected
+
+
+
+ Some fields are placed on top of each other. This may complicate the signing process or cause
+ fields to not work as expected.
+
+
+
+
+
+ )}
+
{currentEnvelopeItem !== null ? (
Rejected
))
+ .with(DocumentStatus.CANCELLED, () => (
+
+ Cancelled
+
+ ))
.exhaustive()}
{autosaveError && (
diff --git a/apps/remix/app/components/general/envelope-signing/envelope-signing-complete-dialog.tsx b/apps/remix/app/components/general/envelope-signing/envelope-signing-complete-dialog.tsx
index c62db2f11..2035e65b8 100644
--- a/apps/remix/app/components/general/envelope-signing/envelope-signing-complete-dialog.tsx
+++ b/apps/remix/app/components/general/envelope-signing/envelope-signing-complete-dialog.tsx
@@ -89,7 +89,7 @@ export const EnvelopeSignerCompleteDialog = () => {
recipientDetails?: { name: string; email: string },
) => {
try {
- await completeDocument({
+ const result = await completeDocument({
token: recipient.token,
documentId: mapSecondaryIdToDocumentId(envelope.secondaryId),
accessAuthOptions,
@@ -97,6 +97,15 @@ export const EnvelopeSignerCompleteDialog = () => {
...(nextSigner?.email && nextSigner?.name ? { nextSigner } : {}),
});
+ // TSP envelopes can't be completed via the SES path; the mutation returns
+ // a credential-scope OAuth URL the recipient must follow to acquire a SAD
+ // before the sync sign mutation can run. Short-circuit here so the
+ // analytics / completion handlers don't run with a still-unsigned doc.
+ if (result.status === 'REDIRECT') {
+ window.location.href = result.redirectUrl;
+ return;
+ }
+
analytics.capture('App: Recipient has completed signing', {
signerId: recipient.id,
documentId: envelope.id,
@@ -119,7 +128,7 @@ export const EnvelopeSignerCompleteDialog = () => {
if (envelope.documentMeta.redirectUrl) {
window.location.href = envelope.documentMeta.redirectUrl;
} else {
- await navigate(`/sign/${recipient.token}/complete`);
+ window.location.href = `/sign/${recipient.token}/complete`;
}
} catch (err) {
const error = AppError.parseError(err);
@@ -197,7 +206,7 @@ export const EnvelopeSignerCompleteDialog = () => {
if (redirectUrl) {
window.location.href = redirectUrl;
} else {
- await navigate(`/sign/${token}/complete`);
+ window.location.href = `/sign/${token}/complete`;
}
} catch (err) {
console.log('err', err);
diff --git a/apps/remix/app/components/tables/documents-table-action-button.tsx b/apps/remix/app/components/tables/documents-table-action-button.tsx
index a6e9a9edd..000b46780 100644
--- a/apps/remix/app/components/tables/documents-table-action-button.tsx
+++ b/apps/remix/app/components/tables/documents-table-action-button.tsx
@@ -66,7 +66,7 @@ export const DocumentsTableActionButton = ({ row }: DocumentsTableActionButtonPr
))
.with({ isRecipient: true, isPending: true, isSigned: false }, () => (
))
.with({ isPending: true, isSigned: true }, () => (
diff --git a/apps/remix/app/components/tables/documents-table-action-dropdown.tsx b/apps/remix/app/components/tables/documents-table-action-dropdown.tsx
index 1555d54a2..febe3ea1a 100644
--- a/apps/remix/app/components/tables/documents-table-action-dropdown.tsx
+++ b/apps/remix/app/components/tables/documents-table-action-dropdown.tsx
@@ -3,7 +3,7 @@ import type { TDocumentMany as TDocumentRow } from '@documenso/lib/types/documen
import { isDocumentCompleted } from '@documenso/lib/utils/document';
import { getEnvelopeItemPermissions } from '@documenso/lib/utils/envelope';
import { findRecipientByEmail } from '@documenso/lib/utils/recipients';
-import { formatDocumentsPath } from '@documenso/lib/utils/teams';
+import { formatDocumentsPath, isMemberManagerOrAbove } from '@documenso/lib/utils/teams';
import { trpc as trpcReact } from '@documenso/trpc/react';
import { DocumentShareButton } from '@documenso/ui/components/document/document-share-button';
import {
@@ -25,18 +25,21 @@ import {
EyeIcon,
FileOutputIcon,
FolderInput,
+ History,
Loader,
MoreHorizontal,
Pencil,
Share,
Trash2,
+ XCircle,
} from 'lucide-react';
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';
@@ -74,6 +77,12 @@ export const DocumentsTableActionDropdown = ({ row, onMoveDocument }: DocumentsT
const isCurrentTeamDocument = team && row.team?.url === team.url;
const canManageDocument = Boolean(isOwner || isCurrentTeamDocument);
+ // Cancelling a document is restricted server-side to the document owner or a
+ // privileged team member (ADMIN/MANAGER). Mirror that here so plain MEMBERs
+ // don't see a Cancel action that would fail on the server.
+ const isPrivilegedTeamMember = isMemberManagerOrAbove(team.currentTeamRole);
+ const canCancelDocument = isOwner || isPrivilegedTeamMember;
+
const { canTitleBeChanged } = getEnvelopeItemPermissions(
{
completedAt: row.completedAt,
@@ -87,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 (
@@ -105,7 +112,7 @@ export const DocumentsTableActionDropdown = ({ row, onMoveDocument }: DocumentsT
recipient?.role !== RecipientRole.CC &&
recipient?.role !== RecipientRole.ASSISTANT && (
-
+
{recipient?.role === RecipientRole.VIEWER && (
<>
@@ -126,7 +133,7 @@ export const DocumentsTableActionDropdown = ({ row, onMoveDocument }: DocumentsT
Approve
>
)}
-
+
)}
@@ -184,11 +191,23 @@ export const DocumentsTableActionDropdown = ({ row, onMoveDocument }: DocumentsT
)}
- {/* No point displaying this if there's no functionality. */}
- {/*
-
- Void
- */}
+ {canCancelDocument && isPending && (
+ {
+ await trpcUtils.document.findDocumentsInternal.invalidate();
+ }}
+ trigger={
+ e.preventDefault()}>
+