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 ( - - - - - Move Document to Folder - - - - Select a folder to move this document to. - - - -
- - setSearchTerm(e.target.value)} - className="pl-8" - /> -
- -
- - ( - - - Folder - - - -
- {isFoldersLoading ? ( -
- -
- ) : ( - <> - - - {filteredFolders?.map((folder) => ( - - ))} - - {searchTerm && filteredFolders?.length === 0 && ( -
- No folders found -
- )} - - )} -
-
- -
- )} - /> - - - - - - - - -
-
- ); -}; 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 ( - - - e.preventDefault()}> - - Resend - - - - - - -

- Who do you want to remind? -

-
-
- -
- - ( - <> - {recipients.map((recipient) => ( - - - - {recipient.email} - - - - - checked - ? onChange([...value, recipient.id]) - : onChange(value.filter((v) => v !== recipient.id)) - } - /> - - - ))} - - )} - /> - - - - -
- - - - - -
-
-
-
- ); -}; 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 ( + !isPending && setOpen(value)}> + {trigger} + + + + + Are you sure? + + + + + You are about to cancel "{title}" + + + + + + +

+ Once confirmed, the following will occur: +

+ +
    +
  • + The document signing process will be stopped +
  • +
  • + Recipients will be notified that the document was cancelled +
  • +
  • + The document will remain in your dashboard marked as Cancelled +
  • +
+
+
+ +
+ + +