Merge branch 'main' into fix/name-input-invalid-characters

This commit is contained in:
Catalin Pit
2026-06-25 08:29:40 +03:00
committed by GitHub
272 changed files with 16244 additions and 2563 deletions
+9 -1
View File
@@ -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.
@@ -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` |
@@ -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.
<Callout type="warn">
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).
</Callout>
<Callout type="warn">
CSC mode requires an active [Enterprise Edition](/docs/policies/enterprise-edition) license. The
instance refuses to start in `csc` mode without it.
</Callout>
## Prerequisites
{/* prettier-ignore */}
<Steps>
<Step>
### 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.
</Step>
<Step>
### 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.
</Step>
<Step>
### Enterprise Edition license
CSC mode is gated by the `instanceCscSigning` license flag. Without a valid Enterprise license, the transport refuses to start (`CSC_UNLICENSED`).
</Step>
<Step>
### 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.
</Step>
</Steps>
## 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). | |
<Callout type="info">
`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).
</Callout>
## 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
@@ -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"
/>
<Card
title="CSC (AES / QES)"
description="Route signing through a third-party Trust Service Provider for Advanced and Qualified Electronic Signatures."
href="/docs/self-hosting/configuration/signing-certificate/csc-qes"
/>
<Card
title="Timestamp Server"
description="Add trusted timestamps and customise signature appearance."
@@ -38,7 +43,7 @@ Self-hosted Documenso instances require a signing certificate. You can generate
## Certificate Options
<Tabs items={['Self-Signed', 'CA-Issued', 'Google Cloud HSM']}>
<Tabs items={['Self-Signed', 'CA-Issued', 'Google Cloud HSM', 'CSC (AES / QES)']}>
<Tab value="Self-Signed">
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.
</Tab>
<Tab value="CSC (AES / QES)">
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.
</Tab>
</Tabs>
@@ -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"]
}
@@ -1,243 +0,0 @@
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { FolderType } from '@documenso/lib/types/folder-type';
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@documenso/ui/primitives/dialog';
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import type * as DialogPrimitive from '@radix-ui/react-dialog';
import { FolderIcon, HomeIcon, Loader2, Search } from 'lucide-react';
import { useEffect, useState } from 'react';
import { useForm } from 'react-hook-form';
import { useNavigate } from 'react-router';
import { z } from 'zod';
import { useCurrentTeam } from '~/providers/team';
export type DocumentMoveToFolderDialogProps = {
documentId: number;
open: boolean;
onOpenChange: (open: boolean) => void;
currentFolderId?: string;
} & Omit<DialogPrimitive.DialogProps, 'children'>;
const ZMoveDocumentFormSchema = z.object({
folderId: z.string().nullable().optional(),
});
type TMoveDocumentFormSchema = z.infer<typeof ZMoveDocumentFormSchema>;
export const DocumentMoveToFolderDialog = ({
documentId,
open,
onOpenChange,
currentFolderId,
...props
}: DocumentMoveToFolderDialogProps) => {
const { _ } = useLingui();
const { toast } = useToast();
const navigate = useNavigate();
const team = useCurrentTeam();
const [searchTerm, setSearchTerm] = useState('');
const form = useForm<TMoveDocumentFormSchema>({
resolver: zodResolver(ZMoveDocumentFormSchema),
defaultValues: {
folderId: currentFolderId,
},
});
const { data: folders, isLoading: isFoldersLoading } = trpc.folder.findFoldersInternal.useQuery(
{
parentId: currentFolderId,
type: FolderType.DOCUMENT,
},
{
enabled: open,
},
);
const { mutateAsync: updateDocument } = trpc.document.update.useMutation();
useEffect(() => {
if (!open) {
form.reset();
setSearchTerm('');
} else {
form.reset({ folderId: currentFolderId });
}
}, [open, currentFolderId, form]);
const onSubmit = async (data: TMoveDocumentFormSchema) => {
try {
await updateDocument({
documentId,
data: {
folderId: data.folderId ?? null,
},
});
const documentsPath = formatDocumentsPath(team.url);
if (data.folderId) {
await navigate(`${documentsPath}/f/${data.folderId}`);
} else {
await navigate(documentsPath);
}
toast({
title: _(msg`Document moved`),
description: _(msg`The document has been moved successfully.`),
variant: 'default',
});
onOpenChange(false);
} catch (err) {
const error = AppError.parseError(err);
if (error.code === AppErrorCode.NOT_FOUND) {
toast({
title: _(msg`Error`),
description: _(msg`The folder you are trying to move the document to does not exist.`),
variant: 'destructive',
});
return;
}
if (error.code === AppErrorCode.UNAUTHORIZED) {
toast({
title: _(msg`Error`),
description: _(msg`You are not allowed to move this document.`),
variant: 'destructive',
});
return;
}
toast({
title: _(msg`Error`),
description: _(msg`An error occurred while moving the document.`),
variant: 'destructive',
});
}
};
const filteredFolders = folders?.data.filter((folder) =>
folder.name.toLowerCase().includes(searchTerm.toLowerCase()),
);
return (
<Dialog {...props} open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>
<Trans>Move Document to Folder</Trans>
</DialogTitle>
<DialogDescription>
<Trans>Select a folder to move this document to.</Trans>
</DialogDescription>
</DialogHeader>
<div className="relative">
<Search className="absolute top-3 left-2 h-4 w-4 text-muted-foreground" />
<Input
placeholder={_(msg`Search folders...`)}
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-8"
/>
</div>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-y-4">
<FormField
control={form.control}
name="folderId"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Folder</Trans>
</FormLabel>
<FormControl>
<div className="max-h-96 space-y-2 overflow-y-auto">
{isFoldersLoading ? (
<div className="flex h-10 items-center justify-center">
<Loader2 className="h-4 w-4 animate-spin" />
</div>
) : (
<>
<Button
type="button"
variant={field.value === null ? 'default' : 'outline'}
className="w-full justify-start"
onClick={() => field.onChange(null)}
disabled={currentFolderId === null}
>
<HomeIcon className="mr-2 h-4 w-4" />
<Trans>Home (No Folder)</Trans>
</Button>
{filteredFolders?.map((folder) => (
<Button
key={folder.id}
type="button"
variant={field.value === folder.id ? 'default' : 'outline'}
className="w-full justify-start"
onClick={() => field.onChange(folder.id)}
disabled={currentFolderId === folder.id}
>
<FolderIcon className="mr-2 h-4 w-4" />
{folder.name}
</Button>
))}
{searchTerm && filteredFolders?.length === 0 && (
<div className="px-2 py-2 text-center text-muted-foreground text-sm">
<Trans>No folders found</Trans>
</div>
)}
</>
)}
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)}>
<Trans>Cancel</Trans>
</Button>
<Button
type="submit"
disabled={isFoldersLoading || form.formState.isSubmitting || currentFolderId === null}
>
<Trans>Move</Trans>
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
);
};
@@ -1,203 +0,0 @@
import { useSession } from '@documenso/lib/client-only/providers/session';
import { getRecipientType } from '@documenso/lib/client-only/recipient-type';
import { AppError } from '@documenso/lib/errors/app-error';
import type { TRecipientLite } from '@documenso/lib/types/recipient';
import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter';
import type { Document } from '@documenso/prisma/types/document-legacy-schema';
import { trpc as trpcReact } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import { Checkbox } from '@documenso/ui/primitives/checkbox';
import {
Dialog,
DialogClose,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@documenso/ui/primitives/dialog';
import { DropdownMenuItem } from '@documenso/ui/primitives/dropdown-menu';
import { Form, FormControl, FormField, FormItem, FormLabel } from '@documenso/ui/primitives/form/form';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { SigningStatus, type Team, type User } from '@prisma/client';
import { History } from 'lucide-react';
import { useState } from 'react';
import { useForm, useWatch } from 'react-hook-form';
import * as z from 'zod';
import { useCurrentTeam } from '~/providers/team';
import { getDistributeErrorMessage } from '~/utils/toast-error-messages';
import { StackAvatar } from '../general/stack-avatar';
const FORM_ID = 'resend-email';
export type DocumentResendDialogProps = {
document: Pick<Document, 'id' | 'userId' | 'teamId' | 'status'> & {
user: Pick<User, 'id' | 'name' | 'email'>;
recipients: TRecipientLite[];
team: Pick<Team, 'id' | 'url'> | null;
};
recipients: TRecipientLite[];
};
export const ZResendDocumentFormSchema = z.object({
recipients: z.array(z.number()).min(1, {
message: 'You must select at least one item.',
}),
});
export type TResendDocumentFormSchema = z.infer<typeof ZResendDocumentFormSchema>;
export const DocumentResendDialog = ({ document, recipients }: DocumentResendDialogProps) => {
const { user } = useSession();
const team = useCurrentTeam();
const { toast } = useToast();
const { _ } = useLingui();
const [isOpen, setIsOpen] = useState(false);
const isOwner = document.userId === user.id;
const isCurrentTeamDocument = team && document.team?.url === team.url;
const isDisabled =
(!isOwner && !isCurrentTeamDocument) ||
document.status !== 'PENDING' ||
!recipients.some((r) => r.signingStatus === SigningStatus.NOT_SIGNED);
const { mutateAsync: resendDocument } = trpcReact.document.redistribute.useMutation();
const form = useForm<TResendDocumentFormSchema>({
resolver: zodResolver(ZResendDocumentFormSchema),
defaultValues: {
recipients: [],
},
});
const {
handleSubmit,
formState: { isSubmitting },
} = form;
const selectedRecipients = useWatch({
control: form.control,
name: 'recipients',
});
const onFormSubmit = async ({ recipients }: TResendDocumentFormSchema) => {
try {
await resendDocument({ documentId: document.id, recipients });
toast({
title: _(msg`Document re-sent`),
description: _(msg`Your document has been re-sent successfully.`),
duration: 5000,
});
setIsOpen(false);
} catch (err) {
const error = AppError.parseError(err);
const errorMessage = getDistributeErrorMessage(error.code);
toast({
title: _(errorMessage.title),
description: _(errorMessage.description),
variant: 'destructive',
duration: 7500,
});
}
};
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
<DropdownMenuItem disabled={isDisabled} onSelect={(e) => e.preventDefault()}>
<History className="mr-2 h-4 w-4" />
<Trans>Resend</Trans>
</DropdownMenuItem>
</DialogTrigger>
<DialogContent className="sm:max-w-sm" hideClose>
<DialogHeader>
<DialogTitle asChild>
<h1 className="text-center text-xl">
<Trans>Who do you want to remind?</Trans>
</h1>
</DialogTitle>
</DialogHeader>
<Form {...form}>
<form id={FORM_ID} onSubmit={handleSubmit(onFormSubmit)} className="px-3">
<FormField
control={form.control}
name="recipients"
render={({ field: { value, onChange } }) => (
<>
{recipients.map((recipient) => (
<FormItem key={recipient.id} className="flex flex-row items-center justify-between gap-x-3">
<FormLabel
className={cn('my-2 flex items-center gap-2 font-normal', {
'opacity-50': !value.includes(recipient.id),
})}
>
<StackAvatar
key={recipient.id}
type={getRecipientType(recipient)}
fallbackText={recipientAbbreviation(recipient)}
/>
{recipient.email}
</FormLabel>
<FormControl>
<Checkbox
className="h-5 w-5 rounded-full border border-neutral-400"
value={recipient.id}
checked={value.includes(recipient.id)}
onCheckedChange={(checked: boolean) =>
checked
? onChange([...value, recipient.id])
: onChange(value.filter((v) => v !== recipient.id))
}
/>
</FormControl>
</FormItem>
))}
</>
)}
/>
</form>
</Form>
<DialogFooter>
<div className="flex w-full flex-1 flex-nowrap gap-4">
<DialogClose asChild>
<Button
type="button"
className="flex-1 bg-black/5 hover:bg-black/10 dark:bg-muted dark:hover:bg-muted/80"
variant="secondary"
disabled={isSubmitting}
>
<Trans>Cancel</Trans>
</Button>
</DialogClose>
<Button
className="flex-1"
loading={isSubmitting}
type="submit"
form={FORM_ID}
disabled={isSubmitting || selectedRecipients.length === 0}
>
<Trans>Send reminder</Trans>
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
@@ -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> | 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 (
<Dialog open={open} onOpenChange={(value) => !isPending && setOpen(value)}>
<DialogTrigger asChild>{trigger}</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>
<Trans>Are you sure?</Trans>
</DialogTitle>
<DialogDescription>
<Trans>
You are about to cancel <strong>"{title}"</strong>
</Trans>
</DialogDescription>
</DialogHeader>
<Alert variant="warning" className="-mt-1">
<AlertDescription>
<p>
<Trans>Once confirmed, the following will occur:</Trans>
</p>
<ul className="mt-0.5 list-inside list-disc">
<li>
<Trans>The document signing process will be stopped</Trans>
</li>
<li>
<Trans>Recipients will be notified that the document was cancelled</Trans>
</li>
<li>
<Trans>The document will remain in your dashboard marked as Cancelled</Trans>
</li>
</ul>
</AlertDescription>
</Alert>
<div className="flex flex-col gap-2">
<Label htmlFor="cancel-reason">
<Trans>Reason (optional)</Trans>
</Label>
<Textarea
id="cancel-reason"
value={reason}
onChange={(event) => setReason(event.target.value)}
placeholder={t`Add an optional reason for cancelling this document`}
/>
</div>
<DialogFooter>
<DialogClose asChild>
<Button type="button" variant="secondary" disabled={isPending}>
<Trans>Cancel</Trans>
</Button>
</DialogClose>
<Button
type="button"
loading={isPending}
onClick={() => void cancelEnvelope({ envelopeId: id, reason: reason || undefined })}
variant="destructive"
>
<Trans>Cancel document</Trans>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
@@ -166,7 +166,7 @@ export const EnvelopeDeleteDialog = ({
</ul>
</AlertDescription>
))
.with(P.union(DocumentStatus.COMPLETED, DocumentStatus.REJECTED), () => (
.with(P.union(DocumentStatus.COMPLETED, DocumentStatus.REJECTED, DocumentStatus.CANCELLED), () => (
<AlertDescription>
<p>
<Trans>By deleting this document, the following will occur:</Trans>
@@ -3,12 +3,13 @@ import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/org
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
import { AppError } from '@documenso/lib/errors/app-error';
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
import { hasOverlappingFields } from '@documenso/lib/utils/fields-overlap';
import { getRecipientsWithMissingFields } from '@documenso/lib/utils/recipients';
import { zEmail } from '@documenso/lib/utils/zod';
import { trpc, trpc as trpcReact } from '@documenso/trpc/react';
import { DocumentSendEmailMessageHelper } from '@documenso/ui/components/document/document-send-email-message-helper';
import { cn } from '@documenso/ui/lib/utils';
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
@@ -32,7 +33,7 @@ import { zodResolver } from '@hookform/resolvers/zod';
import { Trans, useLingui } from '@lingui/react/macro';
import { DocumentDistributionMethod, DocumentStatus, EnvelopeType } from '@prisma/client';
import { AnimatePresence, motion } from 'framer-motion';
import { InfoIcon } from 'lucide-react';
import { AlertTriangleIcon, InfoIcon } from 'lucide-react';
import { useEffect, useMemo, useState } from 'react';
import { useForm } from 'react-hook-form';
import { useNavigate } from 'react-router';
@@ -138,6 +139,27 @@ export const EnvelopeDistributeDialog = ({
});
}, [recipientsWithIndex, envelope.authOptions]);
/**
* Whether any fields significantly overlap each other. This is surfaced as a
* non-blocking warning since overlapping fields still allow sending, but can
* complicate the signing process or cause fields to behave unexpectedly.
*/
const hasOverlappingEnvelopeFields = useMemo(
() =>
hasOverlappingFields(
envelope.fields.map((field) => ({
id: field.id,
envelopeItemId: field.envelopeItemId,
page: field.page,
positionX: Number(field.positionX),
positionY: Number(field.positionY),
width: Number(field.width),
height: Number(field.height),
})),
),
[envelope.fields],
);
const invalidEnvelopeCode = useMemo(() => {
if (recipientsMissingSignatureFields.length > 0) {
return 'MISSING_SIGNATURES';
@@ -206,6 +228,11 @@ export const EnvelopeDistributeDialog = ({
};
useEffect(() => {
// Default the distribution method tab to the envelope's configured setting.
if (isOpen && envelope.documentMeta) {
setValue('meta.distributionMethod', envelope.documentMeta.distributionMethod);
}
// Resync the whole envelope if the envelope is mid saving.
if (isOpen && (isAutosaving || autosaveError)) {
void handleSync();
@@ -235,6 +262,24 @@ export const EnvelopeDistributeDialog = ({
<Form {...form}>
<form onSubmit={handleSubmit(onFormSubmit)}>
<fieldset disabled={isSubmitting}>
{hasOverlappingEnvelopeFields && (
<Alert variant="warning" className="mb-4 flex flex-row items-start gap-3">
<AlertTriangleIcon className="mt-0.5 h-5 w-5 flex-shrink-0" />
<div className="flex flex-col gap-1">
<AlertTitle>
<Trans>Overlapping fields detected</Trans>
</AlertTitle>
<AlertDescription>
<Trans>
Some fields are placed on top of each other. This may complicate the signing process or cause
fields to not work as expected.
</Trans>
</AlertDescription>
</div>
</Alert>
)}
<Tabs
onValueChange={(value) =>
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
@@ -1,6 +1,7 @@
import { formatDocumentsPath, formatTemplatesPath } from '@documenso/lib/utils/teams';
import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import { Checkbox } from '@documenso/ui/primitives/checkbox';
import {
Dialog,
DialogClose,
@@ -11,10 +12,12 @@ import {
DialogTitle,
DialogTrigger,
} from '@documenso/ui/primitives/dialog';
import { Label } from '@documenso/ui/primitives/label';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { Trans, useLingui } from '@lingui/react/macro';
import { EnvelopeType } from '@prisma/client';
import { useState } from 'react';
import { Controller, useForm } from 'react-hook-form';
import { useNavigate } from 'react-router';
import { useCurrentTeam } from '~/providers/team';
@@ -37,6 +40,15 @@ export const EnvelopeDuplicateDialog = ({ envelopeId, envelopeType, trigger }: E
const isDocument = envelopeType === EnvelopeType.DOCUMENT;
const form = useForm({
defaultValues: {
includeRecipients: true,
includeFields: true,
},
});
const includeRecipients = form.watch('includeRecipients');
const { mutateAsync: duplicateEnvelope, isPending: isDuplicating } = trpc.envelope.duplicate.useMutation({
onSuccess: async ({ id }) => {
toast({
@@ -55,8 +67,14 @@ export const EnvelopeDuplicateDialog = ({ envelopeId, envelopeType, trigger }: E
});
const onDuplicate = async () => {
const { includeRecipients, includeFields } = form.getValues();
try {
await duplicateEnvelope({ envelopeId });
await duplicateEnvelope({
envelopeId,
includeRecipients,
includeFields: includeRecipients && includeFields,
});
} catch {
toast({
title: t`Something went wrong`,
@@ -70,7 +88,20 @@ export const EnvelopeDuplicateDialog = ({ envelopeId, envelopeType, trigger }: E
};
return (
<Dialog open={open} onOpenChange={(value) => !isDuplicating && setOpen(value)}>
<Dialog
open={open}
onOpenChange={(value) => {
if (isDuplicating) {
return;
}
setOpen(value);
if (!value) {
form.reset();
}
}}
>
{trigger && <DialogTrigger asChild>{trigger}</DialogTrigger>}
<DialogContent>
@@ -87,6 +118,49 @@ export const EnvelopeDuplicateDialog = ({ envelopeId, envelopeType, trigger }: E
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<Controller
control={form.control}
name="includeRecipients"
render={({ field }) => (
<div className="flex items-center space-x-2">
<Checkbox
id="envelopeDuplicateIncludeRecipients"
checked={field.value}
onCheckedChange={(checked) => {
field.onChange(checked === true);
if (!checked) {
form.setValue('includeFields', false);
}
}}
/>
<Label htmlFor="envelopeDuplicateIncludeRecipients">
<Trans>Include Recipients</Trans>
</Label>
</div>
)}
/>
<Controller
control={form.control}
name="includeFields"
render={({ field }) => (
<div className="flex items-center space-x-2">
<Checkbox
id="envelopeDuplicateIncludeFields"
checked={field.value}
disabled={!includeRecipients}
onCheckedChange={(checked) => field.onChange(checked === true)}
/>
<Label htmlFor="envelopeDuplicateIncludeFields" className={!includeRecipients ? 'opacity-50' : ''}>
<Trans>Include Fields</Trans>
</Label>
</div>
)}
/>
</div>
<DialogFooter>
<DialogClose asChild>
<Button type="button" variant="secondary" disabled={isDuplicating}>
@@ -25,14 +25,16 @@ import { Trans, useLingui } from '@lingui/react/macro';
import { DocumentStatus, EnvelopeType, SigningStatus } from '@prisma/client';
import { useEffect, useState } from 'react';
import { useForm } from 'react-hook-form';
import { match } from 'ts-pattern';
import * as z from 'zod';
import { getDistributeErrorMessage } from '~/utils/toast-error-messages';
import { StackAvatar } from '../general/stack-avatar';
export type EnvelopeRedistributeDialogProps = {
envelope: Pick<TEnvelope, 'id' | 'userId' | 'teamId' | 'status' | 'type' | 'documentMeta'> & {
envelope: Pick<TEnvelope, 'id' | 'status' | 'type'> & {
recipients: TEnvelopeRecipientLite[];
};
envelopeType?: EnvelopeType;
trigger?: React.ReactNode;
};
@@ -44,7 +46,7 @@ export const ZEnvelopeRedistributeFormSchema = z.object({
export type TEnvelopeRedistributeFormSchema = z.infer<typeof ZEnvelopeRedistributeFormSchema>;
export const EnvelopeRedistributeDialog = ({ envelope, trigger }: EnvelopeRedistributeDialogProps) => {
export const EnvelopeRedistributeDialog = ({ envelope, envelopeType, trigger }: EnvelopeRedistributeDialogProps) => {
const recipients = envelope.recipients;
const { toast } = useToast();
@@ -70,9 +72,23 @@ export const EnvelopeRedistributeDialog = ({ envelope, trigger }: EnvelopeRedist
try {
await redistributeEnvelope({ envelopeId: envelope.id, recipients });
const successMessage = match(envelopeType)
.with(EnvelopeType.DOCUMENT, () => ({
title: t`Document resent`,
description: t`Your document has been resent successfully.`,
}))
.with(EnvelopeType.TEMPLATE, () => ({
title: t`Template resent`,
description: t`Your template has been resent successfully.`,
}))
.otherwise(() => ({
title: t`Envelope resent`,
description: t`Your envelope has been resent successfully.`,
}));
toast({
title: t`Envelope resent`,
description: t`Your envelope has been resent successfully.`,
title: successMessage.title,
description: successMessage.description,
duration: 5000,
});
@@ -0,0 +1,159 @@
import { trpc } from '@documenso/trpc/react';
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} 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 { plural } from '@lingui/core/macro';
import { Plural, Trans, useLingui } from '@lingui/react/macro';
import type * as DialogPrimitive from '@radix-ui/react-dialog';
import { useEffect, useState } from 'react';
export type EnvelopesBulkCancelDialogProps = {
envelopeIds: string[];
open: boolean;
onOpenChange: (open: boolean) => void;
onSuccess?: () => void;
} & Omit<DialogPrimitive.DialogProps, 'children'>;
export const EnvelopesBulkCancelDialog = ({
envelopeIds,
open,
onOpenChange,
onSuccess,
...props
}: EnvelopesBulkCancelDialogProps) => {
const { t } = useLingui();
const { toast } = useToast();
const trpcUtils = trpc.useUtils();
const [reason, setReason] = useState('');
useEffect(() => {
if (open) {
setReason('');
}
}, [open]);
const { mutateAsync: bulkCancelEnvelopes, isPending } = trpc.envelope.bulk.cancel.useMutation({
onSuccess: async (result) => {
await trpcUtils.document.findDocumentsInternal.invalidate();
if (result.failedIds.length > 0) {
toast({
title: t`Documents partially cancelled`,
description: t`${plural(result.cancelledCount, {
one: '# document cancelled.',
other: '# documents cancelled.',
})} ${plural(result.failedIds.length, {
one: '# document could not be cancelled.',
other: '# documents could not be cancelled.',
})}`,
variant: 'destructive',
});
} else {
toast({
title: t`Documents cancelled`,
description: plural(result.cancelledCount, {
one: '# document has been cancelled.',
other: '# documents have been cancelled.',
}),
variant: 'default',
});
}
onSuccess?.();
onOpenChange(false);
},
onError: () => {
toast({
title: t`Error`,
description: t`An error occurred while cancelling the documents.`,
variant: 'destructive',
});
},
});
return (
<Dialog {...props} open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>
<Trans>Cancel Documents</Trans>
</DialogTitle>
<DialogDescription>
<Plural
value={envelopeIds.length}
one="You are about to cancel the selected document."
other="You are about to cancel # documents."
/>
</DialogDescription>
</DialogHeader>
<Alert variant="warning">
<AlertDescription>
<p>
<Trans>Only pending documents you have permission to manage will be cancelled.</Trans>
</p>
<p className="mt-1">
<Trans>Once confirmed, the following will occur:</Trans>
</p>
<ul className="mt-0.5 list-inside list-disc">
<li>
<Trans>The document signing process will be stopped</Trans>
</li>
<li>
<Trans>Recipients will be notified that the document was cancelled</Trans>
</li>
<li>
<Trans>The documents will remain in your dashboard marked as Cancelled</Trans>
</li>
</ul>
</AlertDescription>
</Alert>
<div className="flex flex-col gap-2">
<Label htmlFor="bulk-cancel-reason">
<Trans>Reason (optional)</Trans>
</Label>
<Textarea
id="bulk-cancel-reason"
value={reason}
onChange={(event) => setReason(event.target.value)}
placeholder={t`Add an optional reason for cancelling these documents`}
/>
</div>
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)} disabled={isPending}>
<Trans>Cancel</Trans>
</Button>
<Button
onClick={(e) => {
e.preventDefault();
void bulkCancelEnvelopes({ envelopeIds, reason: reason || undefined });
}}
loading={isPending}
variant="destructive"
>
<Trans>Cancel documents</Trans>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
@@ -28,7 +28,7 @@ export type EnvelopesBulkMoveDialogProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
currentFolderId?: string;
onSuccess?: () => void;
onSuccess?: (folderId: string | null) => Promise<void> | void;
} & Omit<DialogPrimitive.DialogProps, 'children'>;
const ZBulkMoveFormSchema = z.object({
@@ -99,11 +99,12 @@ export const EnvelopesBulkMoveDialog = ({
await trpcUtils.template.findTemplates.invalidate();
}
await onSuccess?.(data.folderId);
toast({
description: t`Selected items have been moved.`,
});
onSuccess?.();
onOpenChange(false);
} catch (err) {
const error = AppError.parseError(err);
@@ -16,6 +16,17 @@ import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { useState } from 'react';
import { match } from 'ts-pattern';
/**
* The reason a team member cannot be removed from the team. When set, the delete
* dialog explains the reason instead of offering a confirm button.
*/
export type TeamMemberDeleteDisableReason =
| 'TEAM_OWNER'
| 'HIGHER_ROLE'
| 'INHERIT_MEMBER_ENABLED'
| 'INHERITED_MEMBER';
export type TeamMemberDeleteDialogProps = {
teamId: number;
@@ -23,7 +34,7 @@ export type TeamMemberDeleteDialogProps = {
memberId: string;
memberName: string;
memberEmail: string;
isInheritMemberEnabled: boolean | null;
disableReason?: TeamMemberDeleteDisableReason | null;
trigger?: React.ReactNode;
};
@@ -34,7 +45,7 @@ export const TeamMemberDeleteDialog = ({
memberId,
memberName,
memberEmail,
isInheritMemberEnabled,
disableReason,
}: TeamMemberDeleteDialogProps) => {
const [open, setOpen] = useState(false);
@@ -86,10 +97,19 @@ export const TeamMemberDeleteDialog = ({
</DialogDescription>
</DialogHeader>
{isInheritMemberEnabled ? (
{disableReason ? (
<Alert variant="neutral">
<AlertDescription>
<Trans>You cannot remove members from this team if the inherit member feature is enabled.</Trans>
{match(disableReason)
.with('TEAM_OWNER', () => <Trans>You cannot remove the organisation owner from the team.</Trans>)
.with('HIGHER_ROLE', () => <Trans>You cannot remove a member with a role higher than your own.</Trans>)
.with('INHERIT_MEMBER_ENABLED', () => (
<Trans>You cannot remove members from this team while the inherit member feature is enabled.</Trans>
))
.with('INHERITED_MEMBER', () => (
<Trans>This member is inherited from a group and cannot be removed from the team directly.</Trans>
))
.exhaustive()}
</AlertDescription>
</Alert>
) : (
@@ -109,11 +129,10 @@ export const TeamMemberDeleteDialog = ({
<Trans>Close</Trans>
</Button>
{!isInheritMemberEnabled && (
{!disableReason && (
<Button
type="submit"
variant="destructive"
disabled={Boolean(isInheritMemberEnabled)}
loading={isDeletingTeamMember}
onClick={async () => deleteTeamMember({ teamId, memberId })}
>
@@ -1,232 +0,0 @@
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { FolderType } from '@documenso/lib/types/folder-type';
import { formatTemplatesPath } from '@documenso/lib/utils/teams';
import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@documenso/ui/primitives/dialog';
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import type * as DialogPrimitive from '@radix-ui/react-dialog';
import { FolderIcon, HomeIcon, Loader2, Search } from 'lucide-react';
import { useEffect, useState } from 'react';
import { useForm } from 'react-hook-form';
import { useNavigate } from 'react-router';
import { z } from 'zod';
import { useCurrentTeam } from '~/providers/team';
export type TemplateMoveToFolderDialogProps = {
templateId: number;
templateTitle: string;
isOpen: boolean;
onOpenChange: (open: boolean) => void;
currentFolderId?: string | null;
} & Omit<DialogPrimitive.DialogProps, 'children'>;
const ZMoveTemplateFormSchema = z.object({
folderId: z.string().nullable().optional(),
});
type TMoveTemplateFormSchema = z.infer<typeof ZMoveTemplateFormSchema>;
export function TemplateMoveToFolderDialog({
templateId,
templateTitle,
isOpen,
onOpenChange,
currentFolderId,
...props
}: TemplateMoveToFolderDialogProps) {
const { _ } = useLingui();
const { toast } = useToast();
const navigate = useNavigate();
const team = useCurrentTeam();
const [searchTerm, setSearchTerm] = useState('');
const form = useForm<TMoveTemplateFormSchema>({
resolver: zodResolver(ZMoveTemplateFormSchema),
defaultValues: {
folderId: currentFolderId ?? null,
},
});
const { data: folders, isLoading: isFoldersLoading } = trpc.folder.findFoldersInternal.useQuery(
{
parentId: currentFolderId ?? null,
type: FolderType.TEMPLATE,
},
{
enabled: isOpen,
},
);
const { mutateAsync: updateTemplate } = trpc.template.updateTemplate.useMutation();
useEffect(() => {
if (!isOpen) {
form.reset();
setSearchTerm('');
} else {
form.reset({ folderId: currentFolderId ?? null });
}
}, [isOpen, currentFolderId, form]);
const onSubmit = async (data: TMoveTemplateFormSchema) => {
try {
await updateTemplate({
templateId,
data: {
folderId: data.folderId ?? null,
},
});
toast({
title: _(msg`Template moved`),
description: _(msg`The template has been moved successfully.`),
variant: 'default',
});
onOpenChange(false);
const templatesPath = formatTemplatesPath(team.url);
if (data.folderId) {
void navigate(`${templatesPath}/f/${data.folderId}`);
} else {
void navigate(templatesPath);
}
} catch (err) {
const error = AppError.parseError(err);
if (error.code === AppErrorCode.NOT_FOUND) {
toast({
title: _(msg`Error`),
description: _(msg`The folder you are trying to move the template to does not exist.`),
variant: 'destructive',
});
return;
}
toast({
title: _(msg`Error`),
description: _(msg`An error occurred while moving the template.`),
variant: 'destructive',
});
}
};
const filteredFolders = folders?.data?.filter((folder) =>
folder.name.toLowerCase().includes(searchTerm.toLowerCase()),
);
return (
<Dialog {...props} open={isOpen} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>
<Trans>Move Template to Folder</Trans>
</DialogTitle>
<DialogDescription>
<Trans>Move &quot;{templateTitle}&quot; to a folder</Trans>
</DialogDescription>
</DialogHeader>
<div className="relative">
<Search className="absolute top-3 left-2 h-4 w-4 text-muted-foreground" />
<Input
placeholder={_(msg`Search folders...`)}
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-8"
/>
</div>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="mt-4 flex flex-col gap-y-4">
<FormField
control={form.control}
name="folderId"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Folder</Trans>
</FormLabel>
<FormControl>
<div className="max-h-96 space-y-2 overflow-y-auto">
{isFoldersLoading ? (
<div className="flex h-10 items-center justify-center">
<Loader2 className="h-4 w-4 animate-spin" />
</div>
) : (
<>
<Button
type="button"
variant={field.value === null ? 'default' : 'outline'}
className="w-full justify-start"
onClick={() => field.onChange(null)}
disabled={currentFolderId === null}
>
<HomeIcon className="mr-2 h-4 w-4" />
<Trans>Home (No Folder)</Trans>
</Button>
{filteredFolders?.map((folder) => (
<Button
key={folder.id}
type="button"
variant={field.value === folder.id ? 'default' : 'outline'}
className="w-full justify-start"
onClick={() => field.onChange(folder.id)}
disabled={currentFolderId === folder.id}
>
<FolderIcon className="mr-2 h-4 w-4" />
{folder.name}
</Button>
))}
{searchTerm && filteredFolders?.length === 0 && (
<div className="px-2 py-2 text-center text-muted-foreground text-sm">
<Trans>No folders found</Trans>
</div>
)}
</>
)}
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)}>
<Trans>Cancel</Trans>
</Button>
<Button type="submit" disabled={isFoldersLoading || form.formState.isSubmitting}>
<Trans>Move</Trans>
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
);
}
@@ -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);
@@ -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 (
<div className="-mx-4 flex max-w-[100vw] flex-col items-center overflow-x-hidden px-4 pt-16 md:-mx-8 md:px-8 lg:pt-16 xl:pt-24">
<AlertTriangleIcon className="h-12 w-12 text-destructive" />
<h2 className="mt-6 max-w-[35ch] text-center font-semibold text-2xl leading-normal md:text-3xl lg:text-4xl">
{code === AppErrorCode.CSC_CREDENTIAL_LIST_EMPTY ? (
<Trans>No signing credentials available</Trans>
) : code === AppErrorCode.CSC_CERT_INVALID ? (
<Trans>Signing certificate is invalid</Trans>
) : code === AppErrorCode.CSC_ALGORITHM_REFUSED ? (
<Trans>Signing algorithm is not supported</Trans>
) : (
<Trans>Unable to start the signing flow</Trans>
)}
</h2>
<p className="mt-2.5 max-w-[60ch] text-center font-medium text-muted-foreground/60 text-sm md:text-base">
{code === AppErrorCode.CSC_CREDENTIAL_LIST_EMPTY ? (
<Trans>
Your signing provider returned no usable credentials for this account. Contact your administrator or signing
provider for assistance.
</Trans>
) : code === AppErrorCode.CSC_CERT_INVALID ? (
<Trans>
Your signing certificate is invalid, expired, or missing a required key. Contact your administrator or
signing provider for assistance.
</Trans>
) : code === AppErrorCode.CSC_ALGORITHM_REFUSED ? (
<Trans>
Your signing provider does not advertise a signing algorithm this document accepts. Contact your
administrator or signing provider for assistance.
</Trans>
) : (
<Trans>Something went wrong while preparing the remote signature. Please try again.</Trans>
)}
</p>
<Button asChild className="mt-8">
<a href={retryUrl}>
<Trans>Try again</Trans>
</a>
</Button>
</div>
);
};
@@ -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<string | null>(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 (
<div className="-mx-4 flex max-w-[100vw] flex-col items-center overflow-x-hidden px-4 pt-16 md:-mx-8 md:px-8 lg:pt-16 xl:pt-24">
{error ? (
<>
<AlertTriangleIcon className="h-12 w-12 text-destructive" />
<h2 className="mt-6 max-w-[35ch] text-center font-semibold text-2xl leading-normal md:text-3xl lg:text-4xl">
<Trans>Signing failed</Trans>
</h2>
<p className="mt-2.5 max-w-[60ch] text-center font-medium text-muted-foreground/60 text-sm md:text-base">
{error === AppErrorCode.CSC_TSP_TIMEOUT ? (
<Trans>The signing provider did not respond in time. Please retry.</Trans>
) : error === AppErrorCode.CSC_SAD_EXPIRED_PRE_SIGN ? (
<Trans>
Your signing authorisation expired before the signature could be applied. Please reauthorise to retry.
</Trans>
) : (
<Trans>Something went wrong while applying your signature. Please retry.</Trans>
)}
</p>
<Button asChild className="mt-8">
<a href={retryUrl}>
<Trans>Reauthorise and retry</Trans>
</a>
</Button>
</>
) : (
<>
<Loader2Icon className="h-12 w-12 animate-spin text-primary" />
<h2 className="mt-6 max-w-[35ch] text-center font-semibold text-2xl leading-normal md:text-3xl lg:text-4xl">
<Trans>Applying your signature</Trans>
</h2>
<p className="mt-2.5 max-w-[60ch] text-center font-medium text-muted-foreground/60 text-sm md:text-base">
<Trans>Please don't close this tab. The signing provider is finalising your signature.</Trans>
</p>
</>
)}
</div>
);
};
@@ -27,7 +27,6 @@ import type { Field } from '@prisma/client';
import { FieldType, RecipientRole } from '@prisma/client';
import { LucideChevronDown, LucideChevronUp } from 'lucide-react';
import { useMemo, useState } from 'react';
import { useNavigate } from 'react-router';
import { match, P } from 'ts-pattern';
import { DocumentSigningAttachmentsPopover } from '~/components/general/document-signing/document-signing-attachments-popover';
@@ -84,7 +83,6 @@ export const DocumentSigningPageViewV1 = ({
? authUser.twoFactorEnabled && authUser.email === recipient.email
: false;
const navigate = useNavigate();
const analytics = useAnalytics();
const [selectedSignerId, setSelectedSignerId] = useState<number | null>(allRecipients?.[0]?.id);
@@ -129,7 +127,7 @@ export const DocumentSigningPageViewV1 = ({
if (documentMeta?.redirectUrl) {
window.location.href = documentMeta.redirectUrl;
} else {
await navigate(`/sign/${recipient.token}/complete`);
window.location.href = `/sign/${recipient.token}/complete`;
}
};
@@ -17,7 +17,7 @@ import { msg } from '@lingui/core/macro';
import { Trans, useLingui } from '@lingui/react/macro';
import { useEffect, useState } from 'react';
import { useForm } from 'react-hook-form';
import { useNavigate, useSearchParams } from 'react-router';
import { useSearchParams } from 'react-router';
import { z } from 'zod';
const ZRejectDocumentFormSchema = z.object({
@@ -41,7 +41,6 @@ export function DocumentSigningRejectDialog({
}: DocumentSigningRejectDialogProps) {
const { t } = useLingui();
const { toast } = useToast();
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const [isOpen, setIsOpen] = useState(false);
@@ -74,7 +73,7 @@ export function DocumentSigningRejectDialog({
if (onRejected) {
await onRejected(reason);
} else {
await navigate(`/sign/${token}/rejected`);
window.location.href = `/sign/${token}/rejected`;
}
} catch (err) {
toast({
@@ -38,7 +38,7 @@ export const DocumentPageViewButton = ({ envelope }: DocumentPageViewButtonProps
})
.with({ isRecipient: true, isPending: true, isSigned: false }, () => (
<Button className="w-full" asChild>
<Link to={`/sign/${recipient?.token}`}>
<a href={`/sign/${recipient?.token}`}>
{match(role)
.with(RecipientRole.SIGNER, () => (
<>
@@ -58,7 +58,7 @@ export const DocumentPageViewButton = ({ envelope }: DocumentPageViewButtonProps
<Trans>View</Trans>
</>
))}
</Link>
</a>
</Button>
))
.with({ isComplete: false }, () => (
@@ -19,6 +19,7 @@ import {
Download,
Edit,
FileOutputIcon,
History,
Loader,
MoreHorizontal,
Pencil,
@@ -29,10 +30,10 @@ import {
import { useState } from 'react';
import { Link, useNavigate } from 'react-router';
import { DocumentResendDialog } from '~/components/dialogs/document-resend-dialog';
import { EnvelopeDeleteDialog } from '~/components/dialogs/envelope-delete-dialog';
import { EnvelopeDownloadDialog } from '~/components/dialogs/envelope-download-dialog';
import { EnvelopeDuplicateDialog } from '~/components/dialogs/envelope-duplicate-dialog';
import { EnvelopeRedistributeDialog } from '~/components/dialogs/envelope-redistribute-dialog';
import { EnvelopeRenameDialog } from '~/components/dialogs/envelope-rename-dialog';
import { EnvelopeSaveAsTemplateDialog } from '~/components/dialogs/envelope-save-as-template-dialog';
import { DocumentRecipientLinkCopyDialog } from '~/components/general/document/document-recipient-link-copy-dialog';
@@ -67,8 +68,6 @@ export const DocumentPageViewDropdown = ({ envelope }: DocumentPageViewDropdownP
const documentsPath = formatDocumentsPath(team.url);
const nonSignedRecipients = envelope.recipients.filter((item) => item.signingStatus !== 'SIGNED');
return (
<DropdownMenu>
<DropdownMenuTrigger data-testid="document-page-view-action-btn">
@@ -172,13 +171,20 @@ export const DocumentPageViewDropdown = ({ envelope }: DocumentPageViewDropdownP
/>
)}
<DocumentResendDialog
document={{
...envelope,
id: mapSecondaryIdToDocumentId(envelope.secondaryId),
}}
recipients={nonSignedRecipients}
/>
{canManageDocument && (
<EnvelopeRedistributeDialog
envelope={envelope}
envelopeType={EnvelopeType.DOCUMENT}
trigger={
<DropdownMenuItem asChild onSelect={(e) => e.preventDefault()}>
<div>
<History className="mr-2 h-4 w-4" />
<Trans>Resend</Trans>
</div>
</DropdownMenuItem>
}
/>
)}
<DocumentShareButton
documentId={mapSecondaryIdToDocumentId(envelope.secondaryId)}
@@ -40,6 +40,12 @@ export const FRIENDLY_STATUS_MAP: Record<ExtendedDocumentStatus, FriendlyStatus>
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`,
@@ -1,3 +1,4 @@
import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value';
import type { TLocalField } from '@documenso/lib/client-only/hooks/use-editor-fields';
import { usePageRenderer } from '@documenso/lib/client-only/hooks/use-page-renderer';
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
@@ -13,6 +14,7 @@ import {
} from '@documenso/lib/universal/field-renderer/field-renderer';
import { renderField } from '@documenso/lib/universal/field-renderer/render-field';
import { getClientSideFieldTranslations } from '@documenso/lib/utils/fields';
import { getOverlappingFieldPairs } from '@documenso/lib/utils/fields-overlap';
import { canRecipientFieldsBeModified } from '@documenso/lib/utils/recipients';
import {
Command,
@@ -62,6 +64,36 @@ export const EnvelopeEditorFieldsPageRenderer = ({ pageData }: { pageData: PageR
[editorFields.localFields, pageNumber, currentEnvelopeItem?.id],
);
/**
* Debounce the fields used for overlap highlighting so we don't recompute on every
* small drag/resize tick. Overlaps only occur within the same page and envelope
* item, so computing from this page's fields alone is sufficient.
*/
const debouncedPageFields = useDebouncedValue(localPageFields, 300);
const overlappingFieldFormIds = useMemo(() => {
const formIds = new Set<string>();
const pairs = getOverlappingFieldPairs(
debouncedPageFields.map((field) => ({
id: field.formId,
envelopeItemId: field.envelopeItemId,
page: field.page,
positionX: field.positionX,
positionY: field.positionY,
width: field.width,
height: field.height,
})),
);
for (const pair of pairs) {
formIds.add(pair.fieldA.id);
formIds.add(pair.fieldB.id);
}
return formIds;
}, [debouncedPageFields]);
const handleResizeOrMove = (event: KonvaEventObject<Event>) => {
const isDragEvent = event.type === 'dragend';
@@ -113,6 +145,62 @@ export const EnvelopeEditorFieldsPageRenderer = ({ pageData }: { pageData: PageR
pageLayer.current?.batchDraw();
};
/**
* Draws (or removes) a dashed warning outline over a field that significantly
* overlaps another field. The highlight is a child of the field group so it moves
* and resizes with the field, and sits on top of the field's own rect (which is
* re-styled on every render and would otherwise clobber a direct stroke change).
*/
const syncOverlapHighlight = (fieldGroup: Konva.Group, isOverlapping: boolean) => {
const existingHighlight = fieldGroup.findOne('.field-overlap-highlight');
// Skip while a field is actively being dragged/resized. The highlight is driven
// by debounced field data, so it would lag behind and distort during the gesture.
// It is repainted once the gesture settles (the effect re-runs on isFieldChanging).
if (isFieldChanging) {
existingHighlight?.destroy();
return;
}
if (!isOverlapping) {
existingHighlight?.destroy();
return;
}
const fieldRect = fieldGroup.findOne('.field-rect');
if (!fieldRect) {
return;
}
const highlightAttrs = {
x: 0,
y: 0,
width: fieldRect.width(),
height: fieldRect.height(),
stroke: '#f59e0b',
strokeWidth: 2,
dash: [6, 4],
cornerRadius: 2,
strokeScaleEnabled: false,
listening: false,
} satisfies Partial<Konva.RectConfig>;
if (existingHighlight instanceof Konva.Rect) {
existingHighlight.setAttrs(highlightAttrs);
existingHighlight.moveToTop();
return;
}
const highlight = new Konva.Rect({
name: 'field-overlap-highlight',
...highlightAttrs,
});
fieldGroup.add(highlight);
highlight.moveToTop();
};
const unsafeRenderFieldOnLayer = (field: TLocalField) => {
if (!pageLayer.current) {
return;
@@ -139,6 +227,8 @@ export const EnvelopeEditorFieldsPageRenderer = ({ pageData }: { pageData: PageR
mode: 'edit',
});
syncOverlapHighlight(fieldGroup, overlappingFieldFormIds.has(field.formId));
if (!isFieldEditable) {
return;
}
@@ -435,7 +525,7 @@ export const EnvelopeEditorFieldsPageRenderer = ({ pageData }: { pageData: PageR
interactiveTransformer.current?.forceUpdate();
pageLayer.current.batchDraw();
}, [localPageFields, selectedKonvaFieldGroups]);
}, [localPageFields, selectedKonvaFieldGroups, overlappingFieldFormIds, isFieldChanging]);
const setSelectedFields = (nodes: Konva.Node[]) => {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
@@ -1,3 +1,4 @@
import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value';
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-provider';
import { PDF_VIEWER_ERROR_MESSAGES } from '@documenso/lib/constants/pdf-viewer-i18n';
@@ -17,6 +18,7 @@ import {
type TTextFieldMeta,
} from '@documenso/lib/types/field-meta';
import { getEnvelopeItemPermissions } from '@documenso/lib/utils/envelope';
import { getOverlappingFieldPairs } from '@documenso/lib/utils/fields-overlap';
import { canRecipientFieldsBeModified } from '@documenso/lib/utils/recipients';
import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animate-generic-fade-in-out';
import { cn } from '@documenso/ui/lib/utils';
@@ -28,7 +30,7 @@ import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { DocumentStatus, FieldType, RecipientRole } from '@prisma/client';
import { FileTextIcon, PencilIcon, SparklesIcon } from 'lucide-react';
import { AlertTriangleIcon, FileTextIcon, PencilIcon, SparklesIcon } from 'lucide-react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { useRevalidator, useSearchParams } from 'react-router';
import { isDeepEqual } from 'remeda';
@@ -78,7 +80,7 @@ export const EnvelopeEditorFieldsPage = () => {
const { envelope, editorFields, navigateToStep, editorConfig } = useCurrentEnvelopeEditor();
const { currentEnvelopeItem } = useCurrentEnvelopeRender();
const { currentEnvelopeItem, setCurrentEnvelopeItem } = useCurrentEnvelopeRender();
const { _ } = useLingui();
@@ -93,6 +95,53 @@ export const EnvelopeEditorFieldsPage = () => {
const selectedField = useMemo(() => structuredClone(editorFields.selectedField), [editorFields.selectedField]);
/**
* Debounce the fields used for overlap detection so we don't recompute on every
* small drag/resize movement, which is expensive on large field counts and can
* bog down lower-end devices.
*/
const debouncedLocalFields = useDebouncedValue(editorFields.localFields, 300);
/**
* Fields that significantly overlap each other. Overlapping fields render poorly in
* the editor and can behave unexpectedly during signing, so we warn the author here.
*/
const overlappingFieldPairs = useMemo(
() =>
getOverlappingFieldPairs(
debouncedLocalFields.map((field) => ({
id: field.formId,
envelopeItemId: field.envelopeItemId,
page: field.page,
positionX: field.positionX,
positionY: field.positionY,
width: field.width,
height: field.height,
})),
),
[debouncedLocalFields],
);
const handleReviewOverlappingField = () => {
const firstPair = overlappingFieldPairs[0];
if (!firstPair) {
return;
}
const targetField = editorFields.localFields.find((field) => field.formId === firstPair.fieldA.id);
if (!targetField) {
return;
}
if (targetField.envelopeItemId !== currentEnvelopeItem?.id) {
setCurrentEnvelopeItem(targetField.envelopeItemId);
}
editorFields.setSelectedField(targetField.formId);
};
const updateSelectedFieldMeta = (fieldMeta: TFieldMetaSchema) => {
if (!selectedField) {
return;
@@ -211,6 +260,29 @@ export const EnvelopeEditorFieldsPage = () => {
</Alert>
)}
{overlappingFieldPairs.length > 0 && (
<Alert
variant="warning"
className="mt-20 mb-4 flex w-full max-w-[800px] flex-row items-center justify-between space-y-0 rounded-sm"
>
<div className="flex flex-row items-start gap-3">
<AlertTriangleIcon className="mt-0.5 h-5 w-5 flex-shrink-0" />
<div className="flex flex-col gap-1">
<AlertTitle>
<Trans>Overlapping fields detected</Trans>
</AlertTitle>
<AlertDescription>
<Trans>
Some fields are placed on top of each other. This may complicate the signing process or cause
fields to not work as expected.
</Trans>
</AlertDescription>
</div>
</div>
</Alert>
)}
{currentEnvelopeItem !== null ? (
<EnvelopePdfViewer
customPageRenderer={EnvelopeEditorFieldsPageRenderer}
@@ -148,6 +148,11 @@ export default function EnvelopeEditorHeader() {
<Trans>Rejected</Trans>
</Badge>
))
.with(DocumentStatus.CANCELLED, () => (
<Badge variant="destructive" className="shrink-0">
<Trans>Cancelled</Trans>
</Badge>
))
.exhaustive()}
{autosaveError && (
@@ -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);
@@ -66,7 +66,7 @@ export const DocumentsTableActionButton = ({ row }: DocumentsTableActionButtonPr
))
.with({ isRecipient: true, isPending: true, isSigned: false }, () => (
<Button className="w-32" asChild>
<Link to={`/sign/${recipient?.token}`}>
<a href={`/sign/${recipient?.token}`}>
{match(role)
.with(RecipientRole.SIGNER, () => (
<>
@@ -86,7 +86,7 @@ export const DocumentsTableActionButton = ({ row }: DocumentsTableActionButtonPr
<Trans>View</Trans>
</>
))}
</Link>
</a>
</Button>
))
.with({ isPending: true, isSigned: true }, () => (
@@ -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 (
<DropdownMenu>
<DropdownMenuTrigger data-testid="document-table-action-btn">
@@ -105,7 +112,7 @@ export const DocumentsTableActionDropdown = ({ row, onMoveDocument }: DocumentsT
recipient?.role !== RecipientRole.CC &&
recipient?.role !== RecipientRole.ASSISTANT && (
<DropdownMenuItem disabled={!recipient || isComplete} asChild>
<Link to={`/sign/${recipient?.token}`}>
<a href={`/sign/${recipient?.token}`}>
{recipient?.role === RecipientRole.VIEWER && (
<>
<EyeIcon className="mr-2 h-4 w-4" />
@@ -126,7 +133,7 @@ export const DocumentsTableActionDropdown = ({ row, onMoveDocument }: DocumentsT
<Trans>Approve</Trans>
</>
)}
</Link>
</a>
</DropdownMenuItem>
)}
@@ -184,11 +191,23 @@ export const DocumentsTableActionDropdown = ({ row, onMoveDocument }: DocumentsT
</DropdownMenuItem>
)}
{/* No point displaying this if there's no functionality. */}
{/* <DropdownMenuItem disabled>
<XCircle className="mr-2 h-4 w-4" />
Void
</DropdownMenuItem> */}
{canCancelDocument && isPending && (
<EnvelopeCancelDialog
id={row.envelopeId}
title={row.title}
onCancel={async () => {
await trpcUtils.document.findDocumentsInternal.invalidate();
}}
trigger={
<DropdownMenuItem asChild onSelect={(e) => e.preventDefault()}>
<div>
<XCircle className="mr-2 h-4 w-4" />
<Trans>Cancel</Trans>
</div>
</DropdownMenuItem>
}
/>
)}
<EnvelopeDeleteDialog
id={row.envelopeId}
@@ -224,7 +243,25 @@ export const DocumentsTableActionDropdown = ({ row, onMoveDocument }: DocumentsT
/>
)}
<DocumentResendDialog document={row} recipients={nonSignedRecipients} />
{canManageDocument && (
<EnvelopeRedistributeDialog
envelope={{
id: row.envelopeId,
status: row.status,
type: EnvelopeType.DOCUMENT,
recipients: row.recipients,
}}
envelopeType={EnvelopeType.DOCUMENT}
trigger={
<DropdownMenuItem asChild onSelect={(e) => e.preventDefault()}>
<div>
<History className="mr-2 h-4 w-4" />
<Trans>Resend</Trans>
</div>
</DropdownMenuItem>
}
/>
)}
<DocumentShareButton
documentId={row.id}
@@ -1,7 +1,7 @@
import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Bird, CheckCircle2 } from 'lucide-react';
import { Bird, CheckCircle2, XCircle } from 'lucide-react';
import { match } from 'ts-pattern';
export type DocumentsTableEmptyStateProps = { status: ExtendedDocumentStatus };
@@ -24,6 +24,11 @@ export const DocumentsTableEmptyState = ({ status }: DocumentsTableEmptyStatePro
message: msg`There are no active drafts at the current moment. You can upload a document to start drafting.`,
icon: CheckCircle2,
}))
.with(ExtendedDocumentStatus.CANCELLED, () => ({
title: msg`Nothing cancelled`,
message: msg`There are no cancelled documents. Documents you cancel will remain here as a record that they were distributed.`,
icon: XCircle,
}))
.with(ExtendedDocumentStatus.ALL, () => ({
title: msg`We're all empty`,
message: msg`You have not yet created or received any documents. To create a document please upload one.`,
@@ -29,7 +29,7 @@ export type DocumentsTableProps = {
data?: TFindDocumentsResponse;
isLoading?: boolean;
isLoadingError?: boolean;
onMoveDocument?: (documentId: number) => void;
onMoveDocument?: (envelopeId: string) => void;
enableSelection?: boolean;
rowSelection?: RowSelectionState;
onRowSelectionChange?: (selection: RowSelectionState) => void;
@@ -117,7 +117,7 @@ export const DocumentsTable = ({
<DocumentsTableActionButton row={row.original} />
<DocumentsTableActionDropdown
row={row.original}
onMoveDocument={onMoveDocument ? () => onMoveDocument(row.original.id) : undefined}
onMoveDocument={onMoveDocument ? () => onMoveDocument(row.original.envelopeId) : undefined}
/>
</div>
),
@@ -1,11 +1,12 @@
import { Button } from '@documenso/ui/primitives/button';
import { Trans, useLingui } from '@lingui/react/macro';
import { FolderInputIcon, Trash2Icon, XIcon } from 'lucide-react';
import { FolderInputIcon, Trash2Icon, XCircleIcon, XIcon } from 'lucide-react';
export type EnvelopesTableBulkActionBarProps = {
selectedCount: number;
onMoveClick: () => void;
onDeleteClick: () => void;
onCancelClick?: () => void;
onClearSelection: () => void;
};
@@ -13,6 +14,7 @@ export const EnvelopesTableBulkActionBar = ({
selectedCount,
onMoveClick,
onDeleteClick,
onCancelClick,
onClearSelection,
}: EnvelopesTableBulkActionBarProps) => {
const { t } = useLingui();
@@ -34,6 +36,13 @@ export const EnvelopesTableBulkActionBar = ({
<Trans>Move to Folder</Trans>
</Button>
{onCancelClick && (
<Button type="button" variant="outline" size="sm" onClick={onCancelClick}>
<XCircleIcon className="mr-2 h-4 w-4" />
<Trans>Cancel</Trans>
</Button>
)}
<Button type="button" variant="destructive" size="sm" onClick={onDeleteClick}>
<Trash2Icon className="mr-2 h-4 w-4" />
<Trans>Delete</Trans>
@@ -17,7 +17,7 @@ import { DocumentStatus as DocumentStatusEnum, RecipientRole, SigningStatus } fr
import { CheckCircleIcon, DownloadIcon, EyeIcon, Loader, PencilIcon } from 'lucide-react';
import { DateTime } from 'luxon';
import { useMemo, useTransition } from 'react';
import { Link, useSearchParams } from 'react-router';
import { useSearchParams } from 'react-router';
import { match } from 'ts-pattern';
import { DocumentStatus } from '~/components/general/document/document-status';
@@ -200,7 +200,7 @@ export const InboxTableActionButton = ({ row }: InboxTableActionButtonProps) =>
})
.with({ isPending: true, isSigned: false }, () => (
<Button className="w-32" asChild>
<Link to={`/sign/${recipient?.token}`}>
<a href={`/sign/${recipient?.token}`}>
{match(role)
.with(RecipientRole.SIGNER, () => (
<>
@@ -220,7 +220,7 @@ export const InboxTableActionButton = ({ row }: InboxTableActionButtonProps) =>
<Trans>View</Trans>
</>
))}
</Link>
</a>
</Button>
))
.with({ isPending: true, isSigned: true }, () => (
@@ -29,7 +29,7 @@ import { useSearchParams } from 'react-router';
import { useCurrentTeam } from '~/providers/team';
import { TeamMemberDeleteDialog } from '../dialogs/team-member-delete-dialog';
import { TeamMemberDeleteDialog, type TeamMemberDeleteDisableReason } from '../dialogs/team-member-delete-dialog';
import { TeamMemberUpdateDialog } from '../dialogs/team-member-update-dialog';
import { TeamInheritMemberAlert } from '../general/teams/team-inherit-member-alert';
@@ -86,6 +86,39 @@ export const TeamMembersTable = () => {
);
const columns = useMemo(() => {
// A member is a direct team member when they belong to one of the team's
// INTERNAL_TEAM groups. Otherwise they are inherited from an organisation or
// custom group and cannot be managed directly from this team.
const isMemberPartOfInternalTeamGroup = (memberId: string) =>
groups.some(
(group) =>
group.organisationGroupType === OrganisationGroupType.INTERNAL_TEAM &&
group.members.some((member) => member.id === memberId),
);
// Determine why a member can't be removed from the team (if at all). The delete
// dialog uses this to explain the reason instead of attempting a removal that
// would fail.
const getDeleteDisableReason = (member: (typeof results)['data'][number]): TeamMemberDeleteDisableReason | null => {
if (organisation.ownerUserId === member.userId) {
return 'TEAM_OWNER';
}
if (!isTeamRoleWithinUserHierarchy(team.currentTeamRole, member.teamRole)) {
return 'HIGHER_ROLE';
}
if (memberAccessTeamGroup !== undefined) {
return 'INHERIT_MEMBER_ENABLED';
}
if (!isMemberPartOfInternalTeamGroup(member.id)) {
return 'INHERITED_MEMBER';
}
return null;
};
return [
{
header: _(msg`Team Member`),
@@ -111,15 +144,7 @@ export const TeamMembersTable = () => {
},
{
header: _(msg`Source`),
cell: ({ row }) => {
const internalTeamGroupFound = groups.find(
(group) =>
group.organisationGroupType === OrganisationGroupType.INTERNAL_TEAM &&
group.members.some((member) => member.id === row.original.id),
);
return internalTeamGroupFound ? _(msg`Member`) : _(msg`Group`);
},
cell: ({ row }) => (isMemberPartOfInternalTeamGroup(row.original.id) ? _(msg`Member`) : _(msg`Group`)),
},
{
header: _(msg`Actions`),
@@ -161,16 +186,9 @@ export const TeamMembersTable = () => {
memberId={row.original.id}
memberName={row.original.name ?? ''}
memberEmail={row.original.email}
isInheritMemberEnabled={memberAccessTeamGroup !== undefined}
disableReason={getDeleteDisableReason(row.original)}
trigger={
<DropdownMenuItem
onSelect={(e) => e.preventDefault()}
disabled={
organisation.ownerUserId === row.original.userId ||
!isTeamRoleWithinUserHierarchy(team.currentTeamRole, row.original.teamRole)
}
title={_(msg`Remove team member`)}
>
<DropdownMenuItem onSelect={(e) => e.preventDefault()} title={_(msg`Remove team member`)}>
<Trash2Icon className="mr-2 h-4 w-4" />
<Trans>Remove</Trans>
</DropdownMenuItem>
@@ -11,15 +11,15 @@ import { Trans } from '@lingui/react/macro';
import { DocumentStatus, EnvelopeType, type TemplateDirectLink } from '@prisma/client';
import { Copy, Download, Edit, FolderIcon, MoreHorizontal, Pencil, Share2Icon, Trash2, Upload } from 'lucide-react';
import { useState } from 'react';
import { Link } from 'react-router';
import { Link, useNavigate } from 'react-router';
import { EnvelopeDeleteDialog } from '../dialogs/envelope-delete-dialog';
import { EnvelopeDownloadDialog } from '../dialogs/envelope-download-dialog';
import { EnvelopeDuplicateDialog } from '../dialogs/envelope-duplicate-dialog';
import { EnvelopeRenameDialog } from '../dialogs/envelope-rename-dialog';
import { EnvelopesBulkMoveDialog } from '../dialogs/envelopes-bulk-move-dialog';
import { TemplateBulkSendDialog } from '../dialogs/template-bulk-send-dialog';
import { TemplateDirectLinkDialog } from '../dialogs/template-direct-link-dialog';
import { TemplateMoveToFolderDialog } from '../dialogs/template-move-to-folder-dialog';
export type TemplatesTableActionDropdownProps = {
row: {
@@ -44,6 +44,7 @@ export const TemplatesTableActionDropdown = ({
onDelete,
}: TemplatesTableActionDropdownProps) => {
const trpcUtils = trpcReact.useUtils();
const navigate = useNavigate();
const [isRenameDialogOpen, setRenameDialogOpen] = useState(false);
const [isMoveToFolderDialogOpen, setMoveToFolderDialogOpen] = useState(false);
@@ -153,12 +154,13 @@ export const TemplatesTableActionDropdown = ({
)}
</DropdownMenuContent>
<TemplateMoveToFolderDialog
templateId={row.id}
templateTitle={row.title}
isOpen={isMoveToFolderDialogOpen}
<EnvelopesBulkMoveDialog
envelopeIds={[row.envelopeId]}
envelopeType={EnvelopeType.TEMPLATE}
open={isMoveToFolderDialogOpen}
onOpenChange={setMoveToFolderDialogOpen}
currentFolderId={row.folderId}
currentFolderId={row.folderId ?? undefined}
onSuccess={(folderId) => navigate(folderId ? `${templateRootPath}/f/${folderId}` : templateRootPath)}
/>
<EnvelopeRenameDialog
@@ -224,6 +224,7 @@ export default function DocumentPage({ params }: Route.ComponentProps) {
{match(envelope.status)
.with(DocumentStatus.COMPLETED, () => <Trans>This document has been signed by all recipients</Trans>)
.with(DocumentStatus.REJECTED, () => <Trans>This document has been rejected by a recipient</Trans>)
.with(DocumentStatus.CANCELLED, () => <Trans>This document has been cancelled</Trans>)
.with(DocumentStatus.DRAFT, () => (
<Trans>This document is currently a draft and has not been sent</Trans>
))
@@ -16,10 +16,10 @@ import { msg } from '@lingui/core/macro';
import { Trans } from '@lingui/react/macro';
import { EnvelopeType, FolderType, OrganisationType } from '@prisma/client';
import { useEffect, useMemo, useState } from 'react';
import { Link, useParams, useSearchParams } from 'react-router';
import { Link, useNavigate, useParams, useSearchParams } from 'react-router';
import { z } from 'zod';
import { DocumentMoveToFolderDialog } from '~/components/dialogs/document-move-to-folder-dialog';
import { EnvelopesBulkCancelDialog } from '~/components/dialogs/envelopes-bulk-cancel-dialog';
import { EnvelopesBulkDeleteDialog } from '~/components/dialogs/envelopes-bulk-delete-dialog';
import { EnvelopesBulkMoveDialog } from '~/components/dialogs/envelopes-bulk-move-dialog';
import { DocumentSearch } from '~/components/general/document/document-search';
@@ -54,13 +54,17 @@ export default function DocumentsPage() {
const { folderId } = useParams();
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const documentsPath = formatDocumentsPath(team.url);
const [isMovingDocument, setIsMovingDocument] = useState(false);
const [documentToMove, setDocumentToMove] = useState<number | null>(null);
const [documentToMove, setDocumentToMove] = useState<string | null>(null);
const [rowSelection, setRowSelection] = useSessionStorage<RowSelectionState>('documents-bulk-selection', {});
const [isBulkMoveDialogOpen, setIsBulkMoveDialogOpen] = useState(false);
const [isBulkDeleteDialogOpen, setIsBulkDeleteDialogOpen] = useState(false);
const [isBulkCancelDialogOpen, setIsBulkCancelDialogOpen] = useState(false);
const selectedEnvelopeIds = useMemo(() => {
return Object.keys(rowSelection).filter((id) => rowSelection[id]);
@@ -71,6 +75,7 @@ export default function DocumentsPage() {
[ExtendedDocumentStatus.PENDING]: 0,
[ExtendedDocumentStatus.COMPLETED]: 0,
[ExtendedDocumentStatus.REJECTED]: 0,
[ExtendedDocumentStatus.CANCELLED]: 0,
[ExtendedDocumentStatus.INBOX]: 0,
[ExtendedDocumentStatus.ALL]: 0,
});
@@ -150,6 +155,7 @@ export default function DocumentsPage() {
ExtendedDocumentStatus.INBOX,
ExtendedDocumentStatus.PENDING,
ExtendedDocumentStatus.COMPLETED,
ExtendedDocumentStatus.CANCELLED,
ExtendedDocumentStatus.DRAFT,
ExtendedDocumentStatus.ALL,
]
@@ -196,8 +202,8 @@ export default function DocumentsPage() {
data={data}
isLoading={isLoading}
isLoadingError={isLoadingError}
onMoveDocument={(documentId) => {
setDocumentToMove(documentId);
onMoveDocument={(envelopeId) => {
setDocumentToMove(envelopeId);
setIsMovingDocument(true);
}}
enableSelection
@@ -209,8 +215,9 @@ export default function DocumentsPage() {
</div>
{documentToMove && (
<DocumentMoveToFolderDialog
documentId={documentToMove}
<EnvelopesBulkMoveDialog
envelopeIds={[documentToMove]}
envelopeType={EnvelopeType.DOCUMENT}
open={isMovingDocument}
currentFolderId={folderId}
onOpenChange={(open) => {
@@ -220,6 +227,9 @@ export default function DocumentsPage() {
setDocumentToMove(null);
}
}}
onSuccess={(destinationFolderId) =>
navigate(destinationFolderId ? `${documentsPath}/f/${destinationFolderId}` : documentsPath)
}
/>
)}
@@ -227,6 +237,7 @@ export default function DocumentsPage() {
selectedCount={selectedEnvelopeIds.length}
onMoveClick={() => setIsBulkMoveDialogOpen(true)}
onDeleteClick={() => setIsBulkDeleteDialogOpen(true)}
onCancelClick={() => setIsBulkCancelDialogOpen(true)}
onClearSelection={() => setRowSelection({})}
/>
@@ -246,6 +257,13 @@ export default function DocumentsPage() {
onOpenChange={setIsBulkDeleteDialogOpen}
onSuccess={() => setRowSelection({})}
/>
<EnvelopesBulkCancelDialog
envelopeIds={selectedEnvelopeIds}
open={isBulkCancelDialogOpen}
onOpenChange={setIsBulkCancelDialogOpen}
onSuccess={() => setRowSelection({})}
/>
</div>
</EnvelopeDropZoneWrapper>
);
@@ -1,7 +1,14 @@
import signingCelebration from '@documenso/assets/images/signing-celebration.png';
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
import {
buildClearCscBlockingErrorCookieHeader,
readCscBlockingErrorFromRequest,
} from '@documenso/ee/server-only/signing/csc/cookies/blocking-error-cookie';
import { readCscSadSessionFromRequest } from '@documenso/ee/server-only/signing/csc/cookies/sad-session-cookie';
import { readCscServiceSessionFromRequest } from '@documenso/ee/server-only/signing/csc/cookies/service-session-cookie';
import { EnvelopeRenderProvider } from '@documenso/lib/client-only/providers/envelope-render-provider';
import { useOptionalSession } from '@documenso/lib/client-only/providers/session';
import { IS_INSTANCE_CSC_MODE } from '@documenso/lib/constants/app';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { loadRecipientBrandingByTeamId } from '@documenso/lib/server-only/branding/load-recipient-branding';
import { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document/get-document-by-token';
@@ -18,6 +25,7 @@ import { getRecipientsForAssistant } from '@documenso/lib/server-only/recipient/
import { getTeamSettings } from '@documenso/lib/server-only/team/get-team-settings';
import { getUserByEmail } from '@documenso/lib/server-only/user/get-user-by-email';
import { DocumentAccessAuth } from '@documenso/lib/types/document-auth';
import { isTspEnvelope } from '@documenso/lib/types/signature-level';
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
import { isRecipientExpired } from '@documenso/lib/utils/recipients';
import { prisma } from '@documenso/prisma';
@@ -30,6 +38,8 @@ import { getOptionalLoaderContext } from 'server/utils/get-loader-session';
import { match } from 'ts-pattern';
import { Header as AuthenticatedHeader } from '~/components/general/app-header';
import { CscRecipientBlockedPage } from '~/components/general/document-signing/csc-recipient-blocked-page';
import { CscRecipientSigningInProgressPage } from '~/components/general/document-signing/csc-recipient-signing-in-progress-page';
import { DocumentSigningAuthPageView } from '~/components/general/document-signing/document-signing-auth-page';
import { DocumentSigningAuthProvider } from '~/components/general/document-signing/document-signing-auth-provider';
import { DocumentSigningPageViewV1 } from '~/components/general/document-signing/document-signing-page-view-v1';
@@ -257,6 +267,58 @@ const handleV2Loader = async ({ params, request }: Route.LoaderArgs) => {
recipientAccessAuth: derivedRecipientAccessAuth,
}).catch(() => null);
// CSC / TSP routing. TSP envelopes have three terminal recipient-page
// states beyond the normal signing UI:
// 1. `blocked` — service-scope OAuth returned a hard error (set by the
// callback as a one-shot `csc_blocking_error` cookie).
// 2. `signing-in-progress` — credential-scope OAuth completed, SAD is
// attached server-side, page auto-fires the sync sign mutation.
// 3. pre-auth — no service token yet, kick the recipient into
// service-scope OAuth.
// The fourth state (service session valid, no SAD, no blocking error) falls
// through to the normal signing UI.
if (IS_INSTANCE_CSC_MODE() && isTspEnvelope(envelope)) {
const blockingError = await readCscBlockingErrorFromRequest(request);
if (blockingError && blockingError.recipientToken === token) {
return {
isDocumentAccessValid: true,
envelopeForSigning,
csc: { state: 'blocked', code: blockingError.code } as const,
responseHeaders: { 'Set-Cookie': buildClearCscBlockingErrorCookieHeader() },
} as const;
}
const sadSessionId = await readCscSadSessionFromRequest(request);
if (sadSessionId) {
const cscSession = await prisma.cscSession.findUnique({
where: { id: sadSessionId },
});
const isSadSessionValid =
cscSession !== null &&
cscSession.recipientId === recipient.id &&
cscSession.encryptedSad !== null &&
cscSession.sadExpiresAt !== null &&
cscSession.sadExpiresAt > new Date();
if (isSadSessionValid) {
return {
isDocumentAccessValid: true,
envelopeForSigning,
csc: { state: 'signing-in-progress', sessionId: sadSessionId } as const,
} as const;
}
}
const serviceSessionToken = await readCscServiceSessionFromRequest(request);
if (serviceSessionToken !== token) {
throw redirect(`/api/csc/oauth/authorize?scope=service&token=${encodeURIComponent(token)}`);
}
}
return {
isDocumentAccessValid: true,
envelopeForSigning,
@@ -296,11 +358,22 @@ export async function loader(loaderArgs: Route.LoaderArgs) {
if (foundRecipient.envelope.internalVersion === 2) {
const payloadV2 = await handleV2Loader(loaderArgs);
return superLoaderJson({
version: 2,
payload: payloadV2,
branding,
} as const);
// V2 payload may carry a one-shot `Set-Cookie` header (used to clear the
// CSC blocking-error cookie after the loader reads it). Forward it via
// the `superLoaderJson` response init so the browser actually applies the
// header. The field stays on the payload — it's just a `Max-Age=0` clear
// directive, not sensitive — and isn't read by any consumer.
const responseHeaders =
'responseHeaders' in payloadV2 && payloadV2.responseHeaders ? payloadV2.responseHeaders : undefined;
return superLoaderJson(
{
version: 2,
payload: payloadV2,
branding,
} as const,
responseHeaders ? { headers: responseHeaders } : undefined,
);
}
const payloadV1 = await handleV1Loader(loaderArgs);
@@ -430,6 +503,19 @@ const SigningPageV2 = ({ data }: { data: Awaited<ReturnType<typeof handleV2Loade
return <DocumentSigningAuthPageView email={data.recipientEmail} emailHasAccount={!!data.recipientHasAccount} />;
}
if ('csc' in data && data.csc?.state === 'blocked') {
return <CscRecipientBlockedPage code={data.csc.code} recipientToken={data.envelopeForSigning.recipient.token} />;
}
if ('csc' in data && data.csc?.state === 'signing-in-progress') {
return (
<CscRecipientSigningInProgressPage
sessionId={data.csc.sessionId}
recipientToken={data.envelopeForSigning.recipient.token}
/>
);
}
const { envelope, recipientSignature, recipient } = data.envelopeForSigning;
if (envelope.deletedAt || envelope.status === DocumentStatus.REJECTED) {
@@ -9,7 +9,7 @@ import { Stepper } from '@documenso/ui/primitives/stepper';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { useLingui } from '@lingui/react';
import { useLayoutEffect, useState } from 'react';
import { useNavigate } from 'react-router';
import { useNavigate, useSearchParams } from 'react-router';
import { ConfigureDocumentProvider } from '~/components/embed/authoring/configure-document-context';
import { ConfigureDocumentView } from '~/components/embed/authoring/configure-document-view';
@@ -22,6 +22,9 @@ export default function EmbeddingAuthoringDocumentCreatePage() {
const { toast } = useToast();
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const presignToken = searchParams.get('token') ?? undefined;
const [configuration, setConfiguration] = useState<TConfigureEmbedFormSchema | null>(null);
const [fields, setFields] = useState<TConfigureFieldsFormSchema | null>(null);
@@ -57,11 +60,14 @@ export default function EmbeddingAuthoringDocumentCreatePage() {
const fields = data.fields;
const documentData = await putPdfFile({
arrayBuffer: async () => Promise.resolve(configuration.documentData!.data.buffer),
name: configuration.documentData.name,
type: configuration.documentData.type,
});
const documentData = await putPdfFile(
{
arrayBuffer: async () => Promise.resolve(configuration.documentData!.data.buffer),
name: configuration.documentData.name,
type: configuration.documentData.type,
},
{ presignToken },
);
// Use the externalId from the URL fragment if available
const documentExternalId = externalId || configuration.meta.externalId;
@@ -8,7 +8,7 @@ import { Stepper } from '@documenso/ui/primitives/stepper';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { useLingui } from '@lingui/react';
import { useLayoutEffect, useState } from 'react';
import { useNavigate } from 'react-router';
import { useNavigate, useSearchParams } from 'react-router';
import { ConfigureDocumentProvider } from '~/components/embed/authoring/configure-document-context';
import { ConfigureDocumentView } from '~/components/embed/authoring/configure-document-view';
@@ -20,6 +20,9 @@ export default function EmbeddingAuthoringTemplateCreatePage() {
const { _ } = useLingui();
const { toast } = useToast();
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const presignToken = searchParams.get('token') ?? undefined;
const [configuration, setConfiguration] = useState<TConfigureEmbedFormSchema | null>(null);
const [fields, setFields] = useState<TConfigureFieldsFormSchema | null>(null);
@@ -55,11 +58,14 @@ export default function EmbeddingAuthoringTemplateCreatePage() {
const fields = data.fields;
const documentData = await putPdfFile({
arrayBuffer: async () => Promise.resolve(configuration.documentData!.data.buffer),
name: configuration.documentData.name,
type: configuration.documentData.type,
});
const documentData = await putPdfFile(
{
arrayBuffer: async () => Promise.resolve(configuration.documentData!.data.buffer),
name: configuration.documentData.name,
type: configuration.documentData.type,
},
{ presignToken },
);
// Use the externalId from the URL fragment if available
const metaWithExternalId = {
+1 -1
View File
@@ -106,5 +106,5 @@
"vite-plugin-babel-macros": "^1.0.6",
"vite-tsconfig-paths": "^5.1.4"
},
"version": "2.12.0"
"version": "2.13.0"
}
@@ -1,4 +1,6 @@
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { verifyEmbeddingPresignToken } from '@documenso/lib/server-only/embedding-presign/verify-embedding-presign-token';
import { generatePartialSignedPdf } from '@documenso/lib/server-only/pdf/generate-partial-signed-pdf';
import { getTeamById } from '@documenso/lib/server-only/team/get-team';
import { sha256 } from '@documenso/lib/universal/crypto';
@@ -26,6 +28,29 @@ type DocumentDataInput = {
initialData: string;
};
export const resolveFileUploadUserId = async (c: Context<HonoEnv>): Promise<number | null> => {
const session = await getOptionalSession(c);
if (session.user?.id) {
return session.user.id;
}
const authorizationHeader = c.req.header('authorization');
const [bearerToken] = (authorizationHeader || '').split('Bearer ').filter((part) => part.length > 0);
const queryToken = c.req.query('token');
const presignToken = bearerToken || queryToken;
if (!presignToken) {
return null;
}
const verifiedToken = await verifyEmbeddingPresignToken({ token: presignToken }).catch(() => undefined);
return verifiedToken?.userId ?? null;
};
type EnvelopeForPendingDownload = {
id: string;
status: DocumentStatus;
+19 -2
View File
@@ -10,8 +10,9 @@ import type { Prisma } from '@prisma/client';
import { Hono } from 'hono';
import type { HonoEnv } from '../../router';
import { checkEnvelopeFileAccess, handleEnvelopeItemFileRequest } from './files.helpers';
import { checkEnvelopeFileAccess, handleEnvelopeItemFileRequest, resolveFileUploadUserId } from './files.helpers';
import {
isAllowedUploadContentType,
type TGetPresignedPostUrlResponse,
ZGetEnvelopeItemFileDownloadRequestParamsSchema,
ZGetEnvelopeItemFileRequestParamsSchema,
@@ -31,6 +32,12 @@ export const filesRoute = new Hono<HonoEnv>()
*/
.post('/upload-pdf', sValidator('form', ZUploadPdfRequestSchema), async (c) => {
try {
const userId = await resolveFileUploadUserId(c);
if (!userId) {
return c.json({ error: 'Unauthorized' }, 401);
}
const { file } = c.req.valid('form');
if (!file) {
@@ -55,10 +62,20 @@ export const filesRoute = new Hono<HonoEnv>()
}
})
.post('/presigned-post-url', sValidator('json', ZGetPresignedPostUrlRequestSchema), async (c) => {
const userId = await resolveFileUploadUserId(c);
if (!userId) {
return c.json({ error: 'Unauthorized' }, 401);
}
const { fileName, contentType } = c.req.valid('json');
if (!isAllowedUploadContentType(contentType)) {
return c.json({ error: 'Unsupported content type' }, 400);
}
try {
const { key, url } = await getPresignPostUrl(fileName, contentType);
const { key, url } = await getPresignPostUrl(fileName, contentType, userId);
return c.json({ key, url } satisfies TGetPresignedPostUrlResponse);
} catch (err) {
@@ -13,6 +13,14 @@ export const ZUploadPdfResponseSchema = DocumentDataSchema.pick({
export type TUploadPdfRequest = z.infer<typeof ZUploadPdfRequestSchema>;
export type TUploadPdfResponse = z.infer<typeof ZUploadPdfResponseSchema>;
export const ALLOWED_UPLOAD_CONTENT_TYPES = ['application/pdf', 'image/jpeg', 'image/png', 'image/webp'] as const;
export const isAllowedUploadContentType = (contentType: string): boolean => {
const normalizedContentType = contentType.split(';').at(0)?.trim().toLowerCase();
return ALLOWED_UPLOAD_CONTENT_TYPES.some((allowed) => allowed === normalizedContentType);
};
export const ZGetPresignedPostUrlRequestSchema = z.object({
fileName: z.string().min(1),
contentType: z.string().min(1),
+4
View File
@@ -1,5 +1,6 @@
import { tsRestHonoApp } from '@documenso/api/hono';
import { auth } from '@documenso/auth/server';
import { csc } from '@documenso/ee/server-only/signing/csc/hono';
import { jobsClient } from '@documenso/lib/jobs/client';
import { LicenseClient } from '@documenso/lib/server-only/license/license-client';
import { createRateLimitMiddleware } from '@documenso/lib/server-only/rate-limit/rate-limit-middleware';
@@ -111,6 +112,9 @@ app.route('/api/files', filesRoute);
app.use('/api/ai/*', aiRateLimitMiddleware);
app.route('/api/ai', aiRoute);
// CSC OAuth routes (mounted from @documenso/ee).
app.route('/api/csc', csc);
// API servers.
app.route('/api/v1', tsRestHonoApp);
app.use('/api/jobs/*', jobsClient.getApiHandler());
+13 -11
View File
@@ -1,12 +1,12 @@
{
"name": "@documenso/root",
"version": "2.12.0",
"version": "2.13.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@documenso/root",
"version": "2.12.0",
"version": "2.13.0",
"hasInstallScript": true,
"workspaces": [
"apps/*",
@@ -15,7 +15,7 @@
"dependencies": {
"@ai-sdk/google-vertex": "3.0.81",
"@documenso/prisma": "*",
"@libpdf/core": "^0.3.6",
"@libpdf/core": "^0.4.0",
"@lingui/conf": "^5.6.0",
"@lingui/core": "^5.6.0",
"@marsidev/react-turnstile": "^1.5.0",
@@ -406,7 +406,7 @@
},
"apps/remix": {
"name": "@documenso/remix",
"version": "2.12.0",
"version": "2.13.0",
"dependencies": {
"@cantoo/pdf-lib": "^2.5.3",
"@documenso/api": "*",
@@ -4661,16 +4661,16 @@
"license": "MIT"
},
"node_modules/@libpdf/core": {
"version": "0.3.6",
"resolved": "https://registry.npmjs.org/@libpdf/core/-/core-0.3.6.tgz",
"integrity": "sha512-VzRUXaDq+M9qrroKiipCgePK2mwKM3M6DY7B0yfXnxD4aYnUxD/nUtkcsHCBUUnJpkX9rWikdEhYa5vU8ZlReg==",
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/@libpdf/core/-/core-0.4.0.tgz",
"integrity": "sha512-G9nZRjf9DGDJaS/C23YWogk8akPM7O/6HfMslxVsKTKRbbbb+0szpQIetcGGUGRu7KtmBDmGDWCgz//DXSmq8A==",
"license": "MIT",
"dependencies": {
"@noble/ciphers": "^2.2.0",
"@noble/hashes": "^2.2.0",
"@scure/base": "^2.2.0",
"asn1js": "^3.0.10",
"lru-cache": "^11.4.0",
"lru-cache": "^11.5.1",
"pako": "^2.1.0",
"pkijs": "^3.4.0"
},
@@ -4724,9 +4724,9 @@
}
},
"node_modules/@libpdf/core/node_modules/lru-cache": {
"version": "11.4.0",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.4.0.tgz",
"integrity": "sha512-W+R+kFL4HgVxONq2bhXPi3bGpzGe/yEhVOp233qw9wCRtgncJ15P3bC+e4zZMu4Cq7d+WAJjXGW0uUkifhcatA==",
"version": "11.5.1",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz",
"integrity": "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==",
"license": "BlueOak-1.0.0",
"engines": {
"node": "20 || >=22"
@@ -30593,6 +30593,8 @@
"@aws-sdk/client-sesv2": "^3.998.0",
"@documenso/lib": "*",
"@documenso/prisma": "*",
"arctic": "^3.7.0",
"hono": "^4.12.14",
"luxon": "^3.7.2",
"react": "^18",
"ts-pattern": "^5.9.0",
+2 -2
View File
@@ -5,7 +5,7 @@
"apps/*",
"packages/*"
],
"version": "2.12.0",
"version": "2.13.0",
"scripts": {
"postinstall": "patch-package",
"build": "turbo run build",
@@ -88,7 +88,7 @@
"dependencies": {
"@ai-sdk/google-vertex": "3.0.81",
"@documenso/prisma": "*",
"@libpdf/core": "^0.3.6",
"@libpdf/core": "^0.4.0",
"@lingui/conf": "^5.6.0",
"@lingui/core": "^5.6.0",
"@prisma/extension-read-replicas": "^0.4.1",
+7 -2
View File
@@ -1,7 +1,7 @@
import { getServerLimits } from '@documenso/ee/server-only/limits/server';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { DATE_FORMATS, DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats';
import { DocumentDataType, EnvelopeType, SigningStatus } from '@prisma/client';
import { DocumentDataType, DocumentStatus, EnvelopeType, SigningStatus } from '@prisma/client';
import { tsr } from '@ts-rest/serverless/fetch';
import { match } from 'ts-pattern';
import '@documenso/lib/constants/time-zones';
@@ -240,7 +240,12 @@ export const ApiContractV1Implementation = tsr.router(ApiContractV1, {
};
}
if (!downloadOriginalDocument && !isDocumentCompleted(envelope.status)) {
// A cancelled document was never sealed, so its data is the unsigned original.
// Treat it as not-completed here so a "signed" version is never served for it.
// REJECTED and COMPLETED keep their prior behavior.
const hasSignedArtifact = isDocumentCompleted(envelope.status) && envelope.status !== DocumentStatus.CANCELLED;
if (!downloadOriginalDocument && !hasSignedArtifact) {
return {
status: 400,
body: {
@@ -0,0 +1,64 @@
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { createApiToken } from '@documenso/lib/server-only/public-api/create-api-token';
import { mapSecondaryIdToDocumentId } from '@documenso/lib/utils/envelope';
import { prisma } from '@documenso/prisma';
import { SendStatus, SigningStatus } from '@documenso/prisma/client';
import { seedPendingDocument } from '@documenso/prisma/seed/documents';
import { seedUser } from '@documenso/prisma/seed/users';
import { expect, test } from '@playwright/test';
import type { Team, User } from '@prisma/client';
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
const baseUrl = `${WEBAPP_BASE_URL}/api/v2-beta`;
test.describe.configure({
mode: 'parallel',
});
test.describe('Redistribute updates recipient send status', () => {
let user: User, team: Team, token: string;
test.beforeEach(async () => {
({ user, team } = await seedUser());
({ token } = await createApiToken({
userId: user.id,
teamId: team.id,
tokenName: 'test',
expiresIn: null,
}));
});
test('marks a NOT_SENT signer as SENT after a successful resend', async ({ request }) => {
const document = await seedPendingDocument(user, team.id, ['recipient@test.documenso.com']);
const [recipient] = document.recipients;
// Simulate a recipient that is stuck at NOT_SENT on a pending document
// (e.g. the initial send did not dispatch an email for them).
await prisma.recipient.update({
where: { id: recipient.id },
data: {
sendStatus: SendStatus.NOT_SENT,
signingStatus: SigningStatus.NOT_SIGNED,
sentAt: null,
},
});
const res = await request.post(`${baseUrl}/document/redistribute`, {
headers: { Authorization: `Bearer ${token}` },
data: {
documentId: mapSecondaryIdToDocumentId(document.secondaryId),
recipients: [recipient.id],
},
});
expect(res.ok(), `redistribute should succeed: ${await res.text()}`).toBeTruthy();
const updatedRecipient = await prisma.recipient.findFirstOrThrow({
where: { id: recipient.id },
});
expect(updatedRecipient.sendStatus).toBe(SendStatus.SENT);
expect(updatedRecipient.sentAt).not.toBeNull();
});
});
@@ -0,0 +1,260 @@
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { createApiToken } from '@documenso/lib/server-only/public-api/create-api-token';
import { prisma } from '@documenso/prisma';
import { DocumentVisibility, SigningStatus, TeamMemberRole } from '@documenso/prisma/client';
import { seedPendingDocument } from '@documenso/prisma/seed/documents';
import { seedTeam, seedTeamMember } from '@documenso/prisma/seed/teams';
import { seedUser } from '@documenso/prisma/seed/users';
import type { TRejectEnvelopeRecipientOnBehalfOfRequest } from '@documenso/trpc/server/envelope-router/envelope-recipients/reject-envelope-recipient-on-behalf-of.types';
import { type APIRequestContext, expect, test } from '@playwright/test';
import type { Team, User } from '@prisma/client';
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
const baseUrl = `${WEBAPP_BASE_URL}/api/v2-beta`;
test.describe.configure({
mode: 'parallel',
});
const rejectRecipient = (
request: APIRequestContext,
authToken: string,
envelopeId: string,
recipientId: number,
reason: string,
actAsEmail?: string,
) => {
return request.post(`${baseUrl}/envelope/recipient/${recipientId}/reject`, {
headers: { Authorization: `Bearer ${authToken}`, 'Content-Type': 'application/json' },
data: {
envelopeId,
recipientId,
reason,
actAsEmail,
} satisfies TRejectEnvelopeRecipientOnBehalfOfRequest,
});
};
test.describe('Reject recipient on behalf of', () => {
let user: User;
let team: Team;
let token: string;
test.beforeEach(async () => {
({ user, team } = await seedUser());
({ token } = await createApiToken({
userId: user.id,
teamId: team.id,
tokenName: 'test-reject-recipient',
expiresIn: null,
}));
});
test('should reject a recipient and record an external rejection audit log', async ({ request }) => {
const envelope = await seedPendingDocument(user, team.id, ['recipient@test.documenso.com']);
const recipient = envelope.recipients[0];
const res = await rejectRecipient(request, token, envelope.id, recipient.id, 'Declined out of band');
expect(res.ok()).toBeTruthy();
expect(res.status()).toBe(200);
const updatedRecipient = await prisma.recipient.findUniqueOrThrow({
where: { id: recipient.id },
});
expect(updatedRecipient.signingStatus).toBe(SigningStatus.REJECTED);
expect(updatedRecipient.rejectionReason).toBe('Declined out of band');
const auditLog = await prisma.documentAuditLog.findFirst({
where: {
envelopeId: envelope.id,
type: 'DOCUMENT_RECIPIENT_REJECTED',
},
orderBy: { createdAt: 'desc' },
});
expect(auditLog).not.toBeNull();
const auditData = auditLog!.data as Record<string, unknown>;
expect(auditData.recipientId).toBe(recipient.id);
expect(auditData.recipientEmail).toBe(recipient.email);
expect(auditData.reason).toBe('Declined out of band');
expect(auditData.isExternal).toBe(true);
// No actAsEmail supplied - the rejection defaults to the API user.
expect(auditLog!.userId).toBe(user.id);
expect(auditLog!.email).toBe(user.email);
expect(auditData.onBehalfOfUserEmail).toBeUndefined();
});
test('should attribute the rejection to the elected team member when actAsEmail is supplied', async ({ request }) => {
const member = await seedTeamMember({ teamId: team.id });
const envelope = await seedPendingDocument(user, team.id, ['recipient@test.documenso.com']);
const recipient = envelope.recipients[0];
const res = await rejectRecipient(request, token, envelope.id, recipient.id, 'Declined out of band', member.email);
expect(res.ok()).toBeTruthy();
expect(res.status()).toBe(200);
const auditLog = await prisma.documentAuditLog.findFirstOrThrow({
where: {
envelopeId: envelope.id,
type: 'DOCUMENT_RECIPIENT_REJECTED',
},
orderBy: { createdAt: 'desc' },
});
// The audit log actor must be the elected member, not the API user.
expect(auditLog.userId).toBe(member.id);
expect(auditLog.email).toBe(member.email);
const auditData = auditLog.data as Record<string, unknown>;
expect(auditData.isExternal).toBe(true);
expect(auditData.onBehalfOfUserEmail).toBe(member.email);
});
test('should reject when actAsEmail is not a member of the team', async ({ request }) => {
// A user that exists but belongs to a different team.
const { user: outsider } = await seedUser();
const envelope = await seedPendingDocument(user, team.id, ['recipient@test.documenso.com']);
const recipient = envelope.recipients[0];
const res = await rejectRecipient(
request,
token,
envelope.id,
recipient.id,
'Declined out of band',
outsider.email,
);
expect(res.ok()).toBeFalsy();
expect(res.status()).toBe(401);
// The recipient must remain untouched.
const untouchedRecipient = await prisma.recipient.findUniqueOrThrow({
where: { id: recipient.id },
});
expect(untouchedRecipient.signingStatus).toBe(SigningStatus.NOT_SIGNED);
expect(untouchedRecipient.rejectionReason).toBeNull();
});
test('should deny rejecting a recipient that has already actioned the document', async ({ request }) => {
const envelope = await seedPendingDocument(user, team.id, ['recipient@test.documenso.com']);
const recipient = envelope.recipients[0];
// Reject once - succeeds.
const firstRes = await rejectRecipient(request, token, envelope.id, recipient.id, 'First rejection');
expect(firstRes.ok()).toBeTruthy();
// Reject again - the recipient is no longer NOT_SIGNED.
const secondRes = await rejectRecipient(request, token, envelope.id, recipient.id, 'Second rejection');
expect(secondRes.ok()).toBeFalsy();
expect(secondRes.status()).toBe(400);
// The original rejection reason must remain unchanged.
const updatedRecipient = await prisma.recipient.findUniqueOrThrow({
where: { id: recipient.id },
});
expect(updatedRecipient.rejectionReason).toBe('First rejection');
});
test('should not allow rejecting a recipient in another team', async ({ request }) => {
// Seed a separate team/user that owns the document.
const { user: otherUser, team: otherTeam } = await seedUser();
const envelope = await seedPendingDocument(otherUser, otherTeam.id, ['recipient@test.documenso.com']);
const recipient = envelope.recipients[0];
// Use the original team's token - it must not be able to reject.
const res = await rejectRecipient(request, token, envelope.id, recipient.id, 'Should not work');
expect(res.ok()).toBeFalsy();
expect(res.status()).toBe(404);
// The recipient must remain untouched.
const untouchedRecipient = await prisma.recipient.findUniqueOrThrow({
where: { id: recipient.id },
});
expect(untouchedRecipient.signingStatus).toBe(SigningStatus.NOT_SIGNED);
expect(untouchedRecipient.rejectionReason).toBeNull();
});
test('should return 404 for a non-existent recipient', async ({ request }) => {
const envelope = await seedPendingDocument(user, team.id, ['recipient@test.documenso.com']);
const res = await rejectRecipient(request, token, envelope.id, 999999999, 'No such recipient');
expect(res.ok()).toBeFalsy();
expect(res.status()).toBe(404);
});
test('should return 404 when the recipient does not belong to the supplied envelope', async ({ request }) => {
const targetEnvelope = await seedPendingDocument(user, team.id, ['recipient@test.documenso.com']);
const otherEnvelope = await seedPendingDocument(user, team.id, ['other-recipient@test.documenso.com']);
const recipient = targetEnvelope.recipients[0];
// Valid recipient ID, but paired with the wrong envelope ID.
const res = await rejectRecipient(request, token, otherEnvelope.id, recipient.id, 'Mismatched envelope');
expect(res.ok()).toBeFalsy();
expect(res.status()).toBe(404);
// The recipient must remain untouched.
const untouchedRecipient = await prisma.recipient.findUniqueOrThrow({
where: { id: recipient.id },
});
expect(untouchedRecipient.signingStatus).toBe(SigningStatus.NOT_SIGNED);
expect(untouchedRecipient.rejectionReason).toBeNull();
});
test('should enforce document visibility: manager cannot reject on an ADMIN-only document', async ({ request }) => {
// The API token belongs to a MANAGER, who cannot see ADMIN-visibility docs.
const { team: visTeam, owner } = await seedTeam();
const manager = await seedTeamMember({ teamId: visTeam.id, role: TeamMemberRole.MANAGER });
const { token: managerToken } = await createApiToken({
userId: manager.id,
teamId: visTeam.id,
tokenName: 'manager-reject-token',
expiresIn: null,
});
// ADMIN-visibility document owned by the team owner.
const envelope = await seedPendingDocument(owner, visTeam.id, ['recipient@test.documenso.com'], {
createDocumentOptions: { visibility: DocumentVisibility.ADMIN },
});
const recipient = envelope.recipients[0];
const res = await rejectRecipient(
request,
managerToken,
envelope.id,
recipient.id,
'Should be hidden by visibility',
);
// Visibility failure surfaces as not-found, matching the canonical checks.
expect(res.ok()).toBeFalsy();
expect(res.status()).toBe(404);
const untouchedRecipient = await prisma.recipient.findUniqueOrThrow({
where: { id: recipient.id },
});
expect(untouchedRecipient.signingStatus).toBe(SigningStatus.NOT_SIGNED);
expect(untouchedRecipient.rejectionReason).toBeNull();
});
});
@@ -0,0 +1,242 @@
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { createApiToken } from '@documenso/lib/server-only/public-api/create-api-token';
import { prisma } from '@documenso/prisma';
import { seedCompletedDocument, seedDraftDocument, seedPendingDocument } from '@documenso/prisma/seed/documents';
import { seedTeam, seedTeamMember } from '@documenso/prisma/seed/teams';
import { seedUser } from '@documenso/prisma/seed/users';
import { expect, test } from '@playwright/test';
import { DocumentStatus, TeamMemberRole } from '@prisma/client';
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
const baseUrl = `${WEBAPP_BASE_URL}/api/v2-beta`;
test.describe.configure({
mode: 'parallel',
});
const createTokenForUser = async (userId: number, teamId: number, tokenName: string) => {
const { token } = await createApiToken({
userId,
teamId,
tokenName,
expiresIn: null,
});
return token;
};
test.describe('Envelope cancel endpoint authorization', () => {
test('hides the document from an outsider attempting to cancel it', async ({ request }) => {
const { user: owner, team } = await seedUser();
const { user: recipient } = await seedUser();
const document = await seedPendingDocument(owner, team.id, [recipient]);
const { user: outsider, team: outsiderTeam } = await seedUser();
const outsiderToken = await createTokenForUser(outsider.id, outsiderTeam.id, 'outsider');
const res = await request.post(`${baseUrl}/envelope/cancel`, {
headers: { Authorization: `Bearer ${outsiderToken}` },
data: { envelopeId: document.id },
});
// Outsiders must not be able to determine whether the envelope exists.
expect(res.ok()).toBeFalsy();
expect(res.status()).toBe(404);
// The document must be untouched.
const documentInDb = await prisma.envelope.findFirstOrThrow({
where: { id: document.id },
select: { status: true },
});
expect(documentInDb.status).toBe(DocumentStatus.PENDING);
});
test('hides the document from a recipient attempting to cancel it', async ({ request }) => {
const { user: owner, team } = await seedUser();
const { user: recipient, team: recipientTeam } = await seedUser();
const document = await seedPendingDocument(owner, team.id, [recipient]);
const recipientToken = await createTokenForUser(recipient.id, recipientTeam.id, 'recipient');
const res = await request.post(`${baseUrl}/envelope/cancel`, {
headers: { Authorization: `Bearer ${recipientToken}` },
data: { envelopeId: document.id },
});
// A recipient is not a member of the document's team, so they must not be
// able to determine whether it exists via this endpoint.
expect(res.ok()).toBeFalsy();
expect(res.status()).toBe(404);
const documentInDb = await prisma.envelope.findFirstOrThrow({
where: { id: document.id },
select: { status: true },
});
expect(documentInDb.status).toBe(DocumentStatus.PENDING);
});
// Note: a non-privileged MEMBER cannot obtain an API token at all (token
// creation requires the MANAGE_TEAM permission), so the MEMBER cancellation
// restriction is covered through the UI tests in cancel-documents.spec.ts
// rather than at the API layer.
test('allows the document owner to cancel a pending document', async ({ request }) => {
const { user: owner, team } = await seedUser();
const { user: recipient } = await seedUser();
const document = await seedPendingDocument(owner, team.id, [recipient]);
const ownerToken = await createTokenForUser(owner.id, team.id, 'owner');
const res = await request.post(`${baseUrl}/envelope/cancel`, {
headers: { Authorization: `Bearer ${ownerToken}` },
data: { envelopeId: document.id },
});
expect(res.ok()).toBeTruthy();
expect(res.status()).toBe(200);
const documentInDb = await prisma.envelope.findFirstOrThrow({
where: { id: document.id },
select: { status: true, completedAt: true, deletedAt: true },
});
expect(documentInDb.status).toBe(DocumentStatus.CANCELLED);
expect(documentInDb.completedAt).not.toBeNull();
expect(documentInDb.deletedAt).toBeNull();
});
test('allows a team ADMIN to cancel a pending document they do not own', async ({ request }) => {
const { team, owner } = await seedTeam();
const adminUser = await seedTeamMember({
teamId: team.id,
role: TeamMemberRole.ADMIN,
});
const { user: recipient } = await seedUser();
const document = await seedPendingDocument(owner, team.id, [recipient]);
const adminToken = await createTokenForUser(adminUser.id, team.id, 'admin');
const res = await request.post(`${baseUrl}/envelope/cancel`, {
headers: { Authorization: `Bearer ${adminToken}` },
data: { envelopeId: document.id },
});
expect(res.ok()).toBeTruthy();
expect(res.status()).toBe(200);
const documentInDb = await prisma.envelope.findFirstOrThrow({
where: { id: document.id },
select: { status: true },
});
expect(documentInDb.status).toBe(DocumentStatus.CANCELLED);
});
test('allows a team MANAGER to cancel a pending document they do not own', async ({ request }) => {
const { team, owner } = await seedTeam();
const managerUser = await seedTeamMember({
teamId: team.id,
role: TeamMemberRole.MANAGER,
});
const { user: recipient } = await seedUser();
const document = await seedPendingDocument(owner, team.id, [recipient]);
const managerToken = await createTokenForUser(managerUser.id, team.id, 'manager');
const res = await request.post(`${baseUrl}/envelope/cancel`, {
headers: { Authorization: `Bearer ${managerToken}` },
data: { envelopeId: document.id },
});
expect(res.ok()).toBeTruthy();
expect(res.status()).toBe(200);
const documentInDb = await prisma.envelope.findFirstOrThrow({
where: { id: document.id },
select: { status: true },
});
expect(documentInDb.status).toBe(DocumentStatus.CANCELLED);
});
test('rejects cancelling a draft document', async ({ request }) => {
const { user: owner, team } = await seedUser();
const document = await seedDraftDocument(owner, team.id, []);
const ownerToken = await createTokenForUser(owner.id, team.id, 'owner-draft');
const res = await request.post(`${baseUrl}/envelope/cancel`, {
headers: { Authorization: `Bearer ${ownerToken}` },
data: { envelopeId: document.id },
});
expect(res.ok()).toBeFalsy();
expect(res.status()).toBe(400);
const documentInDb = await prisma.envelope.findFirstOrThrow({
where: { id: document.id },
select: { status: true },
});
expect(documentInDb.status).toBe(DocumentStatus.DRAFT);
});
test('rejects cancelling a completed document', async ({ request }) => {
const { user: owner, team } = await seedUser();
const { user: recipient } = await seedUser();
const document = await seedCompletedDocument(owner, team.id, [recipient]);
const ownerToken = await createTokenForUser(owner.id, team.id, 'owner-completed');
const res = await request.post(`${baseUrl}/envelope/cancel`, {
headers: { Authorization: `Bearer ${ownerToken}` },
data: { envelopeId: document.id },
});
expect(res.ok()).toBeFalsy();
expect(res.status()).toBe(400);
const documentInDb = await prisma.envelope.findFirstOrThrow({
where: { id: document.id },
select: { status: true },
});
expect(documentInDb.status).toBe(DocumentStatus.COMPLETED);
});
test('rejects double cancellation of an already cancelled document', async ({ request }) => {
const { user: owner, team } = await seedUser();
const { user: recipient } = await seedUser();
const document = await seedPendingDocument(owner, team.id, [recipient]);
const ownerToken = await createTokenForUser(owner.id, team.id, 'owner-double');
const firstRes = await request.post(`${baseUrl}/envelope/cancel`, {
headers: { Authorization: `Bearer ${ownerToken}` },
data: { envelopeId: document.id },
});
expect(firstRes.status()).toBe(200);
const secondRes = await request.post(`${baseUrl}/envelope/cancel`, {
headers: { Authorization: `Bearer ${ownerToken}` },
data: { envelopeId: document.id },
});
expect(secondRes.ok()).toBeFalsy();
expect(secondRes.status()).toBe(400);
const documentInDb = await prisma.envelope.findFirstOrThrow({
where: { id: document.id },
select: { status: true },
});
expect(documentInDb.status).toBe(DocumentStatus.CANCELLED);
});
});
@@ -0,0 +1,102 @@
import fs from 'node:fs';
import path from 'node:path';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { createEmbeddingPresignToken } from '@documenso/lib/server-only/embedding-presign/create-embedding-presign-token';
import { createApiToken } from '@documenso/lib/server-only/public-api/create-api-token';
import { seedUser } from '@documenso/prisma/seed/users';
import { expect, test } from '@playwright/test';
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
const examplePdf = fs.readFileSync(path.join(__dirname, '../../../../../../assets/example.pdf'));
test.describe.configure({
mode: 'parallel',
});
const createPresignTokenForUser = async (userId: number, teamId: number) => {
const { token: apiToken } = await createApiToken({
userId,
teamId,
tokenName: 'file-upload-test',
expiresIn: null,
});
const { token: presignToken } = await createEmbeddingPresignToken({ apiToken });
return presignToken;
};
const buildPdfFormData = () => {
const formData = new FormData();
formData.append('file', new File([examplePdf], 'test.pdf', { type: 'application/pdf' }));
return formData;
};
test.describe('File upload endpoint authorization', () => {
test('rejects an unauthenticated upload-pdf request', async ({ request }) => {
const res = await request.post(`${WEBAPP_BASE_URL}/api/files/upload-pdf`, {
multipart: buildPdfFormData(),
});
expect(res.ok()).toBeFalsy();
expect(res.status()).toBe(401);
});
test('rejects an unauthenticated presigned-post-url request', async ({ request }) => {
const res = await request.post(`${WEBAPP_BASE_URL}/api/files/presigned-post-url`, {
headers: { 'Content-Type': 'application/json' },
data: { fileName: 'test.pdf', contentType: 'application/pdf' },
});
expect(res.ok()).toBeFalsy();
expect(res.status()).toBe(401);
});
test('rejects a presigned-post-url request with an invalid presign token', async ({ request }) => {
const res = await request.post(`${WEBAPP_BASE_URL}/api/files/presigned-post-url`, {
headers: {
'Content-Type': 'application/json',
Authorization: 'Bearer not-a-real-token',
},
data: { fileName: 'test.pdf', contentType: 'application/pdf' },
});
expect(res.ok()).toBeFalsy();
expect(res.status()).toBe(401);
});
test('rejects a presigned-post-url request with a disallowed content type', async ({ request }) => {
const { user, team } = await seedUser();
const presignToken = await createPresignTokenForUser(user.id, team.id);
const res = await request.post(`${WEBAPP_BASE_URL}/api/files/presigned-post-url`, {
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${presignToken}`,
},
data: { fileName: 'malware.exe', contentType: 'application/x-msdownload' },
});
// Authenticated, but the content type is not on the allow-list.
expect(res.ok()).toBeFalsy();
expect(res.status()).toBe(400);
});
test('allows an upload-pdf request authorized by a valid presign token', async ({ request }) => {
const { user, team } = await seedUser();
const presignToken = await createPresignTokenForUser(user.id, team.id);
const res = await request.post(`${WEBAPP_BASE_URL}/api/files/upload-pdf`, {
headers: { Authorization: `Bearer ${presignToken}` },
multipart: buildPdfFormData(),
});
expect(res.ok()).toBeTruthy();
expect(res.status()).toBe(200);
const body = await res.json();
expect(body.id).toBeDefined();
});
});
@@ -1,9 +1,12 @@
import { seedDraftDocument } from '@documenso/prisma/seed/documents';
import { prisma } from '@documenso/prisma';
import { seedCompletedDocument, seedDraftDocument, seedPendingDocument } from '@documenso/prisma/seed/documents';
import { seedBlankFolder } from '@documenso/prisma/seed/folders';
import { seedTeam, seedTeamMember } from '@documenso/prisma/seed/teams';
import { seedUser } from '@documenso/prisma/seed/users';
import { expect, test } from '@playwright/test';
import { DocumentStatus, TeamMemberRole } from '@prisma/client';
import { apiSignin } from '../fixtures/authentication';
import { apiSignin, apiSignout } from '../fixtures/authentication';
import { expectToastTextToBeVisible } from '../fixtures/generic';
test.describe.configure({ mode: 'parallel' });
@@ -250,3 +253,147 @@ test('[BULK_ACTIONS]: can move documents from folder to home (root)', async ({ p
await page.goto(`/t/${sender.team.url}/documents/f/${folder.id}`);
await expect(page.getByRole('link', { name: 'Bulk Test Doc 1' })).not.toBeVisible();
});
// ─── Bulk cancel ─────────────────────────────────────────────────────────────
test('[BULK_ACTIONS]: can cancel multiple pending documents', async ({ page }) => {
const sender = await seedUser({ setTeamEmailAsOwner: true });
const { user: recipient } = await seedUser();
const [pending1, pending2] = await Promise.all([
seedPendingDocument(sender.user, sender.team.id, [recipient], {
createDocumentOptions: { title: 'Bulk Cancel Pending 1' },
}),
seedPendingDocument(sender.user, sender.team.id, [recipient], {
createDocumentOptions: { title: 'Bulk Cancel Pending 2' },
}),
]);
await apiSignin({
page,
email: sender.user.email,
redirectPath: `/t/${sender.team.url}/documents`,
});
await page.locator('tr', { hasText: 'Bulk Cancel Pending 1' }).getByRole('checkbox').click();
await page.locator('tr', { hasText: 'Bulk Cancel Pending 2' }).getByRole('checkbox').click();
await expect(page.getByText('2 selected')).toBeVisible();
// The bulk action bar Cancel button (distinct from the dialog's confirm button).
await page.getByRole('button', { name: 'Cancel', exact: true }).click();
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible();
await expect(dialog.getByRole('heading', { name: 'Cancel Documents' })).toBeVisible();
await expect(dialog.getByText('You are about to cancel 2 documents')).toBeVisible();
await dialog.getByRole('button', { name: 'Cancel documents' }).click();
await expectToastTextToBeVisible(page, 'Documents cancelled');
// Selection clears after a successful cancel.
await expect(page.getByText(/\d+ selected/)).not.toBeVisible();
// Both documents are now cancelled in the database.
for (const document of [pending1, pending2]) {
const envelope = await prisma.envelope.findFirstOrThrow({
where: { id: document.id },
select: { status: true, deletedAt: true },
});
expect(envelope.status).toBe(DocumentStatus.CANCELLED);
expect(envelope.deletedAt).toBeNull();
}
});
test('[BULK_ACTIONS]: bulk cancel only affects pending documents', async ({ page }) => {
const sender = await seedUser({ setTeamEmailAsOwner: true });
const { user: recipient } = await seedUser();
const pending = await seedPendingDocument(sender.user, sender.team.id, [recipient], {
createDocumentOptions: { title: 'Mixed Cancel Pending' },
});
const draft = await seedDraftDocument(sender.user, sender.team.id, [], {
createDocumentOptions: { title: 'Mixed Cancel Draft' },
});
const completed = await seedCompletedDocument(sender.user, sender.team.id, [recipient], {
createDocumentOptions: { title: 'Mixed Cancel Completed' },
});
await apiSignin({
page,
email: sender.user.email,
redirectPath: `/t/${sender.team.url}/documents`,
});
await page.locator('thead').getByRole('checkbox').click();
await expect(page.getByText('3 selected')).toBeVisible();
await page.getByRole('button', { name: 'Cancel', exact: true }).click();
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible();
await dialog.getByRole('button', { name: 'Cancel documents' }).click();
// Only one of the three was pending, so this is a partial result.
await expectToastTextToBeVisible(page, 'Documents partially cancelled');
const pendingEnvelope = await prisma.envelope.findFirstOrThrow({
where: { id: pending.id },
select: { status: true },
});
expect(pendingEnvelope.status).toBe(DocumentStatus.CANCELLED);
// The draft and completed documents are untouched.
const draftEnvelope = await prisma.envelope.findFirstOrThrow({
where: { id: draft.id },
select: { status: true },
});
expect(draftEnvelope.status).toBe(DocumentStatus.DRAFT);
const completedEnvelope = await prisma.envelope.findFirstOrThrow({
where: { id: completed.id },
select: { status: true },
});
expect(completedEnvelope.status).toBe(DocumentStatus.COMPLETED);
});
test('[BULK_ACTIONS]: a MEMBER cannot bulk cancel documents they do not own', async ({ page }) => {
const { team, owner } = await seedTeam();
const memberUser = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.MEMBER });
const { user: recipient } = await seedUser();
const ownerDocument = await seedPendingDocument(owner, team.id, [recipient], {
createDocumentOptions: { title: 'Member Cannot Cancel This', visibility: 'EVERYONE' },
});
await apiSignin({
page,
email: memberUser.email,
redirectPath: `/t/${team.url}/documents?status=PENDING`,
});
await page.locator('tr', { hasText: 'Member Cannot Cancel This' }).getByRole('checkbox').click();
await expect(page.getByText('1 selected')).toBeVisible();
await page.getByRole('button', { name: 'Cancel', exact: true }).click();
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible();
await dialog.getByRole('button', { name: 'Cancel documents' }).click();
// The server rejects the cancellation for a document the MEMBER does not own,
// so it reports zero cancelled (a partial result with the document in failedIds).
await expectToastTextToBeVisible(page, 'Documents partially cancelled');
// The document remains pending.
const envelope = await prisma.envelope.findFirstOrThrow({
where: { id: ownerDocument.id },
select: { status: true },
});
expect(envelope.status).toBe(DocumentStatus.PENDING);
await apiSignout({ page });
});
@@ -0,0 +1,342 @@
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { prisma } from '@documenso/prisma';
import { seedCancelledDocument, seedPendingDocument } from '@documenso/prisma/seed/documents';
import { seedTeam, seedTeamMember } from '@documenso/prisma/seed/teams';
import { seedUser } from '@documenso/prisma/seed/users';
import { expect, type Page, test } from '@playwright/test';
import { DocumentStatus, TeamMemberRole } from '@prisma/client';
import { apiSignin, apiSignout } from '../fixtures/authentication';
import { checkDocumentTabCount } from '../fixtures/documents';
import { expectToastTextToBeVisible, openDropdownMenu } from '../fixtures/generic';
test.describe.configure({ mode: 'serial' });
const seedCancelDocumentsTestRequirements = async () => {
const [sender, recipientA, recipientB] = await Promise.all([
seedUser({ setTeamEmailAsOwner: true }),
seedUser({ setTeamEmailAsOwner: true }),
seedUser({ setTeamEmailAsOwner: true }),
]);
const pendingDocument = await seedPendingDocument(sender.user, sender.team.id, [recipientA.user, recipientB.user], {
createDocumentOptions: { title: 'Document 1 - Pending' },
});
return {
sender,
recipients: [recipientA, recipientB],
pendingDocument,
};
};
const cancelDocumentViaUi = async (page: Page, documentTitle: string, reason?: string) => {
const documentActionBtn = page.locator('tr', { hasText: documentTitle }).getByTestId('document-table-action-btn');
await openDropdownMenu(page, documentActionBtn);
await expect(page.getByRole('menuitem', { name: 'Cancel' })).toBeVisible();
await page.getByRole('menuitem', { name: 'Cancel' }).click();
await expect(page.getByRole('heading', { name: 'Are you sure?' })).toBeVisible();
if (reason) {
await page.getByPlaceholder('Add an optional reason for cancelling this document').fill(reason);
}
await page.getByRole('button', { name: 'Cancel document' }).click();
};
test('[DOCUMENTS]: cancelling a pending document keeps it in the owner dashboard as cancelled', async ({ page }) => {
const { sender, pendingDocument } = await seedCancelDocumentsTestRequirements();
await apiSignin({
page,
email: sender.user.email,
redirectPath: `/t/${sender.team.url}/documents`,
});
await cancelDocumentViaUi(page, 'Document 1 - Pending', 'No longer required');
await expectToastTextToBeVisible(page, 'Document cancelled');
// The document must remain in the dashboard, unlike deleting a pending document.
await checkDocumentTabCount(page, 'Inbox', 0);
await checkDocumentTabCount(page, 'Pending', 0);
await checkDocumentTabCount(page, 'Cancelled', 1);
await checkDocumentTabCount(page, 'All', 1);
// The cancelled document is still listed.
await page.getByRole('tab', { name: 'Cancelled' }).click();
await expect(page.getByRole('link', { name: 'Document 1 - Pending' })).toBeVisible();
// The envelope status is persisted as CANCELLED.
const envelope = await prisma.envelope.findFirstOrThrow({
where: {
id: pendingDocument.id,
},
select: {
status: true,
completedAt: true,
deletedAt: true,
},
});
expect(envelope.status).toBe(DocumentStatus.CANCELLED);
expect(envelope.completedAt).not.toBeNull();
expect(envelope.deletedAt).toBeNull();
});
test('[DOCUMENTS]: cancelling a pending document retains it for recipients', async ({ page }) => {
const { sender, recipients } = await seedCancelDocumentsTestRequirements();
await apiSignin({
page,
email: sender.user.email,
redirectPath: `/t/${sender.team.url}/documents`,
});
await cancelDocumentViaUi(page, 'Document 1 - Pending');
await expectToastTextToBeVisible(page, 'Document cancelled');
await apiSignout({ page });
// Recipients should still be able to see the document as a record of distribution.
for (const recipient of recipients) {
await apiSignin({
page,
email: recipient.user.email,
redirectPath: `/t/${recipient.team.url}/documents`,
});
await expect(page.getByRole('link', { name: 'Document 1 - Pending' })).toBeVisible();
await apiSignout({ page });
}
});
test('[DOCUMENTS]: a cancelled document can be deleted, hiding it from the owner without removing it', async ({
page,
}) => {
const { sender, recipients, pendingDocument } = await seedCancelDocumentsTestRequirements();
await apiSignin({
page,
email: sender.user.email,
redirectPath: `/t/${sender.team.url}/documents`,
});
await cancelDocumentViaUi(page, 'Document 1 - Pending');
await expectToastTextToBeVisible(page, 'Document cancelled');
// Delete the now-cancelled document. Being terminal, it should soft delete (hide).
await page.getByRole('tab', { name: 'Cancelled' }).click();
const documentActionBtn = page
.locator('tr', { hasText: 'Document 1 - Pending' })
.getByTestId('document-table-action-btn');
await openDropdownMenu(page, documentActionBtn);
await expect(page.getByRole('menuitem', { name: 'Delete' })).toBeVisible();
await page.getByRole('menuitem', { name: 'Delete' }).click();
await page.getByPlaceholder("Type 'delete' to confirm").fill('delete');
await page.getByRole('button', { name: 'Delete' }).click();
await page.waitForTimeout(2500);
await expect(page.getByRole('row', { name: /Document 1 - Pending/ })).not.toBeVisible();
// The envelope is soft deleted, not hard deleted.
const envelope = await prisma.envelope.findFirstOrThrow({
where: {
id: pendingDocument.id,
},
select: {
status: true,
deletedAt: true,
},
});
expect(envelope.status).toBe(DocumentStatus.CANCELLED);
expect(envelope.deletedAt).not.toBeNull();
await apiSignout({ page });
// Recipients should still retain the document after the owner deletes it.
await apiSignin({
page,
email: recipients[0].user.email,
redirectPath: `/t/${recipients[0].team.url}/documents`,
});
await expect(page.getByRole('link', { name: 'Document 1 - Pending' })).toBeVisible();
});
// ─── Visibility: a cancelled document must respect team document visibility ───
test('[DOCUMENTS]: cancelled document with ADMIN visibility is hidden from a MEMBER', async ({ page }) => {
const { team, owner } = await seedTeam();
const adminUser = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.ADMIN });
const managerUser = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.MANAGER });
const memberUser = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.MEMBER });
await seedCancelledDocument(owner, team.id, [], {
createDocumentOptions: {
visibility: 'ADMIN',
title: 'Cancelled Admin Only Document',
},
});
// The MEMBER must NOT see the ADMIN-visibility cancelled document on any tab.
await apiSignin({
page,
email: memberUser.email,
redirectPath: `/t/${team.url}/documents?status=CANCELLED`,
});
await expect(page.getByRole('link', { name: 'Cancelled Admin Only Document', exact: true })).not.toBeVisible();
// Also confirm it doesn't leak via the ALL tab.
await page.goto(`${NEXT_PUBLIC_WEBAPP_URL()}/t/${team.url}/documents`);
await expect(page.getByRole('link', { name: 'Cancelled Admin Only Document', exact: true })).not.toBeVisible();
await apiSignout({ page });
// The MANAGER must NOT see an ADMIN-visibility document either.
await apiSignin({
page,
email: managerUser.email,
redirectPath: `/t/${team.url}/documents?status=CANCELLED`,
});
await expect(page.getByRole('link', { name: 'Cancelled Admin Only Document', exact: true })).not.toBeVisible();
await apiSignout({ page });
// The ADMIN must see it.
await apiSignin({
page,
email: adminUser.email,
redirectPath: `/t/${team.url}/documents?status=CANCELLED`,
});
await expect(page.getByRole('link', { name: 'Cancelled Admin Only Document', exact: true })).toBeVisible();
});
test('[DOCUMENTS]: cancelled document with MANAGER_AND_ABOVE visibility is hidden from a MEMBER', async ({ page }) => {
const { team, owner } = await seedTeam();
const managerUser = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.MANAGER });
const memberUser = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.MEMBER });
await seedCancelledDocument(owner, team.id, [], {
createDocumentOptions: {
visibility: 'MANAGER_AND_ABOVE',
title: 'Cancelled Manager Document',
},
});
// The MEMBER must NOT see it.
await apiSignin({
page,
email: memberUser.email,
redirectPath: `/t/${team.url}/documents?status=CANCELLED`,
});
await expect(page.getByRole('link', { name: 'Cancelled Manager Document', exact: true })).not.toBeVisible();
await apiSignout({ page });
// The MANAGER must see it.
await apiSignin({
page,
email: managerUser.email,
redirectPath: `/t/${team.url}/documents?status=CANCELLED`,
});
await expect(page.getByRole('link', { name: 'Cancelled Manager Document', exact: true })).toBeVisible();
});
test('[DOCUMENTS]: a recipient sees a cancelled document regardless of restricted visibility', async ({ page }) => {
const { team, owner } = await seedTeam();
// A MEMBER who is also a recipient on an ADMIN-visibility document.
const memberRecipient = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.MEMBER });
await seedCancelledDocument(owner, team.id, [memberRecipient], {
createDocumentOptions: {
visibility: 'ADMIN',
title: 'Cancelled Admin Doc With Recipient',
},
});
// Even though the document is ADMIN-only, the MEMBER is a recipient, so they
// must still see it (proof of distribution), matching completed-document behaviour.
await apiSignin({
page,
email: memberRecipient.email,
redirectPath: `/t/${team.url}/documents?status=CANCELLED`,
});
await expect(page.getByRole('link', { name: 'Cancelled Admin Doc With Recipient', exact: true })).toBeVisible();
});
// ─── UI gating: only privileged members see the Cancel action ────────────────
test('[DOCUMENTS]: a MEMBER does not see the Cancel action on a pending document', async ({ page }) => {
const { team, owner } = await seedTeam();
const memberUser = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.MEMBER });
const { user: recipient } = await seedUser();
await seedPendingDocument(owner, team.id, [recipient], {
createDocumentOptions: { title: 'Member Gating Pending Document', visibility: 'EVERYONE' },
});
await apiSignin({
page,
email: memberUser.email,
redirectPath: `/t/${team.url}/documents?status=PENDING`,
});
const documentActionBtn = page
.locator('tr', { hasText: 'Member Gating Pending Document' })
.getByTestId('document-table-action-btn');
await openDropdownMenu(page, documentActionBtn);
// The dropdown must render (Edit is always there) but Cancel must be absent.
await expect(page.getByRole('menuitem', { name: 'Edit' })).toBeVisible();
await expect(page.getByRole('menuitem', { name: 'Cancel' })).not.toBeVisible();
});
test('[DOCUMENTS]: a team ADMIN sees and can use the Cancel action on a document they do not own', async ({ page }) => {
const { team, owner } = await seedTeam();
const adminUser = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.ADMIN });
const { user: recipient } = await seedUser();
const document = await seedPendingDocument(owner, team.id, [recipient], {
createDocumentOptions: { title: 'Admin Cancellable Document', visibility: 'EVERYONE' },
});
await apiSignin({
page,
email: adminUser.email,
redirectPath: `/t/${team.url}/documents?status=PENDING`,
});
await cancelDocumentViaUi(page, 'Admin Cancellable Document');
await expectToastTextToBeVisible(page, 'Document cancelled');
const envelope = await prisma.envelope.findFirstOrThrow({
where: { id: document.id },
select: { status: true },
});
expect(envelope.status).toBe(DocumentStatus.CANCELLED);
});
@@ -3,6 +3,7 @@ import path from 'node:path';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { createApiToken } from '@documenso/lib/server-only/public-api/create-api-token';
import { prisma } from '@documenso/prisma';
import { seedDraftDocument } from '@documenso/prisma/seed/documents';
import { seedTemplate } from '@documenso/prisma/seed/templates';
import { seedUser } from '@documenso/prisma/seed/users';
import type {
@@ -302,6 +303,95 @@ test.describe('document editor', () => {
expect(envelopes.length).toBeGreaterThanOrEqual(2);
});
test('duplicate document without recipients excludes recipients and fields', async ({ page }) => {
const { user, team } = await seedUser();
// Seed a draft document that has a recipient with a field.
const document = await seedDraftDocument(user, team.id, ['signer@test.documenso.com'], {
key: `dup-exclude-recipients-${Date.now()}`,
internalVersion: 2,
});
await apiSignin({
page,
email: user.email,
redirectPath: `/t/${team.url}/documents/${document.id}/edit`,
});
await expect(page.getByRole('heading', { name: 'Documents' })).toBeVisible();
// Open the duplicate dialog.
await page.locator('button[title="Duplicate Envelope"]').click();
await expect(page.getByRole('heading', { name: 'Duplicate Document' })).toBeVisible();
// Uncheck "Include Recipients" — this also disables and unchecks "Include Fields".
await page.getByLabel('Include Recipients').click();
await expect(page.getByLabel('Include Fields')).toBeDisabled();
// Duplicate.
await page.getByRole('button', { name: 'Duplicate' }).click();
await expectToastTextToBeVisible(page, 'Document Duplicated');
await expect(page).toHaveURL(/\/documents\/.*\/edit/);
// The duplicate should have neither recipients nor fields.
const duplicate = await prisma.envelope.findFirstOrThrow({
where: {
teamId: team.id,
type: EnvelopeType.DOCUMENT,
id: { not: document.id },
},
include: { recipients: true, fields: true },
orderBy: { createdAt: 'desc' },
});
expect(duplicate.recipients).toHaveLength(0);
expect(duplicate.fields).toHaveLength(0);
});
test('duplicate document without fields keeps recipients but excludes fields', async ({ page }) => {
const { user, team } = await seedUser();
// Seed a draft document that has a recipient with a field.
const document = await seedDraftDocument(user, team.id, ['signer@test.documenso.com'], {
key: `dup-exclude-fields-${Date.now()}`,
internalVersion: 2,
});
await apiSignin({
page,
email: user.email,
redirectPath: `/t/${team.url}/documents/${document.id}/edit`,
});
await expect(page.getByRole('heading', { name: 'Documents' })).toBeVisible();
// Open the duplicate dialog.
await page.locator('button[title="Duplicate Envelope"]').click();
await expect(page.getByRole('heading', { name: 'Duplicate Document' })).toBeVisible();
// Uncheck only "Include Fields" (recipients stay included).
await page.getByLabel('Include Fields').click();
// Duplicate.
await page.getByRole('button', { name: 'Duplicate' }).click();
await expectToastTextToBeVisible(page, 'Document Duplicated');
await expect(page).toHaveURL(/\/documents\/.*\/edit/);
// The duplicate should keep the recipient but have no fields.
const duplicate = await prisma.envelope.findFirstOrThrow({
where: {
teamId: team.id,
type: EnvelopeType.DOCUMENT,
id: { not: document.id },
},
include: { recipients: true, fields: true },
orderBy: { createdAt: 'desc' },
});
expect(duplicate.recipients).toHaveLength(1);
expect(duplicate.fields).toHaveLength(0);
});
test('download PDF dialog shows envelope items', async ({ page }) => {
await openDocumentEnvelopeEditor(page);
@@ -270,7 +270,7 @@ test('[ENVELOPE_EXPIRATION]: resending refreshes expiresAt', async ({ page }) =>
await page.getByLabel('test.documenso.com').first().click();
await page.getByRole('button', { name: 'Send reminder' }).click();
await expect(page.getByText('Document re-sent', { exact: true })).toBeVisible({
await expect(page.getByText('Document resent', { exact: true })).toBeVisible({
timeout: 10_000,
});
@@ -238,7 +238,7 @@ test('[TEAMS]: resend pending team document', async ({ page }) => {
await page.getByLabel('test.documenso.com').first().click();
await page.getByRole('button', { name: 'Send reminder' }).click();
await expectToastTextToBeVisible(page, 'Document re-sent');
await expectToastTextToBeVisible(page, 'Document resent');
});
test('[TEAMS]: delete draft team document', async ({ page }) => {
@@ -0,0 +1,105 @@
import { seedOrganisationMembers } from '@documenso/prisma/seed/organisations';
import { seedTeamMember } from '@documenso/prisma/seed/teams';
import { seedUser } from '@documenso/prisma/seed/users';
import { expect, test } from '@playwright/test';
import { OrganisationMemberRole, TeamMemberRole } from '@prisma/client';
import { apiSignin } from '../fixtures/authentication';
import { openDropdownMenu } from '../fixtures/generic';
/**
* Reproduces the "Team has no internal team groups" bug.
*
* When a team has member inheritance turned OFF, organisation admins/managers are
* still inherited into the team as team admins (shown with the "Group" source).
* These members are not part of the team's INTERNAL_TEAM group, so they cannot be
* removed via the team members page - attempting to do so threw a 500 ("Team has no
* internal team groups").
*
* Instead of crashing, the delete dialog must explain why the inherited member can't
* be removed and not offer a confirm button.
*/
test('[TEAMS]: explains why an inherited organisation member cannot be removed', async ({ page }) => {
// Team created with member inheritance OFF.
const { user: owner, organisation, team } = await seedUser({ inheritMembers: false });
const inheritedAdminEmail = `inherited-admin-${team.url}@test.documenso.com`;
// A second organisation admin is inherited into the team as a team admin (source "Group").
await seedOrganisationMembers({
organisationId: organisation.id,
members: [
{
name: 'Inherited Admin',
email: inheritedAdminEmail,
organisationRole: OrganisationMemberRole.ADMIN,
},
],
});
await apiSignin({
page,
email: owner.email,
redirectPath: `/t/${team.url}/settings/members`,
});
const inheritedMemberRow = page.getByRole('row').filter({ hasText: inheritedAdminEmail });
// Sanity check: the member is inherited from a group, not a direct team member.
await expect(inheritedMemberRow).toBeVisible();
await expect(inheritedMemberRow).toContainText('Group');
await openDropdownMenu(page, inheritedMemberRow.getByRole('button').last());
// The action stays enabled - opening it shows a dialog explaining why the inherited
// member can't be removed, rather than triggering the 500.
const removeMenuItem = page.getByRole('menuitem', { name: 'Remove' });
await expect(removeMenuItem).toBeEnabled();
await removeMenuItem.click();
await expect(page.getByText('inherited from a group').first()).toBeVisible();
// No confirm button is offered, so the broken removal can never be triggered.
await expect(page.getByRole('button', { name: 'Remove' })).toHaveCount(0);
});
/**
* Guards against over-disabling the remove action: a direct team member (one that
* belongs to the team's INTERNAL_TEAM group) must still be removable.
*/
test('[TEAMS]: can remove a direct team member', async ({ page }) => {
const { user: owner, team } = await seedUser({ inheritMembers: false });
const directMember = await seedTeamMember({
teamId: team.id,
name: 'Direct Member',
role: TeamMemberRole.MEMBER,
});
await apiSignin({
page,
email: owner.email,
redirectPath: `/t/${team.url}/settings/members`,
});
const directMemberRow = page.getByRole('row').filter({ hasText: directMember.email });
await expect(directMemberRow).toBeVisible();
await openDropdownMenu(page, directMemberRow.getByRole('button').last());
const removeMenuItem = page.getByRole('menuitem', { name: 'Remove' });
// The "Remove" action is enabled for direct members and removing them succeeds.
await expect(removeMenuItem).toBeEnabled();
await removeMenuItem.click();
await page.getByRole('button', { name: 'Remove' }).click();
await expect(page.getByText('You have successfully removed this user from the team.').first()).toBeVisible();
// The member is actually gone after reloading the members list.
await page.reload();
await expect(page.getByRole('row').filter({ hasText: owner.email })).toBeVisible();
await expect(page.getByRole('row').filter({ hasText: directMember.email })).toHaveCount(0);
});
+2
View File
@@ -16,6 +16,8 @@
"@aws-sdk/client-sesv2": "^3.998.0",
"@documenso/lib": "*",
"@documenso/prisma": "*",
"arctic": "^3.7.0",
"hono": "^4.12.14",
"luxon": "^3.7.2",
"react": "^18",
"ts-pattern": "^5.9.0",
@@ -0,0 +1,347 @@
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import type { TCscCredentialsInfoResponse } from './client/types';
/**
* CSC QES V1 algorithm policy.
*
* Single OID-to-algorithm map + single helper that:
* - validates cert state (status, validity window) → `CSC_CERT_INVALID`,
* - validates the credential's key + algorithm against the spec's policy
* table (RSA ≥2048, ECDSA P-256/384/521, SHA-256/384/512) →
* `CSC_ALGORITHM_REFUSED`,
* - resolves a concrete `(signAlgo, hashAlgo)` OID pair for §11.9.
*
* Called at the service-scope OAuth callback (validation boundary) and
* re-called at sign time as a defence-in-depth pre-check. Persisted fields
* (`keyType` / `keyLenBits` / `digestAlgorithm` / `signAlgoOid`) round-trip
* through `CscCredential`.
*/
export type CscKeyType = 'RSA' | 'ECDSA';
export type CscDigest = 'SHA-256' | 'SHA-384' | 'SHA-512';
export type CscEcdsaCurve = 'P-256' | 'P-384' | 'P-521';
export type CscAlgorithmPolicy = {
keyType: CscKeyType;
keyLenBits: number;
digestAlgorithm: CscDigest;
/** OID for `signatures/signHash.signAlgo` + persisted on `CscCredential`. */
signAlgoOid: string;
/** OID for `signatures/signHash.hashAlgo`. */
hashAlgoOid: string;
/** ECDSA named curve (informational; not separately persisted). */
ecdsaCurve?: CscEcdsaCurve;
};
/**
* Default RSA digest when the TSP advertises only hash-agnostic RSA OIDs
* (plain `rsaEncryption` / RSASSA-PSS). SHA-256 matches the CSC §11.9
* sample and is universally TSP-supported.
*/
const DEFAULT_RSA_DIGEST: CscDigest = 'SHA-256';
const HASH_OID_FOR_DIGEST: Record<CscDigest, string> = {
'SHA-256': '2.16.840.1.101.3.4.2.1',
'SHA-384': '2.16.840.1.101.3.4.2.2',
'SHA-512': '2.16.840.1.101.3.4.2.3',
};
/**
* Exposed lookup for the `signatures/signHash.hashAlgo` OID corresponding to a
* resolved {@link CscDigest}. Useful at sign time when the policy's
* `hashAlgoOid` field is not in scope (e.g. when reconstructing a
* `LibpdfSignerAlgo` from a persisted `CscCredential` row).
*/
export const hashOidForDigest = (digest: CscDigest): string => HASH_OID_FOR_DIGEST[digest];
const DIGEST_STRENGTH: Record<CscDigest, number> = {
'SHA-256': 256,
'SHA-384': 384,
'SHA-512': 512,
};
const STRONG_DIGEST_SET = new Set<string>(['SHA-256', 'SHA-384', 'SHA-512']);
type AlgoOidInfo = { family: 'RSA' | 'ECDSA'; boundDigest: CscDigest | 'SHA-1' | 'MD5' | null } | { family: 'DSA' };
/**
* Source-of-truth registry for `key.algo` entries (§11.5). Anything not
* listed is treated as unknown and skipped at policy evaluation.
*/
const KEY_ALGO_OID_REGISTRY: Record<string, AlgoOidInfo> = {
// Hash-agnostic RSA — caller picks the hash via `hashAlgo`.
'1.2.840.113549.1.1.1': { family: 'RSA', boundDigest: null }, // rsaEncryption
'1.2.840.113549.1.1.10': { family: 'RSA', boundDigest: null }, // RSASSA-PSS
// Hash-bound legacy RSA combos.
'1.2.840.113549.1.1.4': { family: 'RSA', boundDigest: 'MD5' }, // md5WithRSAEncryption
'1.2.840.113549.1.1.5': { family: 'RSA', boundDigest: 'SHA-1' }, // sha1WithRSAEncryption
'1.2.840.113549.1.1.11': { family: 'RSA', boundDigest: 'SHA-256' }, // sha256WithRSAEncryption
'1.2.840.113549.1.1.12': { family: 'RSA', boundDigest: 'SHA-384' }, // sha384WithRSAEncryption
'1.2.840.113549.1.1.13': { family: 'RSA', boundDigest: 'SHA-512' }, // sha512WithRSAEncryption
// ECDSA with SHA-x (hash is always bound).
'1.2.840.10045.4.1': { family: 'ECDSA', boundDigest: 'SHA-1' }, // ecdsa-with-SHA1
'1.2.840.10045.4.3.2': { family: 'ECDSA', boundDigest: 'SHA-256' },
'1.2.840.10045.4.3.3': { family: 'ECDSA', boundDigest: 'SHA-384' },
'1.2.840.10045.4.3.4': { family: 'ECDSA', boundDigest: 'SHA-512' },
// DSA — refused outright.
'1.2.840.10040.4.1': { family: 'DSA' },
'1.2.840.10040.4.3': { family: 'DSA' }, // dsa-with-SHA1
};
/**
* ECDSA named-curve OID registry. Policy verdict (allow/refuse) is decided
* by the resolver from the resolved curve name, not encoded here.
*/
const CURVE_OID_REGISTRY: Record<string, CscEcdsaCurve | 'P-192' | 'P-224'> = {
'1.2.840.10045.3.1.7': 'P-256', // secp256r1
'1.3.132.0.34': 'P-384', // secp384r1
'1.3.132.0.35': 'P-521', // secp521r1
'1.2.840.10045.3.1.1': 'P-192', // secp192r1
'1.3.132.0.33': 'P-224', // secp224r1
};
/**
* Validate a CSC credential's cert + key/algorithm against V1 policy and
* resolve the `(signAlgo, hashAlgo)` OID pair used by `signatures/signHash`.
*
* Caller MUST fetch the credential with `certInfo: true` so `cert.validFrom`
* / `cert.validTo` are present.
*
* Throws:
* - `CSC_CERT_INVALID` for cert-state issues (status not `valid`, missing or
* malformed validity dates, current time outside the validity window).
* - `CSC_ALGORITHM_REFUSED` for key/algorithm policy failures (disabled key,
* missing `key.len`, RSA `< 2048`, ECDSA without an allowed curve, DSA, no
* acceptable digest advertised in `key.algo`).
*/
export const resolveCscAlgorithmPolicy = (credentialInfo: TCscCredentialsInfoResponse): CscAlgorithmPolicy => {
assertCertValid(credentialInfo.cert);
if (credentialInfo.key.status !== 'enabled') {
throw new AppError(AppErrorCode.CSC_ALGORITHM_REFUSED, {
message: `CSC credential key status is '${credentialInfo.key.status}'.`,
});
}
if (credentialInfo.key.len === undefined) {
throw new AppError(AppErrorCode.CSC_ALGORITHM_REFUSED, {
message: 'CSC credential omits required key.len (REQUIRED per §11.5).',
});
}
const choice = pickAlgorithmChoice(credentialInfo.key.algo);
if (choice.family === 'RSA') {
if (credentialInfo.key.len < 2048) {
throw new AppError(AppErrorCode.CSC_ALGORITHM_REFUSED, {
message: `CSC RSA credential keyLen ${credentialInfo.key.len} < 2048.`,
});
}
return {
keyType: 'RSA',
keyLenBits: credentialInfo.key.len,
digestAlgorithm: choice.digest,
signAlgoOid: choice.signAlgoOid,
hashAlgoOid: HASH_OID_FOR_DIGEST[choice.digest],
};
}
const curve = resolveEcdsaCurve(credentialInfo.key.curve);
return {
keyType: 'ECDSA',
keyLenBits: credentialInfo.key.len,
digestAlgorithm: choice.digest,
signAlgoOid: choice.signAlgoOid,
hashAlgoOid: HASH_OID_FOR_DIGEST[choice.digest],
ecdsaCurve: curve,
};
};
type AlgorithmChoice = {
family: 'RSA' | 'ECDSA';
signAlgoOid: string;
digest: CscDigest;
};
/**
* Iterate the TSP's advertised `key.algo` OIDs, drop the policy-refused
* entries, and pick the strongest survivor.
*
* Precedence: ECDSA before RSA (smaller signatures, modern); within each
* family, strongest advertised digest first. Hash-agnostic RSA OIDs pair
* with {@link DEFAULT_RSA_DIGEST}.
*/
const pickAlgorithmChoice = (algoOids: readonly string[]): AlgorithmChoice => {
const candidates: AlgorithmChoice[] = [];
for (const oid of algoOids) {
const info = KEY_ALGO_OID_REGISTRY[oid];
if (info === undefined) {
// Unknown OID — another entry in `key.algo` may still be acceptable.
continue;
}
if (info.family === 'DSA') {
continue;
}
if (info.boundDigest === null) {
candidates.push({
family: info.family,
signAlgoOid: oid,
digest: DEFAULT_RSA_DIGEST,
});
continue;
}
if (STRONG_DIGEST_SET.has(info.boundDigest)) {
candidates.push({
family: info.family,
signAlgoOid: oid,
digest: info.boundDigest as CscDigest,
});
}
}
if (candidates.length === 0) {
throw new AppError(AppErrorCode.CSC_ALGORITHM_REFUSED, {
message: `CSC credential advertises no policy-compliant key.algo OIDs (got: ${algoOids.join(', ') || '<empty>'}).`,
});
}
candidates.sort((a, b) => {
if (a.family !== b.family) {
return a.family === 'ECDSA' ? -1 : 1;
}
return DIGEST_STRENGTH[b.digest] - DIGEST_STRENGTH[a.digest];
});
return candidates[0];
};
const resolveEcdsaCurve = (curveOid: string | undefined): CscEcdsaCurve => {
if (curveOid === undefined || curveOid === '') {
throw new AppError(AppErrorCode.CSC_ALGORITHM_REFUSED, {
message: 'CSC ECDSA credential omits required key.curve.',
});
}
const named = CURVE_OID_REGISTRY[curveOid];
if (named === 'P-256' || named === 'P-384' || named === 'P-521') {
return named;
}
const detail = named ? `, named=${named}` : '';
throw new AppError(AppErrorCode.CSC_ALGORITHM_REFUSED, {
message: `CSC ECDSA credential uses refused curve (oid=${curveOid}${detail}).`,
});
};
const assertCertValid = (cert: TCscCredentialsInfoResponse['cert']): void => {
if (cert.status !== undefined && cert.status !== 'valid') {
throw new AppError(AppErrorCode.CSC_CERT_INVALID, {
message: `CSC credential certificate status is '${cert.status}'.`,
});
}
if (!cert.validFrom || !cert.validTo) {
throw new AppError(AppErrorCode.CSC_CERT_INVALID, {
message: 'CSC credential certificate omits validFrom/validTo (malformed).',
});
}
const validFromMs = parseGeneralizedTime(cert.validFrom);
const validToMs = parseGeneralizedTime(cert.validTo);
if (validFromMs === null || validToMs === null) {
throw new AppError(AppErrorCode.CSC_CERT_INVALID, {
message: `CSC credential certificate validity dates are malformed (validFrom=${cert.validFrom}, validTo=${cert.validTo}).`,
});
}
const now = Date.now();
if (now < validFromMs) {
throw new AppError(AppErrorCode.CSC_CERT_INVALID, {
message: `CSC credential certificate is not yet valid (validFrom=${cert.validFrom}).`,
});
}
if (now > validToMs) {
throw new AppError(AppErrorCode.CSC_CERT_INVALID, {
message: `CSC credential certificate has expired (validTo=${cert.validTo}).`,
});
}
};
/**
* Parse an X.509 GeneralizedTime string (`YYYYMMDDHHMMSSZ`) into epoch ms.
* Strict — returns null on any deviation from the §11.5 example format.
*/
const parseGeneralizedTime = (value: string): number | null => {
const matched = /^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})Z$/.exec(value);
if (matched === null) {
return null;
}
const [, y, mo, d, h, mi, s] = matched;
const ms = Date.UTC(Number(y), Number(mo) - 1, Number(d), Number(h), Number(mi), Number(s));
return Number.isNaN(ms) ? null : ms;
};
/**
* Subset of libpdf's `Signer` interface fields derived from a `CscAlgorithmPolicy`.
* Used by `CscCaptureSigner` / `CscFifoSigner` to satisfy libpdf's signer
* contract without re-deriving the mapping at each call site. `keyLenBits`
* is carried through so the capture-signer can size its placeholder output
* appropriately for the chosen key.
*/
export type LibpdfSignerAlgo = {
keyType: 'RSA' | 'EC';
signatureAlgorithm: 'RSASSA-PKCS1-v1_5' | 'RSA-PSS' | 'ECDSA';
digestAlgorithm: CscDigest;
keyLenBits: number;
};
/**
* Translate a `CscAlgorithmPolicy` (CSC §11.5 OIDs) into libpdf's `Signer`
* algorithm tuple. RSASSA-PSS is detected by the `signAlgoOid`; everything
* else maps directly from `keyType` + `digestAlgorithm`.
*/
export const policyToLibpdfSignerAlgo = (policy: CscAlgorithmPolicy): LibpdfSignerAlgo => {
if (policy.keyType === 'ECDSA') {
return {
keyType: 'EC',
signatureAlgorithm: 'ECDSA',
digestAlgorithm: policy.digestAlgorithm,
keyLenBits: policy.keyLenBits,
};
}
// RSA — distinguish PKCS1-v1.5 from PSS by the resolved sign-algo OID.
// RSASSA-PSS OID: '1.2.840.113549.1.1.10'.
const signatureAlgorithm: 'RSASSA-PKCS1-v1_5' | 'RSA-PSS' =
policy.signAlgoOid === '1.2.840.113549.1.1.10' ? 'RSA-PSS' : 'RSASSA-PKCS1-v1_5';
return {
keyType: 'RSA',
signatureAlgorithm,
digestAlgorithm: policy.digestAlgorithm,
keyLenBits: policy.keyLenBits,
};
};
@@ -0,0 +1,122 @@
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
/**
* Length-prefixed X.509 chain for `CscCredential.certCache`. Schema column is
* `Bytes`; this gives a self-describing binary that round-trips without
* base64 inflation. Format: u32 BE cert count, then per-cert u32 BE byte
* length + raw DER bytes.
*
* Encoding inputs come from `cscCredentialsInfo.cert.certificates`, which the
* CSC §11.5 spec defines as an array of base64-encoded DER X.509 certificates
* (leaf-first). The encoder decodes each base64 entry once at persistence
* time; the decoder is the symmetric inverse used at sign time.
*
* Pure functions, no I/O.
*/
const BASE64_REGEX = /^[A-Za-z0-9+/]+={0,2}$/;
/**
* Encode a leaf-first chain of base64-encoded DER certs into the
* length-prefixed binary form persisted on `CscCredential.certCache`.
*
* Throws `INVALID_REQUEST` when the input is empty or any entry is not valid
* base64.
*/
export const encodeCscCertChain = (certs: string[]): Uint8Array => {
if (certs.length === 0) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'CSC certificate chain encoding requires at least one certificate.',
});
}
const derBuffers: Uint8Array[] = [];
let totalDerBytes = 0;
for (const entry of certs) {
if (entry.length === 0 || !BASE64_REGEX.test(entry)) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'CSC certificate chain entry is not valid base64.',
});
}
const der = Buffer.from(entry, 'base64');
if (der.length === 0) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'CSC certificate chain entry decoded to zero bytes.',
});
}
derBuffers.push(der);
totalDerBytes += der.length;
}
// 4 bytes for the count + 4 bytes per-cert length prefix + raw DER bytes.
const totalLength = 4 + derBuffers.length * 4 + totalDerBytes;
const out = new Uint8Array(totalLength);
const view = new DataView(out.buffer, out.byteOffset, out.byteLength);
view.setUint32(0, derBuffers.length, false);
let offset = 4;
for (const der of derBuffers) {
view.setUint32(offset, der.length, false);
offset += 4;
out.set(der, offset);
offset += der.length;
}
return out;
};
/**
* Decode a length-prefixed cert chain back into an array of DER cert byte
* arrays. Inverse of {@link encodeCscCertChain}.
*
* Throws `INVALID_REQUEST` when the buffer is truncated or any per-cert
* length prefix runs off the end of the buffer.
*/
export const decodeCscCertChain = (bytes: Uint8Array): Uint8Array[] => {
if (bytes.byteLength < 4) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'CSC certificate chain buffer is too short to contain a count prefix.',
});
}
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
const count = view.getUint32(0, false);
const result: Uint8Array[] = [];
let offset = 4;
for (let i = 0; i < count; i++) {
if (offset + 4 > bytes.byteLength) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'CSC certificate chain buffer truncated at length prefix.',
});
}
const length = view.getUint32(offset, false);
offset += 4;
if (length === 0 || offset + length > bytes.byteLength) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'CSC certificate chain buffer truncated within certificate body.',
});
}
// Slice copies the underlying bytes so callers can't mutate the source.
result.push(bytes.slice(offset, offset + length));
offset += length;
}
if (offset !== bytes.byteLength) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'CSC certificate chain buffer has trailing bytes after declared chain end.',
});
}
return result;
};
@@ -0,0 +1,51 @@
import { symmetricDecrypt, symmetricEncrypt } from '@documenso/lib/universal/crypto';
import { requireEnv } from '@documenso/lib/utils/env';
import { bytesToHex, hexToBytes } from '@noble/ciphers/utils';
/**
* Bytes-based wrappers around {@link symmetricEncrypt} / {@link symmetricDecrypt}
* for the two CSC secrets stored on Prisma `Bytes` columns:
*
* - `CscCredential.serviceTokenCiphertext` — service-scope OAuth access token.
* - `CscSession.encryptedSad` — credential-scope SAD.
*
* Both use the primary `DOCUMENSO_ENCRYPTION_KEY` (same key family as 2FA
* secrets, OIDC client secrets, DKIM private keys). The underlying cipher
* returns hex; we round-trip through `bytesToHex` / `hexToBytes` so the
* persisted bytes are the raw XChaCha20-Poly1305 ciphertext (nonce + tag +
* payload), not a hex-string-as-bytes inflation.
*/
/**
* Encrypt a CSC plaintext secret (service token or SAD) for persistence.
* Throws `MISSING_ENV_VAR` on missing encryption key — encryption can't
* otherwise fail.
*/
export const encryptCscToken = (plaintext: string): Uint8Array => {
const key = requireEnv('NEXT_PRIVATE_ENCRYPTION_KEY');
const hex = symmetricEncrypt({ key, data: plaintext });
return hexToBytes(hex);
};
/**
* Decrypt a CSC ciphertext back to its UTF-8 plaintext. Returns `null` on
* any cipher-level failure (key rotation, payload tamper, row corruption)
* so the caller can map to a domain-appropriate AppError — typically
* re-auth for service tokens, `CSC_SAD_EXPIRED_PRE_SIGN` for SADs.
*
* A missing key throws (config error, must surface loudly) and is *not*
* folded into the null return.
*/
export const decryptCscToken = (ciphertext: Uint8Array): string | null => {
const key = requireEnv('NEXT_PRIVATE_ENCRYPTION_KEY');
try {
const buf = symmetricDecrypt({ key, data: bytesToHex(ciphertext) });
return Buffer.from(buf).toString('utf-8');
} catch {
return null;
}
};
@@ -0,0 +1,122 @@
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { cscJsonPost, joinCscUrl } from './http';
import {
type TCscCredentialsInfoRequest,
type TCscCredentialsInfoResponse,
type TCscCredentialsListRequest,
type TCscCredentialsListResponse,
ZCscCredentialsInfoResponseSchema,
ZCscCredentialsListResponseSchema,
} from './types';
type CscCredentialsListOptions = TCscCredentialsListRequest & {
baseUrl: string;
/** Service-scope bearer token (CSC §11.4 + §11.9). */
accessToken: string;
signal?: AbortSignal;
};
/**
* `credentials/list` (§11.4) — list the credentialIDs the bearer token's user
* owns at the TSP.
*
* Throws `CSC_CREDENTIAL_LIST_EMPTY` when the TSP returns a successful
* response with zero credentials — the recipient needs to enrol with the TSP
* before they can sign. Other failures throw `CSC_REQUEST_FAILED`.
*
* `userID` MUST be omitted when the service authorization is user-specific
* (true for OAuth `service` scope, which is V1's only flow). The spec rejects
* the call with `invalid_request` if both are present.
*/
export const cscCredentialsList = async (opts: CscCredentialsListOptions): Promise<TCscCredentialsListResponse> => {
const { baseUrl, accessToken, signal, userID, maxResults, pageToken, clientData } = opts;
const body: Record<string, unknown> = {};
if (userID !== undefined) {
body.userID = userID;
}
if (maxResults !== undefined) {
body.maxResults = maxResults;
}
if (pageToken !== undefined) {
body.pageToken = pageToken;
}
if (clientData !== undefined) {
body.clientData = clientData;
}
const response = await cscJsonPost(
{
url: joinCscUrl({ baseUrl, path: 'credentials/list' }),
body,
accessToken,
signal,
},
ZCscCredentialsListResponseSchema,
);
if (response.credentialIDs.length === 0) {
throw new AppError(AppErrorCode.CSC_CREDENTIAL_LIST_EMPTY, {
message:
'CSC provider returned no credentials for the authenticated user. Recipient must enrol with the TSP before signing.',
});
}
return response;
};
type CscCredentialsInfoOptions = TCscCredentialsInfoRequest & {
baseUrl: string;
/** Service-scope bearer token. */
accessToken: string;
signal?: AbortSignal;
};
/**
* `credentials/info` (§11.5) — fetch credential metadata: key algorithm tuple,
* X.509 certificate chain, authorization mode, multisign capacity.
*
* Returns the parsed response verbatim. Cert validity, algorithm policy, and
* SCAL semantics are enforced by `csc/algorithm-resolver.ts` — that lives
* outside the client because it's domain logic, not transport.
*/
export const cscCredentialsInfo = async (opts: CscCredentialsInfoOptions): Promise<TCscCredentialsInfoResponse> => {
const { baseUrl, accessToken, signal, credentialID, certificates, certInfo, authInfo, lang, clientData } = opts;
const body: Record<string, unknown> = { credentialID };
if (certificates !== undefined) {
body.certificates = certificates;
}
if (certInfo !== undefined) {
body.certInfo = certInfo;
}
if (authInfo !== undefined) {
body.authInfo = authInfo;
}
if (lang !== undefined) {
body.lang = lang;
}
if (clientData !== undefined) {
body.clientData = clientData;
}
return await cscJsonPost(
{
url: joinCscUrl({ baseUrl, path: 'credentials/info' }),
body,
accessToken,
signal,
},
ZCscCredentialsInfoResponseSchema,
);
};
@@ -0,0 +1,170 @@
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import type { z } from 'zod';
import { ZCscErrorResponseSchema } from './types';
const LEADING_SLASHES_REGEX = /^\/+/;
const TRAILING_SLASHES_REGEX = /\/+$/;
/**
* Low-level fetch wrapper for the JSON-bodied CSC API methods (§7.1 mandates
* `Content-Type: application/json` for all API requests).
*
* OAuth 2.0 endpoints (`oauth2/token`, `oauth2/revoke`) use
* `application/x-www-form-urlencoded` per RFC 6749 and are handled by the
* `arctic` library — see `oauth.ts` in this directory.
*
* Normalises CSC error responses (§10.1: `{ error, error_description }`)
* into {@link AppError}s carrying the upstream HTTP status in
* {@link AppError.statusCode}, so callers can discriminate without
* re-parsing the body.
*/
type JoinUrlInput = {
baseUrl: string;
path: string;
};
/**
* Join a CSC base URL with a path segment. Strips trailing/leading slashes so
* `joinCscUrl({ baseUrl: 'https://x/csc/v1/', path: '/credentials/list' })`
* yields `https://x/csc/v1/credentials/list`.
*/
export const joinCscUrl = ({ baseUrl, path }: JoinUrlInput): string => {
const cleanBaseUrl = baseUrl.replace(TRAILING_SLASHES_REGEX, ''); // Strip trailing slashes from base URL.
const cleanPath = path.replace(LEADING_SLASHES_REGEX, ''); // Strip leading slashes from path.
const url = new URL(cleanPath, `${cleanBaseUrl}/`);
return url.toString();
};
type CscRequestErrorOptions = {
url: string;
status: number;
cscError?: { error: string; error_description?: string };
cause?: unknown;
errorCode?: string;
};
const buildCscRequestError = ({
url,
status,
cscError,
cause,
errorCode = AppErrorCode.CSC_REQUEST_FAILED,
}: CscRequestErrorOptions): AppError => {
const causeMessage = cause instanceof Error ? cause.message : undefined;
const parts: string[] = [`CSC request to ${url} failed (HTTP ${status})`];
if (cscError) {
parts.push(cscError.error_description ? `${cscError.error}: ${cscError.error_description}` : cscError.error);
}
if (causeMessage) {
parts.push(causeMessage);
}
return new AppError(errorCode, {
message: parts.join(' — '),
statusCode: status,
});
};
/**
* Best-effort parse of a CSC error body. Returns `undefined` on non-JSON or
* schema mismatch so the caller still surfaces the HTTP status without
* masking it.
*/
const readCscErrorBody = async (
response: Response,
): Promise<{ error: string; error_description?: string } | undefined> => {
try {
const json = await response.json();
const parsed = ZCscErrorResponseSchema.safeParse(json);
return parsed.success ? parsed.data : undefined;
} catch {
return undefined;
}
};
type CscJsonPostOptions = {
/** Fully-qualified endpoint URL (use {@link joinCscUrl} to build it). */
url: string;
/** Decoded JSON body; serialised via `JSON.stringify`. */
body: Record<string, unknown>;
/** Bearer access token. Omit for unauthenticated calls (e.g. `info`). */
accessToken?: string;
/** Override the AppError code thrown on failure. Defaults to `CSC_REQUEST_FAILED`. */
errorCode?: string;
/**
* Optional `AbortSignal` so callers can enforce their own deadlines
* (e.g. the 15s sign-time sync timeout).
*/
signal?: AbortSignal;
};
/**
* POST a JSON body to a CSC API endpoint and parse the response against the
* supplied Zod schema.
*
* Throws {@link AppError} on:
* - network/transport error (fetch threw)
* - non-2xx HTTP response (with CSC error body folded into the message)
* - malformed JSON response
* - schema validation failure
*/
export const cscJsonPost = async <T>(opts: CscJsonPostOptions, responseSchema: z.ZodSchema<T>): Promise<T> => {
const { url, body, accessToken, errorCode, signal } = opts;
let response: Response;
try {
response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}),
},
body: JSON.stringify(body),
signal,
});
} catch (cause) {
throw buildCscRequestError({ url, status: 0, cause, errorCode });
}
if (!response.ok) {
const cscError = await readCscErrorBody(response);
throw buildCscRequestError({
url,
status: response.status,
cscError,
errorCode,
});
}
let json: unknown;
try {
json = await response.json();
} catch (cause) {
throw buildCscRequestError({ url, status: response.status, cause, errorCode });
}
const parsed = responseSchema.safeParse(json);
if (!parsed.success) {
throw buildCscRequestError({
url,
status: response.status,
cause: parsed.error,
errorCode,
});
}
return parsed.data;
};
@@ -0,0 +1,32 @@
/**
* CSC v1.0.4.0 HTTP client. Stateless function wrappers — one per endpoint,
* grouped by spec section. Bring your own base URL(s) and bearer token.
*
* Endpoint coverage (V1 scope):
* - §11.1 info → {@link cscInfo}
* - §11.4 credentials/list → {@link cscCredentialsList}
* - §11.5 credentials/info → {@link cscCredentialsInfo}
* - §11.9 signatures/signHash → {@link cscSignHash}
* - §11.10 signatures/timestamp → {@link cscTimestamp}
* - §8.3.2 oauth2/authorize → {@link buildCscServiceScopeAuthorizeUrl},
* {@link buildCscCredentialScopeAuthorizeUrl}
* - §8.3.3 oauth2/token → {@link exchangeCscAuthorizationCode},
* {@link refreshCscServiceToken}
* - §8.3.4 oauth2/revoke → {@link revokeCscToken}
*
* Out of scope for V1 (intentionally excluded; we use OAuth + single-sig):
* - §11.2 auth/login (HTTP Basic)
* - §11.3 auth/revoke (HTTP Basic)
* - §11.6 credentials/authorize (alternative to OAuth credential scope)
* - §11.7 credentials/extendTransaction
* - §11.8 credentials/sendOTP
*
* OAuth is delegated to `arctic` (same library `packages/auth/` uses).
*/
export * from './credentials';
export * from './http';
export * from './info';
export * from './oauth';
export * from './signatures';
export * from './types';
@@ -0,0 +1,42 @@
import { AppErrorCode } from '@documenso/lib/errors/app-error';
import { cscJsonPost, joinCscUrl } from './http';
import { type TCscInfoRequest, type TCscInfoResponse, ZCscInfoResponseSchema } from './types';
type CscInfoOptions = TCscInfoRequest & {
/**
* Base URI of the CSC service (e.g. `https://service.example.org/csc/v1`).
* Per §7.2, `info` is mounted relative to the service base URI; the OAuth
* base URI returned in `oauth2` is discovered from this call.
*/
baseUrl: string;
signal?: AbortSignal;
};
/**
* `info` (§11.1) — discovery method every CSC-conformant TSP MUST implement.
*
* Used at startup to:
*
* 1. Learn the OAuth 2.0 base URI (`oauth2`) for subsequent token / revoke
* calls. Per §11.1, this MAY differ from the API base URI.
* 2. Enumerate supported methods (`methods`) so the caller can fail fast
* when a required endpoint is absent.
* 3. Surface `signatures/timestamp` capability for the B-LTA seal step.
*
* Unauthenticated — `info` requires no bearer token. Failures throw
* `CSC_PROVIDER_INFO_FAILED` per the spec's startup-discovery error code.
*/
export const cscInfo = async (opts: CscInfoOptions): Promise<TCscInfoResponse> => {
const { baseUrl, lang, signal } = opts;
return await cscJsonPost(
{
url: joinCscUrl({ baseUrl, path: 'info' }),
body: lang ? { lang } : {},
errorCode: AppErrorCode.CSC_PROVIDER_INFO_FAILED,
signal,
},
ZCscInfoResponseSchema,
);
};
@@ -0,0 +1,321 @@
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import {
ArcticFetchError,
CodeChallengeMethod,
generateCodeVerifier,
generateState,
OAuth2Client,
OAuth2RequestError,
type OAuth2Tokens,
UnexpectedErrorResponseBodyError,
UnexpectedResponseError,
} from 'arctic';
import { joinCscUrl } from './http';
/**
* OAuth 2.0 surface for the CSC v1.0.4.0 protocol (§8.3.2 authorize,
* §8.3.3 token, §8.3.4 revoke).
*
* Backed by `arctic` — the same library `packages/auth/` uses for sign-in
* OAuth — so PKCE + state generation, token parsing, and revocation share a
* proven implementation. CSC-specific extension parameters (`credentialID`,
* `numSignatures`, `hash`, `description`, `account_token`, `clientData`,
* `lang` — §8.3.2) layer on top of the returned `URL` via
* `searchParams.set()`.
*
* Non-standard CSC bits arctic doesn't model directly:
* - `token_type === 'SAD'` for credential-scope responses (§8.3.3). Read from
* `tokens.tokenType()` which sources from raw `data`.
* - SAD is single-use and short-lived per spec; no refresh_token is issued
* for the credential scope. Callers SHOULD NOT call `refreshAccessToken`
* with a SAD.
*
* Re-exports `generateState` and `generateCodeVerifier` for callers that
* persist these in the OAuth-flow cookie.
*/
export { generateCodeVerifier, generateState };
// ─── Client construction ─────────────────────────────────────────────────────
type CreateCscOAuthClientOptions = {
clientId: string;
clientSecret: string;
redirectUri: string;
};
/**
* Construct an `OAuth2Client` bound to the CSC TSP's OAuth registration. The
* three values come from the env (`NEXT_PRIVATE_SIGNING_CSC_OAUTH_*`).
* Stateless — instantiate per request or cache at the transport singleton
* level; arctic's client carries no per-call state.
*/
export const createCscOAuthClient = ({
clientId,
clientSecret,
redirectUri,
}: CreateCscOAuthClientOptions): OAuth2Client => {
return new OAuth2Client(clientId, clientSecret, redirectUri);
};
// ─── Authorize URL builders (§8.3.2) ─────────────────────────────────────────
type AuthorizeUrlBaseOptions = {
client: OAuth2Client;
/**
* The TSP's OAuth base URI as returned by `info.oauth2` (§11.1). The
* `oauth2/authorize` path is joined on; per §8.3.2 NOTE 1 this can live on
* a different host from the API base URI.
*/
oauthBaseUrl: string;
/** Opaque CSRF token; see {@link generateState}. Caller persists it. */
state: string;
/** PKCE verifier; see {@link generateCodeVerifier}. Caller persists it. */
codeVerifier: string;
/** Preferred response language (§11.1 `lang` parameter). */
lang?: string;
/**
* Arbitrary application-defined string echoed back at callback. WARNING per
* §8.3.2: this is forwarded verbatim to the TSP; never put secrets here.
*/
clientData?: string;
};
const applyCscAuthorizeExtras = (url: URL, opts: { lang?: string; clientData?: string }): URL => {
if (opts.lang) {
url.searchParams.set('lang', opts.lang);
}
if (opts.clientData) {
url.searchParams.set('clientData', opts.clientData);
}
return url;
};
/**
* Build the `oauth2/authorize` URL for the **service** scope. Recipient
* follows this URL to authenticate at the TSP and grant access to list
* credentials + fetch credential info.
*/
export const buildCscServiceScopeAuthorizeUrl = (opts: AuthorizeUrlBaseOptions): URL => {
const { client, oauthBaseUrl, state, codeVerifier, lang, clientData } = opts;
const url = client.createAuthorizationURLWithPKCE(
joinCscUrl({ baseUrl: oauthBaseUrl, path: 'oauth2/authorize' }),
state,
CodeChallengeMethod.S256,
codeVerifier,
['service'],
);
return applyCscAuthorizeExtras(url, { lang, clientData });
};
type CredentialScopeAuthorizeOptions = AuthorizeUrlBaseOptions & {
/** Target credential (§8.3.2 — REQUIRED for credential scope). */
credentialId: string;
/** Number of signatures this SAD will authorise (§8.3.2 — REQUIRED). */
numSignatures: number;
/**
* Standard-base64-encoded hash values the SAD will be bound to. REQUIRED for
* SCAL2 credentials (§8.3.2). The builder converts each value to base64url
* before joining with `,` per the spec — §8.3.2 mandates base64url for the
* `hash` URL parameter, but the rest of the codebase (and the
* `signatures/signHash` JSON body per §11.9) uses standard base64. Callers
* pass what `Buffer.from(...).toString('base64')` produces.
*/
hashes: string[];
/** Human-readable transaction description shown on the TSP's SCA page. */
description?: string;
/** Optional restricted-access token (JWT) some TSPs require (§8.3.2). */
accountToken?: string;
};
/**
* Convert a standard-base64 string to base64url (RFC 4648 §5). The CSC §8.3.2
* `hash` URL parameter requires base64url; TSPs reject standard base64 even
* after percent-decoding because `+`, `/`, and `=` are invalid base64url
* characters. JSON-body fields (§11.9 `signatures/signHash`) keep standard
* base64.
*/
const toBase64Url = (standardBase64: string): string =>
standardBase64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
/**
* Build the `oauth2/authorize` URL for the **credential** scope. The TSP
* binds the issued SAD to `hashes` so it can only sign those exact digests.
*
* Hash ordering in the SAD is independent of the order passed to
* `signatures/signHash` (§8.3.2) — the TSP matches by hash value, not
* position.
*/
export const buildCscCredentialScopeAuthorizeUrl = (opts: CredentialScopeAuthorizeOptions): URL => {
const {
client,
oauthBaseUrl,
state,
codeVerifier,
credentialId,
numSignatures,
hashes,
description,
accountToken,
lang,
clientData,
} = opts;
const url = client.createAuthorizationURLWithPKCE(
joinCscUrl({ baseUrl: oauthBaseUrl, path: 'oauth2/authorize' }),
state,
CodeChallengeMethod.S256,
codeVerifier,
['credential'],
);
url.searchParams.set('credentialID', credentialId);
url.searchParams.set('numSignatures', String(numSignatures));
url.searchParams.set('hash', hashes.map(toBase64Url).join(','));
if (description) {
url.searchParams.set('description', description);
}
if (accountToken) {
url.searchParams.set('account_token', accountToken);
}
return applyCscAuthorizeExtras(url, { lang, clientData });
};
// ─── Token exchange (§8.3.3) ─────────────────────────────────────────────────
type ExchangeCodeOptions = {
client: OAuth2Client;
/** OAuth base URI from `info.oauth2`. `oauth2/token` is joined on. */
oauthBaseUrl: string;
/** Authorization code from the callback's `code` query param. */
code: string;
/** Same PKCE verifier passed to the authorize URL builder. */
codeVerifier: string;
signal?: AbortSignal;
};
/**
* Exchange an authorization code for an access token. Used for both scopes;
* the response shape differs only in `token_type`:
*
* - service scope: `token_type === 'Bearer'`, optional `refresh_token`.
* - credential scope: `token_type === 'SAD'`, single-use, no refresh_token.
*
* Inspect `tokens.tokenType()` (or `tokens.data` for raw access) to
* discriminate.
*/
export const exchangeCscAuthorizationCode = async (opts: ExchangeCodeOptions): Promise<OAuth2Tokens> => {
const { client, oauthBaseUrl, code, codeVerifier } = opts;
try {
return await client.validateAuthorizationCode(
joinCscUrl({ baseUrl: oauthBaseUrl, path: 'oauth2/token' }),
code,
codeVerifier,
);
} catch (err) {
throw mapArcticError(err, 'oauth2/token');
}
};
type RefreshServiceTokenOptions = {
client: OAuth2Client;
oauthBaseUrl: string;
/** Service-scope refresh token from a prior token exchange. */
refreshToken: string;
signal?: AbortSignal;
};
/**
* Refresh a service-scope access token. Credential-scope SADs are NOT
* refreshable per §8.3.3 — only service scope issues refresh tokens.
*
* Scopes passed as `['service']` to keep the refresh narrow; the TSP may
* ignore the scope parameter on refresh per RFC 6749 §6.
*/
export const refreshCscServiceToken = async (opts: RefreshServiceTokenOptions): Promise<OAuth2Tokens> => {
const { client, oauthBaseUrl, refreshToken } = opts;
try {
return await client.refreshAccessToken(joinCscUrl({ baseUrl: oauthBaseUrl, path: 'oauth2/token' }), refreshToken, [
'service',
]);
} catch (err) {
throw mapArcticError(err, 'oauth2/token');
}
};
// ─── Revoke (§8.3.4) ─────────────────────────────────────────────────────────
type RevokeTokenOptions = {
client: OAuth2Client;
oauthBaseUrl: string;
/** Access token or refresh token to revoke. */
token: string;
signal?: AbortSignal;
};
/**
* Revoke a CSC OAuth token. Per §8.3.4, revoking a refresh token also
* invalidates every access token derived from the same grant; revoking an
* access token only invalidates that access token.
*
* `204 No Content` on success; arctic resolves the promise. Failures
* surface as `CSC_REQUEST_FAILED` via {@link mapArcticError}.
*/
export const revokeCscToken = async (opts: RevokeTokenOptions): Promise<void> => {
const { client, oauthBaseUrl, token } = opts;
try {
await client.revokeToken(joinCscUrl({ baseUrl: oauthBaseUrl, path: 'oauth2/revoke' }), token);
} catch (err) {
throw mapArcticError(err, 'oauth2/revoke');
}
};
// ─── Error normalisation ────────────────────────────────────────────────────
/**
* Translate arctic's typed exception hierarchy into AppErrors consistent with
* the rest of the CSC client (see http.ts). Preserves the HTTP status when
* arctic surfaces it.
*/
const mapArcticError = (err: unknown, endpoint: string): AppError => {
if (err instanceof OAuth2RequestError) {
return new AppError(AppErrorCode.CSC_REQUEST_FAILED, {
message: `CSC ${endpoint} rejected: ${err.code}${err.description ? `${err.description}` : ''}`,
});
}
if (err instanceof ArcticFetchError) {
return new AppError(AppErrorCode.CSC_REQUEST_FAILED, {
message: `CSC ${endpoint} fetch failed: ${err.message}`,
});
}
if (err instanceof UnexpectedResponseError) {
return new AppError(AppErrorCode.CSC_REQUEST_FAILED, {
message: `CSC ${endpoint} returned unexpected HTTP ${err.status}`,
statusCode: err.status,
});
}
if (err instanceof UnexpectedErrorResponseBodyError) {
return new AppError(AppErrorCode.CSC_REQUEST_FAILED, {
message: `CSC ${endpoint} returned HTTP ${err.status} with unparseable body`,
statusCode: err.status,
});
}
return new AppError(AppErrorCode.CSC_REQUEST_FAILED, {
message: `CSC ${endpoint} failed: ${err instanceof Error ? err.message : String(err)}`,
});
};
@@ -0,0 +1,111 @@
import { cscJsonPost, joinCscUrl } from './http';
import {
type TCscSignHashRequest,
type TCscSignHashResponse,
type TCscTimestampRequest,
type TCscTimestampResponse,
ZCscSignHashResponseSchema,
ZCscTimestampResponseSchema,
} from './types';
type CscSignHashOptions = TCscSignHashRequest & {
baseUrl: string;
/** Service-scope bearer token. The SAD (in the body) is the credential-scope grant. */
accessToken: string;
signal?: AbortSignal;
};
/**
* `signatures/signHash` (§11.9) — submit one or more pre-computed hashes for
* the TSP to sign with the credential identified by `credentialID`.
*
* Authorisation is two-layered:
* - The service-scope bearer token authenticates the API call itself.
* - The credential-scope SAD (in the JSON body) authorises the specific
* hashes — the TSP rejects with `invalid_request` ("Hash is not authorized
* by the SAD") if any hash in the array wasn't bound at SAD issuance.
*
* The returned `signatures` array is position-ordered with `hash` per §11.9.
* Callers SHALL preserve order when mapping responses back to PDF embed
* slots (the fifoSigner relies on this).
*/
export const cscSignHash = async (opts: CscSignHashOptions): Promise<TCscSignHashResponse> => {
const { baseUrl, accessToken, signal, credentialID, SAD, hash, hashAlgo, signAlgo, signAlgoParams, clientData } =
opts;
const body: Record<string, unknown> = {
credentialID,
SAD,
hash,
signAlgo,
};
if (hashAlgo !== undefined) {
body.hashAlgo = hashAlgo;
}
if (signAlgoParams !== undefined) {
body.signAlgoParams = signAlgoParams;
}
if (clientData !== undefined) {
body.clientData = clientData;
}
return await cscJsonPost(
{
url: joinCscUrl({ baseUrl, path: 'signatures/signHash' }),
body,
accessToken,
signal,
},
ZCscSignHashResponseSchema,
);
};
type CscTimestampOptions = TCscTimestampRequest & {
baseUrl: string;
/**
* Service-scope bearer token. Per §11.10 the timestamp endpoint may or may
* not require auth depending on TSP policy; the spec is silent. We send the
* token unconditionally because all known TSPs gate this endpoint.
*/
accessToken: string;
signal?: AbortSignal;
};
/**
* `signatures/timestamp` (§11.10) — request an RFC 3161 / RFC 5816 time-stamp
* token for a pre-computed hash. Driven by {@link CscTspTimestampAuthority}
* at sign time, when {@link resolveCscSignTimeTsa} selects the TSP source
* (TSP advertises `signatures/timestamp` in `info.methods`). The bearer is
* the current recipient's own service-scope token. Seal-time archival
* timestamps do not go through this endpoint — they use the env-configured
* RFC 3161 TSA directly.
*
* If `nonce` is supplied, the TSP MUST round-trip it in the token — we leave
* verification to LibPDF / our TSA helper, not this client.
*/
export const cscTimestamp = async (opts: CscTimestampOptions): Promise<TCscTimestampResponse> => {
const { baseUrl, accessToken, signal, hash, hashAlgo, nonce, clientData } = opts;
const body: Record<string, unknown> = { hash, hashAlgo };
if (nonce !== undefined) {
body.nonce = nonce;
}
if (clientData !== undefined) {
body.clientData = clientData;
}
return await cscJsonPost(
{
url: joinCscUrl({ baseUrl, path: 'signatures/timestamp' }),
body,
accessToken,
signal,
},
ZCscTimestampResponseSchema,
);
};
@@ -0,0 +1,179 @@
import { z } from 'zod';
/**
* Zod schemas + types for every CSC v1.0.4.0 request/response shape the V1
* client touches. Field names mirror the spec exactly. Unknown fields are
* silently dropped (Zod default `.strip()`); we don't `.passthrough()` to
* keep parsed objects narrow.
*
* Out-of-scope endpoints (`auth/login`, `auth/revoke`, `credentials/authorize`,
* `credentials/extendTransaction`, `credentials/sendOTP`) intentionally have
* no schemas here — V1 uses OAuth + sequential single-signature flows only.
*/
// ─── §10.1 common error envelope ─────────────────────────────────────────────
export const ZCscErrorResponseSchema = z.object({
error: z.string(),
error_description: z.string().optional(),
});
export type TCscErrorResponse = z.infer<typeof ZCscErrorResponseSchema>;
// ─── §11.1 info ──────────────────────────────────────────────────────────────
export const ZCscInfoRequestSchema = z.object({
lang: z.string().optional(),
});
export type TCscInfoRequest = z.infer<typeof ZCscInfoRequestSchema>;
export const ZCscInfoResponseSchema = z.object({
specs: z.string(),
name: z.string(),
logo: z.string(),
region: z.string(),
lang: z.string(),
description: z.string(),
authType: z.array(z.string()),
// REQUIRED Conditional — present when authType includes `oauth2code` /
// `oauth2client`, or when any credential supports `oauth2code` authMode.
// We always need it for V1, but keeping the schema permissive matches the
// spec; absence is detected at the call site.
oauth2: z.string().optional(),
methods: z.array(z.string()),
});
export type TCscInfoResponse = z.infer<typeof ZCscInfoResponseSchema>;
// ─── §11.4 credentials/list ──────────────────────────────────────────────────
export const ZCscCredentialsListRequestSchema = z.object({
// OAuth2 user-specific service auth → userID MUST be omitted (§11.4 NOTE 1).
userID: z.string().optional(),
maxResults: z.number().int().positive().optional(),
pageToken: z.string().optional(),
clientData: z.string().optional(),
});
export type TCscCredentialsListRequest = z.infer<typeof ZCscCredentialsListRequestSchema>;
export const ZCscCredentialsListResponseSchema = z.object({
credentialIDs: z.array(z.string()),
nextPageToken: z.string().optional(),
});
export type TCscCredentialsListResponse = z.infer<typeof ZCscCredentialsListResponseSchema>;
// ─── §11.5 credentials/info ──────────────────────────────────────────────────
export const ZCscCredentialsInfoRequestSchema = z.object({
credentialID: z.string(),
certificates: z.enum(['none', 'single', 'chain']).optional(),
certInfo: z.boolean().optional(),
authInfo: z.boolean().optional(),
lang: z.string().optional(),
clientData: z.string().optional(),
});
export type TCscCredentialsInfoRequest = z.infer<typeof ZCscCredentialsInfoRequestSchema>;
export const ZCscCredentialsInfoKeySchema = z.object({
status: z.enum(['enabled', 'disabled']),
algo: z.array(z.string()),
// REQUIRED per §11.5 but kept optional here so the algorithm-resolver can
// surface absence as a typed `CSC_ALGORITHM_REFUSED` (matching the spec's
// policy table) instead of a generic transport schema failure.
len: z.number().int().positive().optional(),
// REQUIRED Conditional for ECDSA per §11.5; absence handled by the resolver.
curve: z.string().optional(),
});
export const ZCscCredentialsInfoCertSchema = z.object({
status: z.enum(['valid', 'expired', 'revoked', 'suspended']).optional(),
certificates: z.array(z.string()).optional(),
issuerDN: z.string().optional(),
serialNumber: z.string().optional(),
subjectDN: z.string().optional(),
validFrom: z.string().optional(),
validTo: z.string().optional(),
});
export const ZCscCredentialsInfoPinSchema = z.object({
presence: z.enum(['true', 'false', 'optional']),
format: z.enum(['A', 'N']).optional(),
label: z.string().optional(),
description: z.string().optional(),
});
export const ZCscCredentialsInfoOtpSchema = z.object({
presence: z.enum(['true', 'false', 'optional']),
type: z.enum(['offline', 'online']).optional(),
format: z.enum(['A', 'N']).optional(),
label: z.string().optional(),
description: z.string().optional(),
ID: z.string().optional(),
provider: z.string().optional(),
});
export const ZCscCredentialsInfoResponseSchema = z.object({
description: z.string().optional(),
key: ZCscCredentialsInfoKeySchema,
cert: ZCscCredentialsInfoCertSchema,
authMode: z.enum(['implicit', 'explicit', 'oauth2code']),
SCAL: z.enum(['1', '2']).optional(),
PIN: ZCscCredentialsInfoPinSchema.optional(),
OTP: ZCscCredentialsInfoOtpSchema.optional(),
multisign: z.number().int().min(1),
lang: z.string().optional(),
});
export type TCscCredentialsInfoResponse = z.infer<typeof ZCscCredentialsInfoResponseSchema>;
// ─── §11.9 signatures/signHash ───────────────────────────────────────────────
export const ZCscSignHashRequestSchema = z.object({
credentialID: z.string(),
SAD: z.string(),
// Base64-encoded raw message digests.
hash: z.array(z.string()).nonempty(),
// REQUIRED Conditional — OID of the hash algorithm. Omit only when implied
// by signAlgo (per §11.9). The caller decides.
hashAlgo: z.string().optional(),
signAlgo: z.string(),
// REQUIRED Conditional for algorithms like RSASSA-PSS.
signAlgoParams: z.string().optional(),
clientData: z.string().optional(),
});
export type TCscSignHashRequest = z.infer<typeof ZCscSignHashRequestSchema>;
export const ZCscSignHashResponseSchema = z.object({
// Position-ordered Base64-encoded signed hashes matching the input order.
signatures: z.array(z.string()).nonempty(),
});
export type TCscSignHashResponse = z.infer<typeof ZCscSignHashResponseSchema>;
// ─── §11.10 signatures/timestamp ─────────────────────────────────────────────
export const ZCscTimestampRequestSchema = z.object({
hash: z.string(),
hashAlgo: z.string(),
// Hex-encoded random; SHALL round-trip in the timestamp token when supplied.
nonce: z.string().optional(),
clientData: z.string().optional(),
});
export type TCscTimestampRequest = z.infer<typeof ZCscTimestampRequestSchema>;
export const ZCscTimestampResponseSchema = z.object({
// Base64-encoded RFC 3161 (with RFC 5816 update) time-stamp token.
timestamp: z.string(),
});
export type TCscTimestampResponse = z.infer<typeof ZCscTimestampResponseSchema>;
// OAuth 2.0 token + revoke shapes are handled by the `arctic` library — see
// `oauth.ts` in this directory. Arctic exposes `OAuth2Tokens` (with `.data`
// available for non-standard CSC fields like `token_type === 'SAD'`).
@@ -0,0 +1,120 @@
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import type { Context } from 'hono';
import { deleteCookie, getSignedCookie, setSignedCookie } from 'hono/cookie';
import { parseSigned, serialize } from 'hono/utils/cookie';
import { z } from 'zod';
import { CSC_BLOCKING_ERROR_COOKIE_NAME, cscCookieBaseOptions, getCscCookieSecret } from './shared';
/**
* `csc_blocking_error` — one-shot surface for service-scope OAuth callback
* failures the recipient can't self-resolve (empty credential list, invalid
* cert, refused algorithm, etc.). The `/sign/{token}` loader reads + clears
* it on next visit so no error state rides on URL query params.
*/
const CSC_BLOCKING_ERROR_MAX_AGE_SECONDS = 60 * 10; // 10 minutes — matches the other short-lived CSC cookies.
export const ZCscBlockingErrorPayloadSchema = z.object({
/** `AppErrorCode` value, e.g. `'CSC_CREDENTIAL_LIST_EMPTY'`. */
code: z.string().min(1),
/** Recipient token from `/sign/{token}`; loader scopes the error to its recipient. */
recipientToken: z.string().min(1),
});
export type TCscBlockingErrorPayload = z.infer<typeof ZCscBlockingErrorPayloadSchema>;
type SetCscBlockingErrorCookieOptions = {
c: Context;
payload: TCscBlockingErrorPayload;
};
export const setCscBlockingErrorCookie = async (options: SetCscBlockingErrorCookieOptions): Promise<void> => {
const { c, payload } = options;
await setSignedCookie(c, CSC_BLOCKING_ERROR_COOKIE_NAME, JSON.stringify(payload), getCscCookieSecret(), {
...cscCookieBaseOptions,
maxAge: CSC_BLOCKING_ERROR_MAX_AGE_SECONDS,
});
};
/**
* Read + validate the blocking-error cookie. Returns `null` when absent or
* signature-invalid; throws `INVALID_REQUEST` when signed-but-malformed
* (tamper-shaped, mirroring `oauth-flow-cookie.ts`).
*/
export const getCscBlockingErrorCookie = async (c: Context): Promise<TCscBlockingErrorPayload | null> => {
const raw = await getSignedCookie(c, getCscCookieSecret(), CSC_BLOCKING_ERROR_COOKIE_NAME);
if (!raw) {
return null;
}
let parsedJson: unknown;
try {
parsedJson = JSON.parse(raw);
} catch {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'CSC blocking error cookie payload is not valid JSON.',
});
}
const result = ZCscBlockingErrorPayloadSchema.safeParse(parsedJson);
if (!result.success) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'CSC blocking error cookie payload failed schema validation.',
});
}
return result.data;
};
export const clearCscBlockingErrorCookie = (c: Context): void => {
deleteCookie(c, CSC_BLOCKING_ERROR_COOKIE_NAME, cscCookieBaseOptions);
};
/**
* Remix-compatible reader: parses + HMAC-verifies the blocking-error cookie
* from a raw `Cookie` header on a standard `Request`. Returns `null` when
* absent, signature-invalid, or payload-malformed (no throw — the loader
* only uses the cookie advisorily, so a bad cookie shouldn't break the page).
*/
export const readCscBlockingErrorFromRequest = async (request: Request): Promise<TCscBlockingErrorPayload | null> => {
const cookieHeader = request.headers.get('cookie');
if (!cookieHeader) {
return null;
}
const parsed = await parseSigned(cookieHeader, getCscCookieSecret(), CSC_BLOCKING_ERROR_COOKIE_NAME);
const value = parsed[CSC_BLOCKING_ERROR_COOKIE_NAME];
if (typeof value !== 'string') {
return null;
}
try {
const json = JSON.parse(value);
const result = ZCscBlockingErrorPayloadSchema.safeParse(json);
return result.success ? result.data : null;
} catch {
return null;
}
};
/**
* Serialised `Set-Cookie` header value that expires the cookie immediately.
* Use in a Remix loader's response headers to clear the cookie after the
* loader reads it once.
*/
export const buildClearCscBlockingErrorCookieHeader = (): string => {
return serialize(CSC_BLOCKING_ERROR_COOKIE_NAME, '', {
...cscCookieBaseOptions,
maxAge: 0,
});
};
@@ -0,0 +1,85 @@
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import type { Context } from 'hono';
import { deleteCookie, getSignedCookie, setSignedCookie } from 'hono/cookie';
import { z } from 'zod';
import { CSC_OAUTH_FLOW_COOKIE_NAME, cscCookieBaseOptions, getCscCookieSecret } from './shared';
/**
* `csc_oauth_flow` — single-round-trip carrier across `/api/csc/oauth/authorize`
* → TSP → `/api/csc/oauth/callback`. Holds the PKCE verifier + state plus the
* Documenso-side context (`recipientToken`, optional `sessionId`) the
* callback needs to resume the right signing flow.
*
* JSON-encoded inside a single signed cookie; structurally validated on read
* so a tampered or stale shape can't smuggle bad state into the callback.
*/
const CSC_OAUTH_FLOW_MAX_AGE_SECONDS = 60 * 10; // 10 minutes — matches /api/auth/oauth/* convention.
export const ZCscOAuthFlowPayloadSchema = z.object({
/** `'service'` for the first round-trip, `'credential'` for the SAD round-trip. */
scope: z.enum(['service', 'credential']),
/** Arctic-generated CSRF token; re-validated against `?state` at callback. */
state: z.string().min(1),
/** Arctic-generated PKCE verifier (RFC 7636); paired with the URL's `code_challenge`. */
codeVerifier: z.string().min(1),
/** Recipient signing token from `/sign/{token}`; threads recipient identity through the round-trip. */
recipientToken: z.string().min(1),
/** CSC session id — present only on `credential`-scope flows (set at prep). */
sessionId: z.string().min(1).optional(),
});
export type TCscOAuthFlowPayload = z.infer<typeof ZCscOAuthFlowPayloadSchema>;
type SetCscOAuthFlowCookieOptions = {
c: Context;
payload: TCscOAuthFlowPayload;
};
export const setCscOAuthFlowCookie = async (options: SetCscOAuthFlowCookieOptions): Promise<void> => {
const { c, payload } = options;
await setSignedCookie(c, CSC_OAUTH_FLOW_COOKIE_NAME, JSON.stringify(payload), getCscCookieSecret(), {
...cscCookieBaseOptions,
maxAge: CSC_OAUTH_FLOW_MAX_AGE_SECONDS,
});
};
/**
* Read + validate the OAuth-flow cookie. Returns `null` when the cookie is
* absent or the signature is invalid; throws `INVALID_REQUEST` when the
* payload is structurally bad (signed but malformed JSON / schema mismatch),
* since that's tamper-shaped, not a normal missing-cookie case.
*/
export const getCscOAuthFlowCookie = async (c: Context): Promise<TCscOAuthFlowPayload | null> => {
const raw = await getSignedCookie(c, getCscCookieSecret(), CSC_OAUTH_FLOW_COOKIE_NAME);
if (!raw) {
return null;
}
let parsedJson: unknown;
try {
parsedJson = JSON.parse(raw);
} catch {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'CSC OAuth flow cookie payload is not valid JSON.',
});
}
const result = ZCscOAuthFlowPayloadSchema.safeParse(parsedJson);
if (!result.success) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'CSC OAuth flow cookie payload failed schema validation.',
});
}
return result.data;
};
export const clearCscOAuthFlowCookie = (c: Context): void => {
deleteCookie(c, CSC_OAUTH_FLOW_COOKIE_NAME, cscCookieBaseOptions);
};
@@ -0,0 +1,61 @@
import type { Context } from 'hono';
import { deleteCookie, getSignedCookie, setSignedCookie } from 'hono/cookie';
import { parseSigned } from 'hono/utils/cookie';
import { CSC_SAD_SESSION_COOKIE_NAME, cscCookieBaseOptions, getCscCookieSecret } from './shared';
/**
* `csc_sad_session` — HMAC-signed `CscSession` cuid. Set after the
* credential-scope OAuth callback exchanges code → SAD; pointed at the
* server-side session row that owns the SAD + the prep-time item hashes.
*
* Lifetime mirrors the TSP-asserted SAD expiry (`sadExpiresAt`) so the cookie
* cannot outlive its server-side authorisation. Cleared by the sync sign
* mutation on success; otherwise decays naturally with the browser TTL.
*/
type SetCscSadSessionCookieOptions = {
c: Context;
sessionId: string;
/** Mirror of `CscSession.sadExpiresAt`; cookie expires no later than the SAD. */
expiresAt: Date;
};
export const setCscSadSessionCookie = async (options: SetCscSadSessionCookieOptions): Promise<void> => {
const { c, sessionId, expiresAt } = options;
await setSignedCookie(c, CSC_SAD_SESSION_COOKIE_NAME, sessionId, getCscCookieSecret(), {
...cscCookieBaseOptions,
expires: expiresAt,
});
};
export const getCscSadSessionCookie = async (c: Context): Promise<string | null> => {
const value = await getSignedCookie(c, getCscCookieSecret(), CSC_SAD_SESSION_COOKIE_NAME);
// `getSignedCookie` returns `false` on signature mismatch, `undefined` when
// the cookie is absent. Both collapse to `null` for the caller's sake.
return value ? value : null;
};
export const clearCscSadSessionCookie = (c: Context): void => {
deleteCookie(c, CSC_SAD_SESSION_COOKIE_NAME, cscCookieBaseOptions);
};
/**
* Remix-compatible reader: parses + HMAC-verifies the SAD-session cookie
* from a raw `Cookie` header on a standard `Request`. Mirrors
* `getCscSadSessionCookie` but works outside Hono's `Context`.
*/
export const readCscSadSessionFromRequest = async (request: Request): Promise<string | null> => {
const cookieHeader = request.headers.get('cookie');
if (!cookieHeader) {
return null;
}
const parsed = await parseSigned(cookieHeader, getCscCookieSecret(), CSC_SAD_SESSION_COOKIE_NAME);
const value = parsed[CSC_SAD_SESSION_COOKIE_NAME];
return typeof value === 'string' ? value : null;
};
@@ -0,0 +1,65 @@
import type { Context } from 'hono';
import { deleteCookie, getSignedCookie, setSignedCookie } from 'hono/cookie';
import { parseSigned } from 'hono/utils/cookie';
import { CSC_SERVICE_SESSION_COOKIE_NAME, cscCookieBaseOptions, getCscCookieSecret } from './shared';
/**
* `csc_service_session` — recipient-scoped attestation that this browser just
* completed a service-scope OAuth round-trip for `<recipientToken>`. The
* `/sign/{token}` loader compares the cookie value against the path token; on
* match it skips re-auth, breaking the redirect loop that would otherwise
* occur when the TSP silently re-grants from its cached SCA session.
*
* Covers the long-lived T1→T3 window (recipient on the signing page filling
* fields, before clicking Sign). `csc_sad_session` covers the much shorter
* T4→T5 window (active signing transaction); the two are complementary, not
* substitutes.
*
* TTL = TSP-asserted service-scope `expires_in` so the trust window can never
* outlive the underlying access token.
*/
type SetCscServiceSessionCookieOptions = {
c: Context;
recipientToken: string;
/** TSP service-scope `expires_in` in seconds. Mirrored as the cookie max-age. */
ttlSeconds: number;
};
export const setCscServiceSessionCookie = async (options: SetCscServiceSessionCookieOptions): Promise<void> => {
const { c, recipientToken, ttlSeconds } = options;
await setSignedCookie(c, CSC_SERVICE_SESSION_COOKIE_NAME, recipientToken, getCscCookieSecret(), {
...cscCookieBaseOptions,
maxAge: ttlSeconds,
});
};
export const getCscServiceSessionCookie = async (c: Context): Promise<string | null> => {
const value = await getSignedCookie(c, getCscCookieSecret(), CSC_SERVICE_SESSION_COOKIE_NAME);
return value ? value : null;
};
export const clearCscServiceSessionCookie = (c: Context): void => {
deleteCookie(c, CSC_SERVICE_SESSION_COOKIE_NAME, cscCookieBaseOptions);
};
/**
* Remix-compatible reader: parses + HMAC-verifies the service-session cookie
* from a raw `Cookie` header on a standard `Request`. Mirrors
* `getCscServiceSessionCookie` but works outside Hono's `Context`.
*/
export const readCscServiceSessionFromRequest = async (request: Request): Promise<string | null> => {
const cookieHeader = request.headers.get('cookie');
if (!cookieHeader) {
return null;
}
const parsed = await parseSigned(cookieHeader, getCscCookieSecret(), CSC_SERVICE_SESSION_COOKIE_NAME);
const value = parsed[CSC_SERVICE_SESSION_COOKIE_NAME];
return typeof value === 'string' ? value : null;
};
@@ -0,0 +1,46 @@
import { formatSecureCookieName, getCookieDomain, useSecureCookies } from '@documenso/lib/constants/auth';
import { requireEnv } from '@documenso/lib/utils/env';
/**
* Shared HMAC secret + base attribute set for the CSC cookies.
*
* `NEXTAUTH_SECRET` is reused so signed-cookie verification stays uniform
* across the auth + CSC surfaces. The `sameSite` conditional matches
* `sessionCookieOptions` in `@documenso/auth` so a future embedding flow
* (CSC inside an `<iframe>` on a partner host) works without a separate
* cookie-attribute regime.
*/
/** HMAC secret for hono `setSignedCookie` / `getSignedCookie`. */
export const getCscCookieSecret = (): string => requireEnv('NEXTAUTH_SECRET');
/**
* CSC cookie names; prefixed with `__Secure-` in production over HTTPS.
*
* Naming maps 1:1 to the CSC OAuth scope each cookie attests:
* - `csc_service_session` — service-scope grant (long-lived per-browser SCA
* attestation; lifetime = TSP `expires_in`).
* - `csc_sad_session` — credential-scope grant in progress (in-flight signing
* transaction; lifetime = SAD lifetime).
* - `csc_oauth_flow` — single-round-trip carrier across authorize → callback
* (scope-agnostic; both flows reuse it).
* - `csc_blocking_error` — callback failure surface; carries an unresolvable
* service-scope error (e.g. empty credential list, refused algorithm) to
* the next `/sign/{token}` loader, read-once.
*/
export const CSC_SERVICE_SESSION_COOKIE_NAME = formatSecureCookieName('csc_service_session');
export const CSC_SAD_SESSION_COOKIE_NAME = formatSecureCookieName('csc_sad_session');
export const CSC_OAUTH_FLOW_COOKIE_NAME = formatSecureCookieName('csc_oauth_flow');
export const CSC_BLOCKING_ERROR_COOKIE_NAME = formatSecureCookieName('csc_blocking_error');
/**
* Base options spread into every CSC cookie. Callers add per-cookie expiry
* (`maxAge` or `expires`) on top.
*/
export const cscCookieBaseOptions = {
httpOnly: true,
path: '/',
sameSite: useSecureCookies ? 'none' : 'lax',
secure: useSecureCookies,
domain: getCookieDomain(),
} as const;
@@ -0,0 +1,184 @@
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { prisma } from '@documenso/prisma';
import { Prisma } from '@prisma/client';
/**
* DB helpers for `CscCredential` — the per-recipient row that holds the
* TSP-validated certificate chain, the resolved algorithm policy, and the
* encrypted service-scope access token.
*
* Lifecycle mirrors {@link sign-session.ts} but with a longer-lived row:
*
* - {@link upsertCscCredential} — service-scope OAuth callback writes the
* full credential after `credentials/info` + algorithm validation succeed.
* Re-runs replace prior bytes (cert / token rotates as the TSP refreshes).
* - {@link loadCscCredential} — sign-time fetches by `recipientId` to recover
* the persisted algorithm + encrypted service token; returns `null` when
* the recipient never completed service-scope OAuth.
*
* Encryption is the caller's job — both byte columns hold raw ciphertext
* produced by {@link encryptCscToken} so the helpers stay cipher-agnostic.
* Cascade cleanup on `Recipient` delete removes the row transitively.
*/
export type CscCredentialRow = {
id: string;
recipientId: number;
providerId: string;
credentialId: string;
certCache: Uint8Array | null;
signatureAlgorithm: string;
keyType: string;
digestAlgorithm: string;
keyLenBits: number | null;
signAlgoParams: string | null;
serviceTokenCiphertext: Uint8Array | null;
serviceTokenExpiresAt: Date | null;
createdAt: Date;
updatedAt: Date;
};
type UpsertCscCredentialInput = {
recipientId: number;
providerId: string;
credentialId: string;
/** Length-prefixed X.509 chain — produced from `cscCredentialsInfo.cert.certificates`. */
certCache: Uint8Array;
/** OID persisted from {@link CscAlgorithmPolicy.signAlgoOid}. */
signatureAlgorithm: string;
/** `'RSA'` or `'ECDSA'` from the resolved policy. */
keyType: string;
/** `'SHA-256'` / `'SHA-384'` / `'SHA-512'` from the resolved policy. */
digestAlgorithm: string;
keyLenBits: number;
/** RSASSA-PSS only; omit otherwise. */
signAlgoParams?: string;
/** Output of {@link encryptCscToken}. */
serviceTokenCiphertext: Uint8Array;
/** Mirrors the TSP's `expires_in` projected onto wall-clock. */
serviceTokenExpiresAt: Date;
};
/**
* Create or refresh the per-recipient credential row at service-scope OAuth
* callback success. Replaces every prior byte payload — a re-auth always
* supersedes the prior cert + token (TSPs may have rotated either).
*/
export const upsertCscCredential = async (input: UpsertCscCredentialInput): Promise<CscCredentialRow> => {
const {
recipientId,
providerId,
credentialId,
certCache,
signatureAlgorithm,
keyType,
digestAlgorithm,
keyLenBits,
signAlgoParams,
serviceTokenCiphertext,
serviceTokenExpiresAt,
} = input;
const row = await prisma.cscCredential.upsert({
where: { recipientId },
create: {
recipientId,
providerId,
credentialId,
certCache,
signatureAlgorithm,
keyType,
digestAlgorithm,
keyLenBits,
signAlgoParams: signAlgoParams ?? null,
serviceTokenCiphertext,
serviceTokenExpiresAt,
},
update: {
providerId,
credentialId,
certCache,
signatureAlgorithm,
keyType,
digestAlgorithm,
keyLenBits,
signAlgoParams: signAlgoParams ?? null,
serviceTokenCiphertext,
serviceTokenExpiresAt,
},
});
return toCscCredentialRow(row);
};
/**
* Fetch the credential row for a recipient. Returns `null` when absent — the
* recipient hasn't completed service-scope OAuth yet (loader path) or the
* recipient cascade fired (cleanup path). Both are normal terminal outcomes.
*/
export const loadCscCredential = async (recipientId: number): Promise<CscCredentialRow | null> => {
const row = await prisma.cscCredential.findUnique({
where: { recipientId },
});
return row ? toCscCredentialRow(row) : null;
};
/**
* Explicit delete by recipient id. Recipient-cascade handles routine cleanup;
* this helper is for operator-triggered re-auth flows (force the next visit
* to re-do service-scope OAuth even within the trust window).
*
* Throws `NOT_FOUND` when the row is already gone — semantically distinct
* from {@link loadCscCredential}'s nullable return because explicit delete
* is a deliberate operation and silent no-op would mask flow-state bugs.
*/
export const deleteCscCredential = async (recipientId: number): Promise<CscCredentialRow> => {
try {
const row = await prisma.cscCredential.delete({
where: { recipientId },
});
return toCscCredentialRow(row);
} catch (err) {
if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === 'P2025') {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: `CSC credential for recipient ${recipientId} not found.`,
});
}
throw err;
}
};
const toCscCredentialRow = (row: {
id: string;
recipientId: number;
providerId: string;
credentialId: string;
certCache: Uint8Array | null;
signatureAlgorithm: string;
keyType: string;
digestAlgorithm: string;
keyLenBits: number | null;
signAlgoParams: string | null;
serviceTokenCiphertext: Uint8Array | null;
serviceTokenExpiresAt: Date | null;
createdAt: Date;
updatedAt: Date;
}): CscCredentialRow => ({
id: row.id,
recipientId: row.recipientId,
providerId: row.providerId,
credentialId: row.credentialId,
certCache: row.certCache,
signatureAlgorithm: row.signatureAlgorithm,
keyType: row.keyType,
digestAlgorithm: row.digestAlgorithm,
keyLenBits: row.keyLenBits,
signAlgoParams: row.signAlgoParams,
serviceTokenCiphertext: row.serviceTokenCiphertext,
serviceTokenExpiresAt: row.serviceTokenExpiresAt,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
});
@@ -0,0 +1,548 @@
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { jobs } from '@documenso/lib/jobs/client';
import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token';
import { triggerWebhook } from '@documenso/lib/server-only/webhooks/trigger/trigger-webhook';
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
import { mapEnvelopeToWebhookDocumentPayload, ZWebhookDocumentSchema } from '@documenso/lib/types/webhook-payload';
import type { RequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { getFileServerSide } from '@documenso/lib/universal/upload/get-file.server';
import { putPdfFileServerSide } from '@documenso/lib/universal/upload/put-file.server';
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
import { mapSecondaryIdToDocumentId } from '@documenso/lib/utils/envelope';
import { prisma } from '@documenso/prisma';
import { PDF } from '@libpdf/core';
import {
type DocumentDataType,
EnvelopeType,
RecipientRole,
SendStatus,
SigningStatus,
WebhookTriggerEvents,
} from '@prisma/client';
import { type CscDigest, hashOidForDigest, policyToLibpdfSignerAlgo } from './algorithm-resolver';
import { decodeCscCertChain } from './cert-chain';
import { decryptCscToken } from './ciphers';
import { cscSignHash } from './client/signatures';
import { loadCscCredential } from './credential';
import { buildTspAnchorName } from './pdf-names';
import { consumeCscSession, loadCscSession } from './sign-session';
import { CscCaptureSigner } from './signers/capture-signer';
import { CscFifoSigner } from './signers/fifo-signer';
import { getCscTransport } from './transport';
import { resolveCscSignTimeTsa } from './tsa-resolver';
/**
* CSC TSP sign-time orchestrator.
*
* Two-pass run, both passes operating on the same prep-time-persisted PDF
* bytes (`CscSession.items[i].documentDataId` pins an immutable rendered
* orphan row — see `prepare-recipient-signing.ts`):
*
* 1. Capture re-derives each item's `signedAttrs` digest under the
* session-pinned `signingTime` and asserts it matches the prep-time hash
* bit-for-bit. Defense in depth — the bytes are identical so a mismatch
* means libpdf changed between prep and sign or the row was tampered
* with. Throws `CSC_BASE_DOCUMENT_MUTATED` on divergence.
* 2. A single batched `signatures/signHash` (§11.9) returns position-ordered
* signatures that the embed pass writes back into the same anchors via
* `CscFifoSigner`.
*
* Output bytes are in-place-copied onto `envelopeItem.documentData` (the
* row id stays stable; only `type` + `data` change) — same pattern as
* `materializeTspAnchorsForEnvelope`. The uploaded rows from
* `putPdfFileServerSide` orbit as orphans.
*
* Persistence is bundled into one outer transaction so document-content
* updates, recipient signing-status, audit log, and session consume commit
* atomically. Post-tx side effects (webhooks, emails) run after.
*/
export type ExecuteTspSignOptions = {
sessionId: string;
recipientToken: string;
requestMetadata?: RequestMetadata;
};
export type ExecuteTspSignResult = { outcome: 'signed' } | { outcome: 'already_signed' };
type CapturedItem = {
envelopeItemId: string;
recapturedDigestB64: string;
anchorName: string;
pdfBytes: Uint8Array;
};
type SignedItemDataUpdate = {
/** Existing `envelopeItem.documentDataId` — receives the in-place data update. */
envelopeItemDataId: string;
/** Payload to copy onto the existing row. */
uploadedType: DocumentDataType;
uploadedData: string;
};
export const executeTspSign = async (opts: ExecuteTspSignOptions): Promise<ExecuteTspSignResult> => {
const { sessionId, recipientToken, requestMetadata } = opts;
const session = await loadCscSession(sessionId);
if (!session) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: `CSC session "${sessionId}" not found.`,
});
}
const recipient = await getRecipientByToken({ token: recipientToken }).catch(() => null);
if (!recipient) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: `Recipient with token "${recipientToken}" not found.`,
});
}
if (recipient.id !== session.recipientId) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'CSC session does not belong to the recipient identified by token.',
});
}
// Idempotency: a 15s tRPC timeout that races with a successful sign can
// leave the client retrying after the recipient row already flipped to
// SIGNED. Return success rather than re-running.
if (recipient.signingStatus === SigningStatus.SIGNED) {
return { outcome: 'already_signed' };
}
if (!session.encryptedSad || !session.sadExpiresAt) {
throw new AppError(AppErrorCode.CSC_SAD_EXPIRED_PRE_SIGN, {
message: 'CSC session has no attached SAD — credential-scope OAuth must complete first.',
});
}
if (session.sadExpiresAt.getTime() <= Date.now()) {
throw new AppError(AppErrorCode.CSC_SAD_EXPIRED_PRE_SIGN, {
message: 'CSC SAD expired before sign-time execution.',
});
}
const sad = decryptCscToken(session.encryptedSad);
if (!sad) {
throw new AppError(AppErrorCode.CSC_SAD_EXPIRED_PRE_SIGN, {
message: 'CSC SAD decrypt failed — key rotation or row corruption.',
});
}
const credential = await loadCscCredential(recipient.id);
if (!credential) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'CSC credential missing at sign time.',
});
}
if (!credential.certCache) {
throw new AppError(AppErrorCode.CSC_CERT_INVALID, {
message: 'CSC credential has no persisted certificate chain.',
});
}
if (credential.keyLenBits === null) {
throw new AppError(AppErrorCode.CSC_ALGORITHM_REFUSED, {
message: 'CSC credential omits persisted keyLenBits — service-scope OAuth must re-run.',
});
}
if (!credential.serviceTokenCiphertext || !credential.serviceTokenExpiresAt) {
throw new AppError(AppErrorCode.CSC_REQUEST_FAILED, {
message: 'CSC credential has no persisted service token — recipient must re-auth.',
});
}
if (credential.serviceTokenExpiresAt.getTime() <= Date.now()) {
throw new AppError(AppErrorCode.CSC_REQUEST_FAILED, {
message: 'CSC service token expired — recipient must re-auth via service-scope OAuth.',
});
}
const serviceToken = decryptCscToken(credential.serviceTokenCiphertext);
if (!serviceToken) {
throw new AppError(AppErrorCode.CSC_REQUEST_FAILED, {
message: 'CSC service token decrypt failed — operator re-auth required.',
});
}
const chain = decodeCscCertChain(credential.certCache);
const algo = policyToLibpdfSignerAlgo({
keyType: credential.keyType as 'RSA' | 'ECDSA',
digestAlgorithm: credential.digestAlgorithm as CscDigest,
signAlgoOid: credential.signatureAlgorithm,
keyLenBits: credential.keyLenBits,
hashAlgoOid: '',
});
const envelope = await prisma.envelope.findUniqueOrThrow({
where: { id: session.envelopeId },
include: {
envelopeItems: { include: { documentData: true } },
recipients: true,
documentMeta: true,
},
});
// Capture pass: iterate session.items in order so the resulting hash array
// is position-bound to session.items[*].ordinal.
const capturedItems: CapturedItem[] = [];
for (let i = 0; i < session.items.length; i++) {
const sessionItem = session.items[i];
const envelopeItem = envelope.envelopeItems.find((item) => item.id === sessionItem.envelopeItemId);
if (!envelopeItem) {
throw new AppError(AppErrorCode.CSC_BASE_DOCUMENT_MUTATED, {
message: `Session references envelope item "${sessionItem.envelopeItemId}" not on envelope.`,
});
}
const pinnedDocumentData = await prisma.documentData.findUniqueOrThrow({
where: { id: sessionItem.documentDataId },
});
const bytes = await getFileServerSide(pinnedDocumentData);
const pdfDoc = await PDF.load(bytes);
const captureSigner = new CscCaptureSigner({
certificate: chain[0],
certificateChain: chain.slice(1),
algo,
});
const anchorName = buildTspAnchorName(recipient.id, envelopeItem.id);
// Capture pass stays at B-B even though the embed pass below is B-T:
// libpdf's B-T signature timestamp is added as a CMS *unsigned*
// attribute *after* `signer.sign()` runs over the signed-attrs digest.
// The signed-attrs builder (see CAdESDetachedBuilder.create in
// @libpdf/core) takes only (signer, documentHash, digestAlgorithm,
// signingTime) — no level-conditional attributes — so B-B and B-T
// produce byte-identical signed-attrs for the same inputs. Capturing
// at B-B avoids dragging the TSA into the dry-run.
await pdfDoc.sign({
signer: captureSigner,
fieldName: anchorName,
signingTime: session.signingTime,
level: 'B-B',
digestAlgorithm: algo.digestAlgorithm,
});
if (captureSigner.capturedDigest === null) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'CscCaptureSigner was not invoked by pdf.sign during sign-time capture.',
});
}
const recapturedDigestB64 = Buffer.from(captureSigner.capturedDigest).toString('base64');
if (recapturedDigestB64 !== sessionItem.hashB64) {
throw new AppError(AppErrorCode.CSC_BASE_DOCUMENT_MUTATED, {
message: `Re-derived signedAttrs digest at sign time diverged from prep-time hash for envelope item "${envelopeItem.id}".`,
});
}
capturedItems.push({
envelopeItemId: envelopeItem.id,
recapturedDigestB64,
anchorName,
pdfBytes: bytes,
});
}
// Defensive: session-item / captured-item position binding must hold.
for (let i = 0; i < capturedItems.length; i++) {
if (capturedItems[i].envelopeItemId !== session.items[i].envelopeItemId) {
throw new AppError(AppErrorCode.CSC_EMBED_FAILED, {
message: 'Capture-pass item ordering diverged from session-pinned ordering.',
});
}
}
if (capturedItems.length === 0) {
throw new AppError(AppErrorCode.CSC_EMBED_FAILED, {
message: 'CSC session contains no items — nothing to sign.',
});
}
const hashes = capturedItems.map((c) => c.recapturedDigestB64);
// The cscSignHash request schema requires a non-empty tuple; the explicit
// check above narrows the array literal for the type system.
const [firstHash, ...restHashes] = hashes;
const transport = await getCscTransport();
const signHashResp = await cscSignHash({
baseUrl: transport.serviceBaseUrl,
accessToken: serviceToken,
credentialID: credential.credentialId,
SAD: sad,
hash: [firstHash, ...restHashes],
signAlgo: credential.signatureAlgorithm,
hashAlgo: hashOidForDigest(algo.digestAlgorithm),
});
if (signHashResp.signatures.length !== capturedItems.length) {
throw new AppError(AppErrorCode.CSC_EMBED_FAILED, {
message: `CSC signHash returned ${signHashResp.signatures.length} signatures for ${capturedItems.length} hashes.`,
});
}
// Embed pass: per-item, reload the same prep-persisted PDF bytes and sign
// with a single-signature FIFO signer. No re-render — bytes are exactly
// the ones whose digest the TSP just authorised. Level is B-T: each
// recipient's CMS gets a TSA-attested signature timestamp embedded as an
// unsigned attribute, binding proven time to the signature itself (the
// actual eIDAS AES/QES requirement). The TSA is resolved per-recipient
// via the sign-time resolver — TSP if advertised (authorised with this
// recipient's service-scope bearer), env otherwise.
const timestampAuthority = resolveCscSignTimeTsa(transport, serviceToken);
const signedItemDataUpdates: SignedItemDataUpdate[] = [];
for (let i = 0; i < capturedItems.length; i++) {
const captured = capturedItems[i];
const sigBytes = Buffer.from(signHashResp.signatures[i], 'base64');
const pdfDoc = await PDF.load(captured.pdfBytes);
const fifoSigner = new CscFifoSigner({
certificate: chain[0],
certificateChain: chain.slice(1),
algo,
signatures: [sigBytes],
});
const signResult = await pdfDoc.sign({
signer: fifoSigner,
fieldName: captured.anchorName,
signingTime: session.signingTime,
level: 'B-T',
timestampAuthority,
digestAlgorithm: algo.digestAlgorithm,
});
const envelopeItem = envelope.envelopeItems.find((item) => item.id === captured.envelopeItemId);
if (!envelopeItem) {
throw new AppError(AppErrorCode.CSC_EMBED_FAILED, {
message: `Envelope item "${captured.envelopeItemId}" missing during embed pass.`,
});
}
const fileName = envelope.title.endsWith('.pdf') ? envelope.title : `${envelope.title || 'envelope'}.pdf`;
const uploaded = await putPdfFileServerSide(
{
name: fileName,
type: 'application/pdf',
arrayBuffer: async () => Promise.resolve(signResult.bytes),
},
envelopeItem.documentData.initialData ?? undefined,
);
// In-place data update target: the existing envelopeItem.documentDataId
// row. `uploaded.documentData` is the freshly-created row whose payload
// we'll copy on; that row stays orphan after the copy. Mirrors the
// `materializeTspAnchorsForEnvelope` pattern.
signedItemDataUpdates.push({
envelopeItemDataId: envelopeItem.documentDataId,
uploadedType: uploaded.documentData.type,
uploadedData: uploaded.documentData.data,
});
}
const legacyDocumentId = mapSecondaryIdToDocumentId(envelope.secondaryId);
// Single tx: per-item in-place data updates + recipient flip + audit log +
// session consume. Atomic across items — if any write fails, the recipient
// stays unsigned and the session row stays attached. `envelopeItem.
// documentDataId` is preserved across the run; only `documentData.{type,
// data}` changes. Mirrors `materializeTspAnchorsForEnvelope`.
await prisma.$transaction(async (tx) => {
for (const { envelopeItemDataId, uploadedType, uploadedData } of signedItemDataUpdates) {
await tx.documentData.update({
where: { id: envelopeItemDataId },
data: { type: uploadedType, data: uploadedData },
});
}
await tx.recipient.update({
where: { id: recipient.id },
data: {
signingStatus: SigningStatus.SIGNED,
signedAt: new Date(),
},
});
const authOptions = extractDocumentAuthMethods({
documentAuth: envelope.authOptions,
recipientAuth: recipient.authOptions,
});
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_COMPLETED,
envelopeId: envelope.id,
user: {
name: recipient.name,
email: recipient.email,
},
requestMetadata,
data: {
recipientEmail: recipient.email,
recipientName: recipient.name,
recipientId: recipient.id,
recipientRole: recipient.role,
actionAuth: authOptions.derivedRecipientActionAuth,
},
}),
});
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_CSC_SIGNED,
envelopeId: envelope.id,
user: { name: recipient.name, email: recipient.email },
requestMetadata,
data: {
recipientEmail: recipient.email,
recipientName: recipient.name,
recipientId: recipient.id,
recipientRole: recipient.role,
providerId: credential.providerId,
credentialId: credential.credentialId,
sessionId,
numItemsSigned: signedItemDataUpdates.length,
signatureAlgorithm: credential.signatureAlgorithm,
digestAlgorithm: credential.digestAlgorithm,
},
}),
});
await consumeCscSession(sessionId, tx);
});
// Post-tx side effects (webhooks, emails, next-signer advancement, seal
// job dispatch). Inlined rather than shared with the SES completion path —
// the in-tx shape diverges enough (TSP swaps documentDataIds + consumes
// the CSC session; SES doesn't) that a shared helper would obscure both.
const envelopeWithRelations = await prisma.envelope.findUniqueOrThrow({
where: { id: envelope.id },
include: { documentMeta: true, recipients: true },
});
await triggerWebhook({
event: WebhookTriggerEvents.DOCUMENT_RECIPIENT_COMPLETED,
data: ZWebhookDocumentSchema.parse(mapEnvelopeToWebhookDocumentPayload(envelopeWithRelations)),
userId: envelope.userId,
teamId: envelope.teamId,
});
await jobs.triggerJob({
name: 'send.recipient.signed.email',
payload: {
documentId: legacyDocumentId,
recipientId: recipient.id,
},
});
const pendingRecipients = await prisma.recipient.findMany({
select: {
id: true,
signingOrder: true,
role: true,
},
where: {
envelopeId: envelope.id,
signingStatus: { not: SigningStatus.SIGNED },
role: { not: RecipientRole.CC },
},
orderBy: [{ signingOrder: { sort: 'asc', nulls: 'last' } }, { id: 'asc' }],
});
if (pendingRecipients.length > 0) {
await jobs.triggerJob({
name: 'send.document.pending.email',
payload: {
envelopeId: envelope.id,
recipientId: recipient.id,
},
});
// TSP envelopes are forced SEQUENTIAL at send-time; this branch always
// fires when pending recipients exist. No `nextSigner` dictation path
// — `prepareCscRecipientSigning` doesn't accept one.
const [nextRecipient] = pendingRecipients;
await prisma.recipient.update({
where: { id: nextRecipient.id },
data: {
sendStatus: SendStatus.SENT,
sentAt: new Date(),
},
});
await jobs.triggerJob({
name: 'send.signing.requested.email',
payload: {
userId: envelope.userId,
documentId: legacyDocumentId,
recipientId: nextRecipient.id,
requestMetadata,
},
});
}
const haveAllRecipientsSigned = await prisma.envelope.findFirst({
where: {
id: envelope.id,
recipients: {
every: {
OR: [{ signingStatus: SigningStatus.SIGNED }, { role: RecipientRole.CC }],
},
},
},
});
if (haveAllRecipientsSigned) {
await jobs.triggerJob({
name: 'internal.seal-document',
payload: {
documentId: legacyDocumentId,
requestMetadata,
},
});
}
const updatedDocument = await prisma.envelope.findFirstOrThrow({
where: {
id: envelope.id,
type: EnvelopeType.DOCUMENT,
},
include: {
documentMeta: true,
recipients: true,
},
});
await triggerWebhook({
event: WebhookTriggerEvents.DOCUMENT_SIGNED,
data: ZWebhookDocumentSchema.parse(mapEnvelopeToWebhookDocumentPayload(updatedDocument)),
userId: updatedDocument.userId,
teamId: updatedDocument.teamId ?? undefined,
});
return { outcome: 'signed' };
};
@@ -0,0 +1,130 @@
import type { RequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { getFileServerSide } from '@documenso/lib/universal/upload/get-file.server';
import { putPdfFileServerSide } from '@documenso/lib/universal/upload/put-file.server';
import type { CreateDocumentAuditLogDataResponse } from '@documenso/lib/utils/document-audit-logs';
import { prisma } from '@documenso/prisma';
import { HttpTimestampAuthority, PDF, type TimestampAuthority } from '@libpdf/core';
import type { DocumentData, DocumentMeta, Envelope, EnvelopeItem, Recipient, User } from '@prisma/client';
import { DocumentStatus } from '@prisma/client';
import { resolveCscSealTimeTsa } from './tsa-resolver';
/**
* TSP envelope finalisation step run from the `seal-document` job.
*
* Replaces the SES "decorate + p12 sign" pass: recipient bytes are already
* PAdES-signed by each recipient's CSC TSP, so the seal step is reduced to
* a per-item PAdES B-LTA upgrade — libpdf's `pdf.addArchivalData()` runs
* the full archive sequence (DSS for every existing signature + archival
* `/DocTimeStamp` + DSS for the timestamp's own chain), and the resulting
* bytes are copied in-place onto each `envelopeItem.documentData` row.
* `envelopeItem.documentDataId` stays stable across the whole envelope
* lifecycle (materialise → per-recipient signs → finalise) — mirrors the
* pattern used by `materializeTspAnchorsForEnvelope` and `executeTspSign`.
*
* Certificate / audit-log sidecar PDFs are intentionally NOT merged into
* the signed bytes here — they're rendered on-demand at download time so
* the signed PDF stays byte-identical to what each recipient's SAD
* authorised. Rejection and resealing are unsupported in V1 and rejected
* by the caller before this runs.
*/
export type FinalizeTspEnvelopeCompletionOptions = {
envelope: Envelope & {
documentMeta: DocumentMeta | null;
recipients: Recipient[];
envelopeItems: Array<EnvelopeItem & { documentData: DocumentData }>;
user: Pick<User, 'name' | 'email'>;
};
envelopeCompletedAuditLog: CreateDocumentAuditLogDataResponse;
requestMetadata?: RequestMetadata;
};
type ArchivedItem = {
/** Existing `envelopeItem.documentDataId` — target of the in-place update. */
envelopeItemDataId: string;
uploadedType: DocumentData['type'];
uploadedData: string;
};
export const finalizeTspEnvelopeCompletion = async (opts: FinalizeTspEnvelopeCompletionOptions): Promise<void> => {
const { envelope, envelopeCompletedAuditLog } = opts;
// Resolve the TSA up-front — fail fast if the instance is mis-configured
// before we start round-tripping PDF bytes through storage.
const tsa = resolveCscSealTimeTsa();
const timestampAuthority = buildLibpdfTsa(tsa);
const archivedItems: ArchivedItem[] = [];
for (const envelopeItem of envelope.envelopeItems) {
const pdfBytes = await getFileServerSide(envelopeItem.documentData);
const pdfDoc = await PDF.load(pdfBytes);
// PAdES B-LTA in one call. Internally:
// 1. Gather LTV (certs/OCSP/CRL) for every existing signed field and
// write a single DSS incremental update.
// 2. Add an archival `/DocTimeStamp` over the result.
// 3. Gather LTV for the new timestamp's own certificate chain.
// All three are append-only incremental updates — every prior recipient
// signature's `/ByteRange` stays valid.
const archived = await pdfDoc.addArchivalData({ timestampAuthority });
const { documentData: uploaded } = await putPdfFileServerSide(
{
name: envelopeItem.title.endsWith('.pdf') ? envelopeItem.title : `${envelopeItem.title}.pdf`,
type: 'application/pdf',
arrayBuffer: async () => Promise.resolve(archived.bytes),
},
envelopeItem.documentData.initialData,
);
archivedItems.push({
envelopeItemDataId: envelopeItem.documentData.id,
uploadedType: uploaded.type,
uploadedData: uploaded.data,
});
}
// Single tx: per-item in-place data updates + envelope status flip +
// completion audit log. `envelopeItem.documentDataId` is preserved; the
// freshly-uploaded `DocumentData` rows orbit as orphans.
await prisma.$transaction(async (tx) => {
for (const { envelopeItemDataId, uploadedType, uploadedData } of archivedItems) {
await tx.documentData.update({
where: { id: envelopeItemDataId },
data: { type: uploadedType, data: uploadedData },
});
}
await tx.envelope.update({
where: { id: envelope.id },
data: {
status: DocumentStatus.COMPLETED,
completedAt: new Date(),
},
});
await tx.documentAuditLog.create({
data: envelopeCompletedAuditLog,
});
});
};
/**
* Wrap a resolved seal-time TSA config into a libpdf `TimestampAuthority`.
*
* Env only at seal time — the archival `/DocTimeStamp` is the operator's
* long-term trust anchor and SHOULD point at a dedicated qualified archival
* TSA (e.g. DigiCert) that's independent of the per-recipient TSP. We
* deliberately don't fall back to the TSP here: doing so would couple the
* archive's longevity to a TSP that may revoke or rotate without notice,
* and would require keeping a live service-scope bearer around at the
* seal-document job which has no recipient context anyway.
*
* First URL only — multi-URL fallback can layer on later via a composite
* wrapper if operators need it.
*/
const buildLibpdfTsa = (tsa: { urls: string[] }): TimestampAuthority => {
return new HttpTimestampAuthority(tsa.urls[0]);
};
@@ -0,0 +1,16 @@
import type { logger } from '@documenso/lib/utils/logger';
/**
* CSC subapp Hono context. Mirrors the subset of `apps/remix/server/router.ts`
* `HonoEnv` that CSC handlers actually read. Duplicated (rather than imported
* from `apps/remix/`) to keep the `packages/ee` → `apps/remix` dep direction
* unidirectional.
*
* Runtime contract: the remix host's middleware sets `logger` on every request
* before the CSC subapp runs; the CSC subapp does not set it itself.
*/
export type HonoCscEnv = {
Variables: {
logger: typeof logger;
};
};
@@ -0,0 +1,63 @@
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { Hono } from 'hono';
import { HTTPException } from 'hono/http-exception';
import type { ContentfulStatusCode } from 'hono/utils/http-status';
import type { HonoCscEnv } from './context';
import { cscOAuthAuthorizeRoute } from './oauth-authorize';
import { cscOAuthCallbackRoute } from './oauth-callback';
/**
* `@documenso/ee` CSC subapp. Mount under `/api/csc` in the remix host (see
* `apps/remix/server/router.ts`). All CSC endpoints — OAuth authorize +
* callback — are composed here so the host only has to wire one route.
*
* Routes throw `AppError` freely; the `.onError` handler below normalises
* them into REST responses (mirrors `@documenso/auth/server`'s pattern).
*/
export const csc = new Hono<HonoCscEnv>()
.route('/oauth/authorize', cscOAuthAuthorizeRoute)
.route('/oauth/callback', cscOAuthCallbackRoute);
csc.onError((err, c) => {
const logger = c.get('logger');
if (err instanceof HTTPException) {
return c.json(
{
code: AppErrorCode.UNKNOWN_ERROR,
message: err.message,
statusCode: err.status,
},
err.status,
);
}
if (err instanceof AppError) {
const { status, body } = AppError.toRestAPIError(err);
logger.error({
event: 'csc.error',
code: err.code,
message: err.message,
});
return c.json(body, status as ContentfulStatusCode);
}
logger.error({
event: 'csc.unknown_error',
error: err,
});
return c.json(
{
code: AppErrorCode.UNKNOWN_ERROR,
message: 'Internal Server Error',
statusCode: 500,
},
500,
);
});
export type CscAppType = typeof csc;
@@ -0,0 +1,154 @@
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token';
import { prisma } from '@documenso/prisma';
import { sValidator } from '@hono/standard-validator';
import { Hono } from 'hono';
import { z } from 'zod';
import {
buildCscCredentialScopeAuthorizeUrl,
buildCscServiceScopeAuthorizeUrl,
generateCodeVerifier,
generateState,
} from '../client/oauth';
import { setCscOAuthFlowCookie } from '../cookies/oauth-flow-cookie';
import { loadCscCredential } from '../credential';
import { loadCscSession } from '../sign-session';
import { getCscTransport } from '../transport';
import type { HonoCscEnv } from './context';
/**
* `GET /api/csc/oauth/authorize` — initiates the CSC OAuth round-trip and
* 302-redirects to the TSP's authorize URL with a signed `csc_oauth_flow`
* cookie carrying the state, PKCE verifier, and recipient context the
* callback needs to resume the flow.
*
* Branches on `?scope=service|credential`:
* - `service`: authorised by recipient token; precedes credentials/list.
* - `credential`: authorised by an active `CscSession`; binds the issued SAD
* to the per-item hashes captured at prep.
*
* Errors bubble to the parent app's `.onError` handler (see `./index.ts`).
*/
const ZAuthorizeQuerySchema = z.discriminatedUnion('scope', [
z.object({
scope: z.literal('service'),
token: z.string().min(1),
}),
z.object({
scope: z.literal('credential'),
session: z.string().min(1),
}),
]);
export const cscOAuthAuthorizeRoute = new Hono<HonoCscEnv>().get(
'/',
sValidator('query', ZAuthorizeQuerySchema),
async (c) => {
const logger = c.get('logger');
const query = c.req.valid('query');
const transport = await getCscTransport();
if (query.scope === 'service') {
const recipient = await getRecipientByToken({ token: query.token }).catch(() => null);
if (!recipient) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Recipient not found for the provided token.',
});
}
logger.info({
event: 'csc.oauth.authorize.start',
scope: 'service',
recipientId: recipient.id,
});
const state = generateState();
const codeVerifier = generateCodeVerifier();
const authorizeUrl = buildCscServiceScopeAuthorizeUrl({
client: transport.oauthClient,
oauthBaseUrl: transport.oauthBaseUrl,
state,
codeVerifier,
});
await setCscOAuthFlowCookie({
c,
payload: {
scope: 'service',
state,
codeVerifier,
recipientToken: query.token,
},
});
return c.redirect(authorizeUrl.toString(), 302);
}
const session = await loadCscSession(query.session);
if (!session) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'CSC session not found or already consumed.',
});
}
const credential = await loadCscCredential(session.recipientId);
if (!credential) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'CSC credential missing — service-scope OAuth must complete first.',
});
}
const recipient = await prisma.recipient.findUnique({
where: { id: session.recipientId },
select: { token: true },
});
if (!recipient) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Recipient not found for the CSC session.',
});
}
logger.info({
event: 'csc.oauth.authorize.start',
scope: 'credential',
recipientId: session.recipientId,
sessionId: session.id,
numSignatures: session.items.length,
});
const state = generateState();
const codeVerifier = generateCodeVerifier();
const authorizeUrl = buildCscCredentialScopeAuthorizeUrl({
client: transport.oauthClient,
oauthBaseUrl: transport.oauthBaseUrl,
state,
codeVerifier,
credentialId: credential.credentialId,
numSignatures: session.items.length,
hashes: session.items.map((item) => item.hashB64),
});
await setCscOAuthFlowCookie({
c,
payload: {
scope: 'credential',
state,
codeVerifier,
recipientToken: recipient.token,
sessionId: session.id,
},
});
return c.redirect(authorizeUrl.toString(), 302);
},
);
@@ -0,0 +1,303 @@
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token';
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
import { extractRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
import { formatSigningLink } from '@documenso/lib/utils/recipients';
import { prisma } from '@documenso/prisma';
import { sValidator } from '@hono/standard-validator';
import { Hono } from 'hono';
import { z } from 'zod';
import { resolveCscAlgorithmPolicy } from '../algorithm-resolver';
import { encodeCscCertChain } from '../cert-chain';
import { encryptCscToken } from '../ciphers';
import { cscCredentialsInfo, cscCredentialsList } from '../client/credentials';
import { exchangeCscAuthorizationCode } from '../client/oauth';
import { setCscBlockingErrorCookie } from '../cookies/blocking-error-cookie';
import { clearCscOAuthFlowCookie, getCscOAuthFlowCookie } from '../cookies/oauth-flow-cookie';
import { setCscSadSessionCookie } from '../cookies/sad-session-cookie';
import { setCscServiceSessionCookie } from '../cookies/service-session-cookie';
import { loadCscCredential, upsertCscCredential } from '../credential';
import { updateCscSessionWithSad } from '../sign-session';
import { getCscTransport } from '../transport';
import type { HonoCscEnv } from './context';
/**
* `GET /api/csc/oauth/callback` — landing point for the recipient's return
* from the TSP after the round-trip initiated by `oauth-authorize`. Reads
* the `csc_oauth_flow` cookie, verifies CSRF, exchanges the code, and
* branches on the cookie's `scope`:
*
* - `service`: pulls `credentials/list` + `credentials/info`, validates the
* cert + algorithm policy, persists the `CscCredential` row + service
* token, sets the `csc_service_session` cookie, and redirects to
* `/sign/{token}`. Blocking validation errors (empty list, bad cert,
* refused algorithm) round-trip via the `csc_blocking_error` cookie so the
* signing-page loader can render a stable error UI.
* - `credential`: exchanges code → SAD, stamps it onto the existing
* `CscSession`, sets the `csc_sad_session` cookie, and redirects to
* `/sign/{token}`. Credential-scope failures bubble to `.onError` — the
* recipient simply re-clicks Sign.
*
* Non-blocking errors bubble to the parent app's `.onError` (see
* `./index.ts`) — mirrors `oauth-authorize.ts`.
*/
const ZCallbackQuerySchema = z.object({
state: z.string().min(1),
code: z.string().min(1).optional(),
error: z.string().min(1).optional(),
error_description: z.string().optional(),
});
const BLOCKING_SERVICE_ERROR_CODES = new Set<string>([
AppErrorCode.CSC_CREDENTIAL_LIST_EMPTY,
AppErrorCode.CSC_CERT_INVALID,
AppErrorCode.CSC_ALGORITHM_REFUSED,
]);
const isBlockingServiceError = (code: string): boolean => BLOCKING_SERVICE_ERROR_CODES.has(code);
export const cscOAuthCallbackRoute = new Hono<HonoCscEnv>().get(
'/',
sValidator('query', ZCallbackQuerySchema),
async (c) => {
const logger = c.get('logger');
const query = c.req.valid('query');
const cookie = await getCscOAuthFlowCookie(c);
if (!cookie) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'CSC OAuth flow cookie missing or expired.',
});
}
if (query.state !== cookie.state) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'CSC OAuth callback state mismatch — possible CSRF.',
});
}
// The single-round-trip carrier is spent regardless of subsequent
// outcome; clear it now so a retry restarts from `/api/csc/oauth/authorize`.
clearCscOAuthFlowCookie(c);
if (query.error) {
throw new AppError(AppErrorCode.CSC_REQUEST_FAILED, {
message: `CSC TSP returned OAuth error: ${query.error}${query.error_description ? ' — ' + query.error_description : ''}`,
});
}
if (!query.code) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'CSC OAuth callback missing code parameter.',
});
}
const transport = await getCscTransport();
const recipient = await getRecipientByToken({ token: cookie.recipientToken }).catch(() => null);
if (!recipient) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Recipient not found for CSC OAuth flow cookie.',
});
}
if (cookie.scope === 'service') {
const tokens = await exchangeCscAuthorizationCode({
client: transport.oauthClient,
oauthBaseUrl: transport.oauthBaseUrl,
code: query.code,
codeVerifier: cookie.codeVerifier,
});
try {
const listResp = await cscCredentialsList({
baseUrl: transport.serviceBaseUrl,
accessToken: tokens.accessToken(),
});
// V1 picks the first credential per spec section "Out of scope for
// V1": multi-credential selection UI lands in a later iteration.
const credentialId = listResp.credentialIDs[0];
const infoResp = await cscCredentialsInfo({
baseUrl: transport.serviceBaseUrl,
accessToken: tokens.accessToken(),
credentialID: credentialId,
certificates: 'chain',
certInfo: true,
});
const policy = resolveCscAlgorithmPolicy(infoResp);
if (!infoResp.cert.certificates || infoResp.cert.certificates.length === 0) {
throw new AppError(AppErrorCode.CSC_CERT_INVALID, {
message: 'CSC credential info response omitted required certificate chain.',
});
}
const certCache = encodeCscCertChain(infoResp.cert.certificates);
const serviceTokenCiphertext = encryptCscToken(tokens.accessToken());
const serviceTokenExpiresAt = tokens.accessTokenExpiresAt();
await upsertCscCredential({
recipientId: recipient.id,
providerId: transport.serviceBaseUrl,
credentialId,
certCache,
signatureAlgorithm: policy.signAlgoOid,
keyType: policy.keyType,
digestAlgorithm: policy.digestAlgorithm,
keyLenBits: policy.keyLenBits,
serviceTokenCiphertext,
serviceTokenExpiresAt,
});
await setCscServiceSessionCookie({
c,
recipientToken: cookie.recipientToken,
ttlSeconds: tokens.accessTokenExpiresInSeconds(),
});
await prisma.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_CSC_AUTHENTICATED,
envelopeId: recipient.envelopeId,
user: { name: recipient.name, email: recipient.email },
requestMetadata: extractRequestMetadata(c.req.raw),
data: {
recipientEmail: recipient.email,
recipientName: recipient.name,
recipientId: recipient.id,
recipientRole: recipient.role,
providerId: transport.serviceBaseUrl,
credentialId,
signatureAlgorithm: policy.signAlgoOid,
digestAlgorithm: policy.digestAlgorithm,
},
}),
});
logger.info({
event: 'csc.oauth.callback.service.complete',
recipientId: recipient.id,
});
return c.redirect(formatSigningLink(cookie.recipientToken), 302);
} catch (err) {
if (err instanceof AppError && isBlockingServiceError(err.code)) {
await setCscBlockingErrorCookie({
c,
payload: { code: err.code, recipientToken: cookie.recipientToken },
});
await prisma.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_CSC_AUTHENTICATION_FAILED,
envelopeId: recipient.envelopeId,
user: { name: recipient.name, email: recipient.email },
requestMetadata: extractRequestMetadata(c.req.raw),
data: {
recipientEmail: recipient.email,
recipientName: recipient.name,
recipientId: recipient.id,
recipientRole: recipient.role,
providerId: transport.serviceBaseUrl,
reason: err.code,
},
}),
});
logger.warn({
event: 'csc.oauth.callback.service.blocking',
recipientId: recipient.id,
code: err.code,
});
return c.redirect(formatSigningLink(cookie.recipientToken), 302);
}
throw err;
}
}
if (!cookie.sessionId) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'CSC credential-scope OAuth callback missing sessionId in cookie.',
});
}
const tokens = await exchangeCscAuthorizationCode({
client: transport.oauthClient,
oauthBaseUrl: transport.oauthBaseUrl,
code: query.code,
codeVerifier: cookie.codeVerifier,
});
// CSC §8.3.3 says credential-scope returns `token_type === 'SAD'`. We
// don't hard-fail on a divergent label — the binding is by scope + hash,
// not by `token_type` — but we log so operator metrics can spot loose
// TSPs.
if (tokens.tokenType() !== 'SAD') {
logger.warn({
event: 'csc.oauth.callback.credential.unexpected_token_type',
actual: tokens.tokenType(),
});
}
const sadCiphertext = encryptCscToken(tokens.accessToken());
const sadExpiresAt = tokens.accessTokenExpiresAt();
await updateCscSessionWithSad({
sessionId: cookie.sessionId,
encryptedSad: sadCiphertext,
sadExpiresAt,
});
await setCscSadSessionCookie({
c,
sessionId: cookie.sessionId,
expiresAt: sadExpiresAt,
});
const credential = await loadCscCredential(recipient.id);
if (!credential) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'CSC credential missing at credential-scope callback.',
});
}
await prisma.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_CSC_AUTHORIZED,
envelopeId: recipient.envelopeId,
user: { name: recipient.name, email: recipient.email },
requestMetadata: extractRequestMetadata(c.req.raw),
data: {
recipientEmail: recipient.email,
recipientName: recipient.name,
recipientId: recipient.id,
recipientRole: recipient.role,
providerId: credential.providerId,
credentialId: credential.credentialId,
sessionId: cookie.sessionId,
sadExpiresAt,
},
}),
});
logger.info({
event: 'csc.oauth.callback.credential.complete',
recipientId: recipient.id,
sessionId: cookie.sessionId,
});
return c.redirect(formatSigningLink(cookie.recipientToken), 302);
},
);
@@ -0,0 +1,230 @@
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { isTspEnvelope } from '@documenso/lib/types/signature-level';
import { getFileServerSide } from '@documenso/lib/universal/upload/get-file.server';
import { putPdfFileServerSide } from '@documenso/lib/universal/upload/put-file.server';
import { prisma } from '@documenso/prisma';
import { PDF } from '@libpdf/core';
import { buildTspAnchorName, buildTspStampName } from './pdf-names';
export type MaterializeTspAnchorsForEnvelopeOptions = {
envelopeId: string;
};
/**
* Pre-allocate per-recipient AcroForm signature anchors and per-page `/Stamp`
* overlay annotations on every envelope item of a TSP (AES/QES) envelope.
*
* Mutates the existing `DocumentData` row in place — the `envelopeItem.
* documentDataId` pointer is preserved across materialisation. Materialise
* is distribution housekeeping (pre-allocate fixed anchor slots before any
* recipient signs), not a content version bump, so a pointer swap +
* audit-log entry would mis-attribute the change. The new uploaded row
* created by `putPdfFileServerSide` is kept as an orphan rather than
* deleted — it preserves the standard upload mechanics (S3 PUT or BYTES_64
* encode) without a separate "copy then drop" dance.
*
* Idempotent: re-runs are no-ops when every expected anchor/stamp is
* already present. No-op for SES envelopes.
*/
export const materializeTspAnchorsForEnvelope = async ({
envelopeId,
}: MaterializeTspAnchorsForEnvelopeOptions): Promise<void> => {
const envelope = await prisma.envelope.findUnique({
where: {
id: envelopeId,
},
include: {
recipients: true,
envelopeItems: {
include: {
documentData: true,
},
},
fields: {
select: {
recipientId: true,
envelopeItemId: true,
page: true,
},
},
},
});
if (!envelope) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: `Envelope ${envelopeId} not found`,
});
}
if (!isTspEnvelope(envelope)) {
return;
}
if (envelope.recipients.length === 0) {
return;
}
for (const envelopeItem of envelope.envelopeItems) {
const expectedAnchorNames = envelope.recipients.map((recipient) =>
buildTspAnchorName(recipient.id, envelopeItem.id),
);
const expectedStampNames: string[] = [];
for (const recipient of envelope.recipients) {
const pagesWithFields = new Set<number>();
for (const field of envelope.fields) {
if (field.recipientId === recipient.id && field.envelopeItemId === envelopeItem.id) {
pagesWithFields.add(field.page);
}
}
for (const page of pagesWithFields) {
expectedStampNames.push(buildTspStampName(recipient.id, envelopeItem.id, page));
}
}
const bytes = await getFileServerSide(envelopeItem.documentData);
const pdfDoc = await PDF.load(bytes);
if (isAlreadyMaterialised(pdfDoc, expectedAnchorNames, expectedStampNames)) {
continue;
}
// Bake operator AcroForm, annotations and OCG layers into static graphics
// so the materialised PDF is a deterministic surface. `skipSignatures`
// preserves any operator-placed signature widgets and (on re-materialise)
// the TSP anchors created previously.
pdfDoc.flattenAll({
form: {
skipSignatures: true,
},
});
const form = pdfDoc.getOrCreateForm();
if (pdfDoc.getPageCount() === 0) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: `Envelope item ${envelopeItem.id} PDF has no pages`,
});
}
// Anchors are AcroForm signature fields with no pre-attached widget.
// libpdf forbids `drawField` for signature fields — at sign time
// `pdf.sign({ fieldName })` promotes the existing field dict in place
// to a merged field/widget (Type=Annot, Subtype=Widget, P=page0,
// Rect=[0,0,0,0]) without modifying the page object. That preserves the
// per-recipient `/ByteRange` invariant across sequential signatures.
for (const anchorName of expectedAnchorNames) {
if (form.getSignatureField(anchorName)) {
continue;
}
form.createSignatureField(anchorName);
}
for (const recipient of envelope.recipients) {
const pagesWithFields = new Set<number>();
for (const field of envelope.fields) {
if (field.recipientId === recipient.id && field.envelopeItemId === envelopeItem.id) {
pagesWithFields.add(field.page);
}
}
for (const pageNumber of pagesWithFields) {
const stampName = buildTspStampName(recipient.id, envelopeItem.id, pageNumber);
const page = pdfDoc.getPage(pageNumber - 1);
if (!page) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: `Envelope item ${envelopeItem.id} missing page ${pageNumber} referenced by field`,
});
}
const existing = page.getStampAnnotations().some((stamp) => stamp.stampName === stampName);
if (existing) {
continue;
}
page.addStampAnnotation({
name: stampName,
rect: {
x: 0,
y: 0,
width: page.width,
height: page.height,
},
});
}
}
const newBytes = await pdfDoc.save({ useXRefStream: true });
// CRITICAL: persist via `putPdfFileServerSide` (raw). The normalised path
// would call `form.flatten()` without `skipSignatures` and wipe anchors.
const fileName = envelope.title.endsWith('.pdf') ? envelope.title : `${envelope.title || 'envelope'}.pdf`;
const uploaded = await putPdfFileServerSide(
{
name: fileName,
type: 'application/pdf',
arrayBuffer: async () => Promise.resolve(newBytes),
},
envelopeItem.documentData.initialData ?? undefined,
);
// Copy the persisted bytes reference (S3 key or BYTES_64 payload) onto the
// existing DocumentData row in place. `envelopeItem.documentDataId` stays
// put — see file-level docblock for the rationale.
await prisma.documentData.update({
where: { id: envelopeItem.documentDataId },
data: {
type: uploaded.documentData.type,
data: uploaded.documentData.data,
},
});
}
};
/**
* Whole-item idempotency probe: returns true only when every expected anchor
* and stamp name is already present on the loaded PDF. Partial state is
* treated as not-materialised — the whole item is rebuilt.
*/
const isAlreadyMaterialised = (pdfDoc: PDF, expectedAnchorNames: string[], expectedStampNames: string[]): boolean => {
const form = pdfDoc.getForm();
if (!form) {
return expectedAnchorNames.length === 0 && expectedStampNames.length === 0;
}
for (const anchorName of expectedAnchorNames) {
if (!form.getSignatureField(anchorName)) {
return false;
}
}
if (expectedStampNames.length === 0) {
return true;
}
const presentStampNames = new Set<string>();
for (let i = 0; i < pdfDoc.getPageCount(); i++) {
const page = pdfDoc.getPage(i);
if (!page) {
continue;
}
for (const stamp of page.getStampAnnotations()) {
presentStampNames.add(stamp.stampName);
}
}
return expectedStampNames.every((name) => presentStampNames.has(name));
};
@@ -0,0 +1,23 @@
import { bytesToHex, utf8ToBytes } from '@noble/ciphers/utils';
import { sha1 } from '@noble/hashes/legacy';
/**
* Deterministic PDF object names for CSC TSP signing.
*
* Materialise-time and sign-time both derive these from the same
* `(recipient, item [, page])` tuple — they MUST agree byte-for-byte.
*
* Output is opaque: SHA-1(label) hex-encoded uppercase (40 chars). The PDF
* persists only the hex serial so recipient / envelope-item IDs never leak
* into the document.
*/
const hashToOpaqueSerial = (label: string): string => bytesToHex(sha1(utf8ToBytes(label))).toUpperCase();
/** AcroForm signature-field name (TSP anchor) for a recipient + envelope item. */
export const buildTspAnchorName = (recipientId: number, envelopeItemId: string): string =>
hashToOpaqueSerial(`recipient:${recipientId}|item:${envelopeItemId}`);
/** `/Stamp` annotation name for a recipient + envelope item on a specific page. */
export const buildTspStampName = (recipientId: number, envelopeItemId: string, pageNumber: number): string =>
hashToOpaqueSerial(`recipient:${recipientId}|item:${envelopeItemId}|page:${pageNumber}`);
@@ -0,0 +1,248 @@
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import type { TCscSessionItems } from '@documenso/lib/types/csc-session';
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
import { isTspEnvelope } from '@documenso/lib/types/signature-level';
import type { RequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { getFileServerSide } from '@documenso/lib/universal/upload/get-file.server';
import { putPdfFileServerSide } from '@documenso/lib/universal/upload/put-file.server';
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
import { prisma } from '@documenso/prisma';
import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
import { PDF } from '@libpdf/core';
import { type CscDigest, policyToLibpdfSignerAlgo } from './algorithm-resolver';
import { decodeCscCertChain } from './cert-chain';
import { loadCscCredential } from './credential';
import { buildTspAnchorName, buildTspStampName } from './pdf-names';
import { renderRecipientOverlay } from './render-overlay';
import { upsertCscSession } from './sign-session';
import { CscCaptureSigner } from './signers/capture-signer';
/**
* CSC TSP prep-phase orchestrator.
*
* Per envelope item:
*
* 1. Render the recipient's overlay into the materialised PDF in memory.
* 2. Persist the rendered bytes as a fresh `DocumentData` row — this is the
* immutable byte-source the sign pass will load. Pinning the rendered PDF
* (rather than re-rendering at sign time) eliminates the determinism risk
* of running Konva twice across the OAuth round-trip.
* 3. Reload `pdfDoc` from the persisted bytes and dry-run `pdf.sign` with
* `CscCaptureSigner` to derive the `signedAttrs` digest — captured over
* the same bytes the sign pass will load.
*
* The resulting `{ envelopeItemId, documentDataId, hashB64, ordinal }` tuples
* are stored on `CscSession.itemsJson`. `documentDataId` pins the orphan
* rendered row, not `envelopeItem.documentDataId` — the latter stays stable
* (in-place data updates only, mirroring the materialise pattern).
*
* Sequential per item — PDF parse + libpdf sign is CPU-heavy and per-recipient
* concurrency is wasted on a single Node event loop.
*/
export type PrepareCscRecipientSigningOptions = {
/** Recipient token from `/sign/{token}` URL. */
recipientToken: string;
/** Forwarded for audit log attribution. */
requestMetadata?: RequestMetadata;
};
export type PrepareCscRecipientSigningResult = {
status: 'REDIRECT';
redirectUrl: string;
};
export const prepareCscRecipientSigning = async (
opts: PrepareCscRecipientSigningOptions,
): Promise<PrepareCscRecipientSigningResult> => {
const { recipientToken, requestMetadata } = opts;
const recipient = await prisma.recipient
.findFirst({
where: { token: recipientToken },
// `signature` must be eager-loaded — `renderRecipientOverlay` runs the
// field renderer in `export` mode, which throws `MISSING_SIGNATURE` for
// any inserted SIGNATURE field without signature data. Mirrors the
// include pattern in `seal-document.handler.ts`.
include: { fields: { include: { signature: true } } },
})
.catch(() => null);
if (!recipient) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: `Recipient with token "${recipientToken}" not found.`,
});
}
const envelope = await prisma.envelope.findUniqueOrThrow({
where: { id: recipient.envelopeId },
include: {
envelopeItems: {
include: {
documentData: true,
},
},
recipients: true,
},
});
if (!isTspEnvelope(envelope)) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'prepareCscRecipientSigning called for a non-TSP envelope.',
});
}
const credential = await loadCscCredential(recipient.id);
if (!credential) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'CSC credential missing — service-scope OAuth must complete first.',
});
}
if (!credential.certCache) {
throw new AppError(AppErrorCode.CSC_CERT_INVALID, {
message: 'CSC credential has no persisted certificate chain.',
});
}
if (credential.keyLenBits === null) {
throw new AppError(AppErrorCode.CSC_ALGORITHM_REFUSED, {
message: 'CSC credential omits persisted keyLenBits — service-scope OAuth must re-run.',
});
}
const chain = decodeCscCertChain(credential.certCache);
const algo = policyToLibpdfSignerAlgo({
keyType: credential.keyType as 'RSA' | 'ECDSA',
digestAlgorithm: credential.digestAlgorithm as CscDigest,
signAlgoOid: credential.signatureAlgorithm,
keyLenBits: credential.keyLenBits,
// `policyToLibpdfSignerAlgo` does not read `hashAlgoOid`; passing empty
// string keeps the synthetic policy type-correct without re-derivation.
hashAlgoOid: '',
});
// Pin a single signingTime for every per-item capture so the embed pass
// re-derives byte-identical signedAttrs digests.
const signingTime = new Date();
const items: TCscSessionItems = [];
for (const envelopeItem of envelope.envelopeItems) {
const recipientFieldsOnItem = recipient.fields.filter((field) => field.envelopeItemId === envelopeItem.id);
const pagesWithFields = new Set<number>();
for (const field of recipientFieldsOnItem) {
pagesWithFields.add(field.page);
}
const bytes = await getFileServerSide(envelopeItem.documentData);
const pdfDoc = await PDF.load(bytes);
for (const pageNumber of pagesWithFields) {
const fieldsOnPage: FieldWithSignature[] = recipientFieldsOnItem.filter((field) => field.page === pageNumber);
await renderRecipientOverlay({
pdfDoc,
stampName: buildTspStampName(recipient.id, envelopeItem.id, pageNumber),
pageNumber,
fields: fieldsOnPage,
});
}
// Persist the rendered PDF as an orphan `DocumentData` row before the
// capture pass so sign-time can load byte-identical input — eliminates
// the determinism risk of running Konva again after the OAuth round-trip.
const renderedBytes = await pdfDoc.save({ incremental: true });
const fileName = envelope.title.endsWith('.pdf') ? envelope.title : `${envelope.title || 'envelope'}.pdf`;
const renderedUpload = await putPdfFileServerSide(
{
name: fileName,
type: 'application/pdf',
arrayBuffer: async () => Promise.resolve(renderedBytes),
},
envelopeItem.documentData.initialData ?? undefined,
);
// Reload from the persisted bytes so the capture pass operates on the
// exact same bytes the sign pass will fetch from storage. Skipping the
// reload would compute the digest over an in-memory incremental update
// that diverges from what `PDF.load(renderedBytes)` produces.
const capturePdfDoc = await PDF.load(renderedBytes);
const captureSigner = new CscCaptureSigner({
certificate: chain[0],
certificateChain: chain.slice(1),
algo,
});
const anchorName = buildTspAnchorName(recipient.id, envelopeItem.id);
// Capture at B-B even though the eventual embed pass is B-T. The B-T
// signature timestamp is a CMS *unsigned* attribute, added by libpdf
// after `signer.sign()` runs over the signed-attrs digest — so B-B and
// B-T produce byte-identical signed-attrs for the same `(signer,
// documentHash, digestAlgorithm, signingTime)` tuple. See the matching
// note in `execute-tsp-sign.ts`.
await capturePdfDoc.sign({
signer: captureSigner,
fieldName: anchorName,
signingTime,
level: 'B-B',
digestAlgorithm: algo.digestAlgorithm,
});
if (captureSigner.capturedDigest === null) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'CscCaptureSigner was not invoked by pdf.sign during prep.',
});
}
items.push({
envelopeItemId: envelopeItem.id,
documentDataId: renderedUpload.documentData.id,
hashB64: Buffer.from(captureSigner.capturedDigest).toString('base64'),
ordinal: items.length,
});
}
const session = await upsertCscSession({
recipientId: recipient.id,
envelopeId: envelope.id,
signingTime,
items,
});
await prisma.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_CSC_SIGN_REQUESTED,
envelopeId: envelope.id,
user: { name: recipient.name, email: recipient.email },
requestMetadata,
data: {
recipientEmail: recipient.email,
recipientName: recipient.name,
recipientId: recipient.id,
recipientRole: recipient.role,
providerId: credential.providerId,
credentialId: credential.credentialId,
sessionId: session.id,
numSignatures: items.length,
},
}),
});
const redirectUrl = `${NEXT_PUBLIC_WEBAPP_URL()}/api/csc/oauth/authorize?scope=credential&session=${session.id}`;
return {
status: 'REDIRECT',
redirectUrl,
};
};
@@ -0,0 +1,162 @@
import { AnnotationFlags, ops, PDF, PdfArray, PdfDict, PdfName, PdfNumber } from '@libpdf/core';
// `Operator` is declared in `@libpdf/core` but not exported. Derive it from
// `ops.pushGraphicsState`'s return type instead of importing.
type LibpdfOperator = ReturnType<typeof ops.pushGraphicsState>;
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { insertFieldInPDFV2 } from '@documenso/lib/server-only/pdf/insert-field-in-pdf-v2';
import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
/**
* CSC TSP recipient overlay renderer.
*
* Writes a recipient's per-page field values into the pre-allocated
* `/Stamp` annotation's normal appearance (`/AP /N`), reusing the Konva
* overlay generator that powers the SES path.
*
* SES uses `page.drawPage(embeddedPage)` to paint directly onto the page
* content stream. For TSP that would create a new page object in the
* incremental update and invalidate prior recipients' `/ByteRange`. Routing
* the same embedded FormXObject through a stamp's appearance keeps the page
* dict untouched while reusing the embed pipeline `drawPage` does.
*
* The appearance stream mirrors `drawPage`'s `x=0, y=0, scale=1, no-rotate`
* branch: a single `concatMatrix(1, 0, 0, 1, -box.x, -box.y)` compensates
* for any non-origin MediaBox on the overlay PDF before `paintXObject`. The
* stamp's `/Rect` and the appearance `/BBox` both span `[0, 0, page.width,
* page.height]`, so the PDF reader maps content 1:1 and page rotation
* applies at the page level (not inside the appearance).
*/
export type RenderRecipientOverlayOptions = {
/** The loaded PDF the stamp lives on. */
pdfDoc: PDF;
/** Stamp name from `buildTspStampName(recipientId, envelopeItemId, pageNumber)`. */
stampName: string;
/** 1-based page number. */
pageNumber: number;
/** Recipient's fields for THIS page only. */
fields: FieldWithSignature[];
};
/**
* Render `fields` into the pre-allocated `/Stamp` annotation named `stampName`
* on `pageNumber`. Mutates `pdfDoc` in place.
*
* Throws when the named stamp can't be located — every call site must have
* materialised the stamp first via `materializeTspAnchorsForEnvelope`.
*/
export const renderRecipientOverlay = async ({
pdfDoc,
stampName,
pageNumber,
fields,
}: RenderRecipientOverlayOptions): Promise<void> => {
const page = pdfDoc.getPage(pageNumber - 1);
if (!page) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: `Page ${pageNumber} not found on PDF.`,
});
}
const stamp = page.getStampAnnotations().find((annotation) => annotation.stampName === stampName);
if (!stamp) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: `TSP stamp ${stampName} not found on page ${pageNumber}.`,
});
}
const overlayBytes = await insertFieldInPDFV2({
pageWidth: page.width,
pageHeight: page.height,
fields,
});
const overlayDoc = await PDF.load(overlayBytes);
const embedded = await pdfDoc.embedPage(overlayDoc, 0);
// Bind the embedded page under a local XObject name in the appearance's
// own /Resources. Appearance streams are scoped — they can't see the
// parent page's resource dict.
const xobjectName = 'X0';
// Mirror `PDFPage.drawPage`'s no-rotation, no-scale branch:
// translateX = x - embedded.box.x * scaleX (x = 0, scaleX = 1)
// translateY = y - embedded.box.y * scaleY (y = 0, scaleY = 1)
// concatMatrix(scaleX, 0, 0, scaleY, translateX, translateY)
// Identity matrix when the overlay PDF has an origin-aligned MediaBox;
// a translate-only shift otherwise. No-op cost is negligible.
const operators: LibpdfOperator[] = [
ops.pushGraphicsState(),
ops.concatMatrix(1, 0, 0, 1, -embedded.box.x, -embedded.box.y),
ops.paintXObject(xobjectName),
ops.popGraphicsState(),
];
const contentBytes = serializeOperators(operators);
const appearanceRef = pdfDoc.createStream(
{
Type: PdfName.of('XObject'),
Subtype: PdfName.of('Form'),
FormType: PdfNumber.of(1),
BBox: new PdfArray([PdfNumber.of(0), PdfNumber.of(0), PdfNumber.of(page.width), PdfNumber.of(page.height)]),
Resources: PdfDict.of({
XObject: PdfDict.of({
[xobjectName]: embedded.ref,
}),
}),
},
contentBytes,
);
// Direct dict write — bypasses `PDFAnnotation.setNormalAppearance`, which
// (a) re-registers the stream and (b) has a no-op branch when `/AP` is
// absent on the annotation. See `node_modules/@libpdf/core/dist/index.mjs:
// 4347-4357`. The PDF reader and libpdf's `getAppearance` (index.mjs:4337)
// both follow refs transparently, so `/AP -> { N: <ref> }` is valid.
stamp.dict.set('AP', PdfDict.of({ N: appearanceRef }));
stamp.setFlag(AnnotationFlags.Print, true);
stamp.setFlag(AnnotationFlags.ReadOnly, true);
stamp.setFlag(AnnotationFlags.Locked, true);
stamp.setFlag(AnnotationFlags.LockedContents, true);
};
/**
* Serialize a content-stream operator sequence into a single byte buffer,
* newline-separated. Mirrors libpdf's internal `serializeOperators` (not
* exported from `@libpdf/core`); each `Operator.toBytes()` returns one
* operator's `operand1 operand2 ... op` slice.
*/
const serializeOperators = (operators: LibpdfOperator[]): Uint8Array => {
if (operators.length === 0) {
return new Uint8Array(0);
}
const chunks = operators.map((operator) => operator.toBytes());
let totalLength = 0;
for (const chunk of chunks) {
totalLength += chunk.length + 1; // +1 for trailing newline
}
const out = new Uint8Array(totalLength);
let offset = 0;
for (const chunk of chunks) {
out.set(chunk, offset);
offset += chunk.length;
out[offset] = 0x0a;
offset += 1;
}
return out;
};
@@ -0,0 +1,181 @@
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { type TCscSessionItems, ZCscSessionItemsSchema } from '@documenso/lib/types/csc-session';
import { prisma } from '@documenso/prisma';
import { Prisma } from '@prisma/client';
/**
* DB helpers for `CscSession` — the per-recipient transient row that bridges
* prep, the credential-scope OAuth round-trip, and the sync sign mutation.
*
* Four operations cover the spec's lifecycle:
*
* - {@link upsertCscSession} — prep time; clears any prior SAD by writing
* `encryptedSad = null` so a re-clicked Sign starts fresh.
* - {@link updateCscSessionWithSad} — credential-scope callback; sets the
* SAD + its TSP-asserted expiry.
* - {@link loadCscSession} — authorize route, signing-page loader, sync
* mutation. Returns null on missing (cookie referenced a deleted session).
* - {@link consumeCscSession} — sync mutation success path; single-use delete
* returning the consumed row so the caller can use its data post-deletion.
*
* `itemsJson` is parsed through `ZCscSessionItemsSchema` on every read so the
* caller works with typed {@link TCscSessionItems}.
*/
export type CscSessionRow = {
id: string;
recipientId: number;
envelopeId: string;
signingTime: Date;
items: TCscSessionItems;
encryptedSad: Uint8Array | null;
sadExpiresAt: Date | null;
createdAt: Date;
};
type UpsertCscSessionInput = {
recipientId: number;
envelopeId: string;
signingTime: Date;
items: TCscSessionItems;
};
/**
* Create or refresh the per-recipient session row at prep time. The recipient
* has at most one in-flight session (`@@unique([recipientId])`); re-clicking
* Sign overwrites prior `itemsJson` + clears `encryptedSad` / `sadExpiresAt`
* so the next credential-scope callback starts from a clean SAD slot.
*/
export const upsertCscSession = async (input: UpsertCscSessionInput): Promise<CscSessionRow> => {
const { recipientId, envelopeId, signingTime, items } = input;
const row = await prisma.cscSession.upsert({
where: { recipientId },
create: {
recipientId,
envelopeId,
signingTime,
itemsJson: items,
encryptedSad: null,
sadExpiresAt: null,
},
update: {
envelopeId,
signingTime,
itemsJson: items,
encryptedSad: null,
sadExpiresAt: null,
},
});
return toCscSessionRow(row);
};
type UpdateCscSessionWithSadInput = {
sessionId: string;
encryptedSad: Uint8Array;
sadExpiresAt: Date;
};
/**
* Stamp the credential-scope SAD onto an existing session at the OAuth
* callback. Throws when the session id was already consumed or never existed
* — that's a flow-state bug the caller must surface, not silently skip.
*/
export const updateCscSessionWithSad = async (input: UpdateCscSessionWithSadInput): Promise<CscSessionRow> => {
const { sessionId, encryptedSad, sadExpiresAt } = input;
try {
const row = await prisma.cscSession.update({
where: {
id: sessionId,
},
data: {
encryptedSad: Buffer.from(encryptedSad),
sadExpiresAt,
},
});
return toCscSessionRow(row);
} catch (err) {
if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === 'P2025') {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: `CSC session "${sessionId}" not found at SAD attach time.`,
});
}
throw err;
}
};
/**
* Fetch a session by id. Returns `null` when the row is absent — callers MUST
* handle the missing case (cookie outliving the row is a normal terminal
* outcome, not an error).
*/
export const loadCscSession = async (sessionId: string): Promise<CscSessionRow | null> => {
const row = await prisma.cscSession.findUnique({
where: { id: sessionId },
});
return row ? toCscSessionRow(row) : null;
};
/**
* Atomically delete the session row and return its parsed contents. Used by
* the sync mutation's success path so the caller still has the session data
* for post-sign side effects (audit log, webhook payloads).
*
* Throws `NOT_FOUND` when the row is already gone — semantically distinct
* from {@link loadCscSession}'s nullable return because consume is the
* success-path single-use closer; a missing row at that point means another
* branch raced to consume and the caller should not double-count.
*/
export const consumeCscSession = async (sessionId: string, tx?: Prisma.TransactionClient): Promise<CscSessionRow> => {
const client = tx ?? prisma;
try {
const row = await client.cscSession.delete({
where: { id: sessionId },
});
return toCscSessionRow(row);
} catch (err) {
if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === 'P2025') {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: `CSC session "${sessionId}" already consumed or never existed.`,
});
}
throw err;
}
};
/**
* Project a raw Prisma `CscSession` into the helper's parsed shape. Throws
* on `itemsJson` parse failure — that's a data-integrity issue, not a
* recoverable runtime case.
*/
const toCscSessionRow = (row: {
id: string;
recipientId: number;
envelopeId: string;
signingTime: Date;
itemsJson: Prisma.JsonValue;
encryptedSad: Uint8Array | null;
sadExpiresAt: Date | null;
createdAt: Date;
}): CscSessionRow => {
const items = ZCscSessionItemsSchema.parse(row.itemsJson);
return {
id: row.id,
recipientId: row.recipientId,
envelopeId: row.envelopeId,
signingTime: row.signingTime,
items,
encryptedSad: row.encryptedSad,
sadExpiresAt: row.sadExpiresAt,
createdAt: row.createdAt,
};
};
@@ -0,0 +1,123 @@
/**
* CSC dry-run capture signer.
*
* Libpdf's signing flow expects an inline signer that hashes the
* `signedAttrs` bytes and returns a CMS signature. For the CSC §11.9
* `signatures/signHash` contract the actual signature is produced
* remotely by the TSP, so a single libpdf sign cycle has to be split
* into two passes:
*
* 1. Dry-run — drive `pdf.sign()` with this capture signer to derive
* the `signedAttrs` digest libpdf would otherwise sign. The
* resulting PDF is discarded; only `capturedDigest` matters.
* 2. Embed pass — the `CscFifoSigner` re-runs `pdf.sign()` and feeds
* the TSP-produced signature bytes back into the same byte slots.
*
* The placeholder bytes returned from `sign()` are sized to the
* chosen algorithm so libpdf's downstream CMS construction is not
* surprised by an unexpectedly short signature.
*/
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import type { Signer } from '@libpdf/core';
import { sha256, sha384, sha512 } from '@noble/hashes/sha2';
import type { LibpdfSignerAlgo } from '../algorithm-resolver';
type DigestAlgorithm = 'SHA-256' | 'SHA-384' | 'SHA-512';
type KeyType = 'RSA' | 'EC';
type SignatureAlgorithm = 'RSASSA-PKCS1-v1_5' | 'RSA-PSS' | 'ECDSA';
export type CscCaptureSignerOptions = {
certificate: Uint8Array;
certificateChain?: Uint8Array[];
algo: LibpdfSignerAlgo;
};
export class CscCaptureSigner implements Signer {
readonly certificate: Uint8Array;
readonly certificateChain?: Uint8Array[];
readonly keyType: KeyType;
readonly signatureAlgorithm: SignatureAlgorithm;
private readonly algo: LibpdfSignerAlgo;
/** Populated by `sign()`. `null` until libpdf calls into the signer. */
capturedDigest: Uint8Array | null = null;
constructor(options: CscCaptureSignerOptions) {
this.certificate = options.certificate;
this.certificateChain = options.certificateChain;
this.keyType = options.algo.keyType;
this.signatureAlgorithm = options.algo.signatureAlgorithm;
this.algo = options.algo;
}
/**
* Hash `data` with `algorithm` to derive the `signedAttrs` digest libpdf
* would normally sign, stash it on `capturedDigest`, then return a
* placeholder buffer sized to the chosen key so libpdf's CMS scaffolding
* accepts it. The placeholder bytes are never inspected — the resulting
* PDF is discarded after the digest is read.
*/
// biome-ignore lint/suspicious/useAwait: intentional
async sign(data: Uint8Array, algorithm: DigestAlgorithm): Promise<Uint8Array> {
if (this.capturedDigest !== null) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'CscCaptureSigner.sign() called more than once — capture signers are single-use.',
});
}
this.capturedDigest = hashData(data, algorithm);
return new Uint8Array(placeholderSize(this.algo));
}
}
const hashData = (data: Uint8Array, algorithm: DigestAlgorithm): Uint8Array => {
if (algorithm === 'SHA-256') {
return sha256(data);
}
if (algorithm === 'SHA-384') {
return sha384(data);
}
if (algorithm === 'SHA-512') {
return sha512(data);
}
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: `CscCaptureSigner.sign() called with unsupported digest algorithm '${String(algorithm)}'.`,
});
};
const placeholderSize = (algo: LibpdfSignerAlgo): number => {
if (algo.keyType === 'RSA') {
// RSA signature length === modulus length in bytes.
if (algo.keyLenBits >= 4096) {
return 512;
}
if (algo.keyLenBits >= 3072) {
return 384;
}
return 256;
}
// ECDSA DER-encoded SEQUENCE { INTEGER r, INTEGER s }. Upper bounds:
// P-256 ≈ 72 bytes, P-384 ≈ 104, P-521 ≈ 139. The dry-run PDF is
// discarded — exact size is informational, not load-bearing.
if (algo.keyLenBits >= 512) {
return 139;
}
if (algo.keyLenBits >= 384) {
return 104;
}
return 72;
};
@@ -0,0 +1,57 @@
/**
* CSC embed-pass FIFO signer.
*
* `signatures/signHash` (CSC §11.9) returns one signature per submitted
* hash, in the same position-bound order as the request `hash[]` array.
* The embed pass re-runs `pdf.sign()` once per anchor in that same order,
* so a FIFO queue of signature bytes — popped on each `sign()` call —
* is sufficient to feed libpdf without any per-anchor binding metadata.
*/
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import type { Signer } from '@libpdf/core';
import type { LibpdfSignerAlgo } from '../algorithm-resolver';
type DigestAlgorithm = 'SHA-256' | 'SHA-384' | 'SHA-512';
type KeyType = 'RSA' | 'EC';
type SignatureAlgorithm = 'RSASSA-PKCS1-v1_5' | 'RSA-PSS' | 'ECDSA';
export type CscFifoSignerOptions = {
certificate: Uint8Array;
certificateChain?: Uint8Array[];
algo: LibpdfSignerAlgo;
/** Base64-decoded raw signature bytes in the order produced by `signatures/signHash`. */
signatures: Uint8Array[];
};
export class CscFifoSigner implements Signer {
readonly certificate: Uint8Array;
readonly certificateChain?: Uint8Array[];
readonly keyType: KeyType;
readonly signatureAlgorithm: SignatureAlgorithm;
private readonly queue: Uint8Array[];
constructor(options: CscFifoSignerOptions) {
this.certificate = options.certificate;
this.certificateChain = options.certificateChain;
this.keyType = options.algo.keyType;
this.signatureAlgorithm = options.algo.signatureAlgorithm;
this.queue = [...options.signatures];
}
// biome-ignore lint/suspicious/useAwait: intentional
async sign(_data: Uint8Array, _algorithm: DigestAlgorithm): Promise<Uint8Array> {
const next = this.queue.shift();
if (next === undefined) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'CSC FIFO signer exhausted — more sign() calls than queued signatures.',
});
}
return next;
}
}
@@ -0,0 +1,153 @@
import { IS_INSTANCE_CSC_MODE, NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { assertLicensedFor } from '@documenso/lib/server-only/license/assert-licensed-for';
import { requireEnv } from '@documenso/lib/utils/env';
import type { OAuth2Client } from 'arctic';
import { cscInfo } from './client/info';
import { createCscOAuthClient } from './client/oauth';
import type { TCscInfoResponse } from './client/types';
import { isEnvTsaConfigured } from './tsa-resolver';
/**
* Lazily-built, globally-cached CSC transport.
*
* Boot-discovers `cscInfo` (§11.1) once, caches the OAuth base URL +
* `signatures/timestamp` capability, and exposes a configured arctic
* `OAuth2Client`. License + env + discovery are gated at construction so a
* misconfigured instance fails at the first call site, not at sign time.
*
* Cached on `globalThis` so Hono routes and Remix loaders share one instance
* across bundles (mirrors {@link LicenseClient}'s strategy).
*
* A failed build is **not** cached — the next caller retries. This keeps a
* transient discovery hiccup from permanently breaking the transport while
* still amortising the success path to one round-trip per process.
*/
const DISCOVERY_TIMEOUT_MS = 10_000;
const CSC_TIMESTAMP_METHOD = 'signatures/timestamp';
export type CscTransport = {
/** Service base URI from `NEXT_PRIVATE_SIGNING_CSC_PROVIDER_BASE_URL`. */
serviceBaseUrl: string;
/** OAuth base URI from `info.oauth2` (§11.1). MAY differ from `serviceBaseUrl`. */
oauthBaseUrl: string;
/** Pre-configured arctic client bound to the TSP's OAuth registration. */
oauthClient: OAuth2Client;
/**
* Documenso's callback URL registered with the TSP. Derived from
* `NEXT_PUBLIC_WEBAPP_URL` and the fixed `/api/csc/oauth/callback` mount —
* mirrors `packages/auth/server/config.ts` for the sign-in OAuth providers.
* Operators must register this exact URL with the TSP.
*/
oauthRedirectUri: string;
/** True when the TSP advertises `signatures/timestamp` in `info.methods`. */
supportsTimestamp: boolean;
/** Raw discovery response, exposed for callers needing other fields (`name`, `region`, `lang`). */
info: TCscInfoResponse;
};
declare global {
// eslint-disable-next-line no-var
var __documenso_csc_transport__: CscTransport | undefined;
// eslint-disable-next-line no-var
var __documenso_csc_transport_promise__: Promise<CscTransport> | undefined;
}
/**
* Get the current CSC transport, building + caching it on first call.
*
* Throws:
* - `NOT_SETUP` — instance is not in CSC mode, or a required env var is unset.
* - `CSC_UNLICENSED` — `instanceCscSigning` license flag missing.
* - `CSC_PROVIDER_INFO_FAILED` — `info` discovery failed or response omits
* the REQUIRED `oauth2` base URL.
*
* Safe to call concurrently — a second call during in-flight discovery
* awaits the same promise instead of starting a duplicate request.
*/
export const getCscTransport = async (): Promise<CscTransport> => {
if (globalThis.__documenso_csc_transport__) {
return globalThis.__documenso_csc_transport__;
}
if (!globalThis.__documenso_csc_transport_promise__) {
globalThis.__documenso_csc_transport_promise__ = buildCscTransport()
.then((transport) => {
globalThis.__documenso_csc_transport__ = transport;
return transport;
})
.finally(() => {
globalThis.__documenso_csc_transport_promise__ = undefined;
});
}
return await globalThis.__documenso_csc_transport_promise__;
};
/**
* Drop the cached transport. Intended for tests / operator-triggered reload
* after a license-key swap. Next {@link getCscTransport} call re-runs the
* full build pipeline (license + env + discovery).
*/
export const resetCscTransport = (): void => {
globalThis.__documenso_csc_transport__ = undefined;
globalThis.__documenso_csc_transport_promise__ = undefined;
};
const buildCscTransport = async (): Promise<CscTransport> => {
if (!IS_INSTANCE_CSC_MODE()) {
throw new AppError(AppErrorCode.NOT_SETUP, {
message: 'CSC transport requested but NEXT_PRIVATE_SIGNING_TRANSPORT is not "csc".',
});
}
await assertLicensedFor('instanceCscSigning', { errorCode: AppErrorCode.CSC_UNLICENSED });
const serviceBaseUrl = requireEnv('NEXT_PRIVATE_SIGNING_CSC_PROVIDER_BASE_URL');
const clientId = requireEnv('NEXT_PRIVATE_SIGNING_CSC_OAUTH_CLIENT_ID');
const clientSecret = requireEnv('NEXT_PRIVATE_SIGNING_CSC_OAUTH_CLIENT_SECRET');
const oauthRedirectUri = `${NEXT_PUBLIC_WEBAPP_URL()}/api/csc/oauth/callback`;
const oauthClient = createCscOAuthClient({ clientId, clientSecret, redirectUri: oauthRedirectUri });
const info = await cscInfo({
baseUrl: serviceBaseUrl,
signal: AbortSignal.timeout(DISCOVERY_TIMEOUT_MS),
});
if (!info.oauth2) {
throw new AppError(AppErrorCode.CSC_PROVIDER_INFO_FAILED, {
message:
'CSC TSP info response omits the required `oauth2` base URL. CSC QES V1 only supports OAuth-based authorization (§8.3) — non-OAuth TSPs are not compatible.',
});
}
const supportsTimestamp = info.methods.includes(CSC_TIMESTAMP_METHOD);
// Boot-time TSA invariant: `NEXT_PRIVATE_SIGNING_TIMESTAMP_AUTHORITY` is
// required unconditionally in CSC mode. Sign-time B-T can use the TSP's
// own `signatures/timestamp` endpoint when advertised, but seal-time
// B-LTA archival is env-only by design (operators should pin a dedicated
// qualified archival TSA — see `resolveCscSealTimeTsa`). Without env, an
// envelope would sign successfully and then hang in
// WAITING_FOR_SIGNATURE_COMPLETION when the seal job throws. Catch the
// misconfiguration at boot instead so the instance refuses to start.
if (!isEnvTsaConfigured()) {
throw new AppError(AppErrorCode.CSC_PROVIDER_NO_TSA, {
message:
'NEXT_PRIVATE_SIGNING_TIMESTAMP_AUTHORITY is unset. AES/QES envelopes require a TSA for B-LTA archival at seal time regardless of whether the CSC TSP advertises signatures/timestamp for B-T sign-time. Configure NEXT_PRIVATE_SIGNING_TIMESTAMP_AUTHORITY.',
});
}
return {
serviceBaseUrl,
oauthBaseUrl: info.oauth2,
oauthClient,
oauthRedirectUri,
supportsTimestamp,
info,
};
};
@@ -0,0 +1,105 @@
import { NEXT_PRIVATE_SIGNING_TIMESTAMP_AUTHORITY } from '@documenso/lib/constants/app';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { HttpTimestampAuthority, type TimestampAuthority } from '@libpdf/core';
import type { CscTransport } from './transport';
import { CscTspTimestampAuthority } from './tsp-timestamp-authority';
/**
* Two-phase TSA resolution for the CSC transport.
*
* Phase 1 — sign time (PAdES B-T, per recipient signature).
* Each recipient's CMS gets a signature timestamp embedded as an unsigned
* attribute. {@link resolveCscSignTimeTsa} returns a libpdf-shaped
* `TimestampAuthority` bound to either the TSP's `signatures/timestamp`
* endpoint (authorised with the recipient's own service-scope bearer) or
* the operator's env-configured RFC 3161 TSA, whichever is configured.
* TSP wins precedence so a TSP-supplied TSA is the default when the TSP
* advertises the method.
*
* Phase 2 — seal time (PAdES B-LTA archival timestamp).
* The seal-document job emits one `/DocTimeStamp` over the fully-signed
* envelope. {@link resolveCscSealTimeTsa} returns the env-configured TSA
* only — the archival anchor SHOULD be a dedicated qualified archival
* TSA, independent of the per-recipient TSP. Using the TSP here would
* couple archive longevity to a TSP that may rotate or revoke, and seal
* time has no recipient context to carry a service-scope bearer anyway.
*
* Boot-time guard: {@link buildCscTransport} asserts
* `NEXT_PRIVATE_SIGNING_TIMESTAMP_AUTHORITY` is set unconditionally — seal
* time always needs it, so making it env-or-fail at boot also satisfies
* the sign-time fallback. The defensive throws inside the resolvers below
* should be unreachable in practice.
*/
/**
* Build a libpdf `TimestampAuthority` for a recipient's B-T sign-time
* signature timestamp.
*
* Precedence: TSP first, env fallback. Selection is made up-front based on
* the boot-discovered transport capability — we don't try TSP then fall
* through to env on a runtime error. If the chosen source fails at call
* time, the recipient's sign attempt fails (operator's recourse is to
* configure env, which then wins on the next sign).
*
* `serviceToken` is the decrypted, non-expired service-scope bearer for
* the current recipient — used only when the TSP source is selected.
*/
export const resolveCscSignTimeTsa = (transport: CscTransport, serviceToken: string): TimestampAuthority => {
if (transport.supportsTimestamp) {
return new CscTspTimestampAuthority({ transport, serviceToken });
}
const envUrls = parseTsaEnv(NEXT_PRIVATE_SIGNING_TIMESTAMP_AUTHORITY());
if (envUrls.length > 0) {
return new HttpTimestampAuthority(envUrls[0]);
}
// Boot-time guard in `buildCscTransport` should have rejected this
// configuration before any recipient hit this code path.
throw new AppError(AppErrorCode.CSC_PROVIDER_NO_TSA, {
message:
'CSC sign-time TSA unresolved: TSP does not advertise signatures/timestamp and NEXT_PRIVATE_SIGNING_TIMESTAMP_AUTHORITY is unset. This should have been caught by the boot-time guard in buildCscTransport.',
});
};
/**
* Resolve the seal-time archival TSA URLs (env only).
*
* Returns the parsed env list; the caller picks how to consume it (today
* `finalize-tsp-completion.ts` uses the first URL).
*/
export const resolveCscSealTimeTsa = (): { urls: string[] } => {
const envUrls = parseTsaEnv(NEXT_PRIVATE_SIGNING_TIMESTAMP_AUTHORITY());
if (envUrls.length === 0) {
throw new AppError(AppErrorCode.CSC_PROVIDER_NO_TSA, {
message:
'CSC seal-time archival timestamps require NEXT_PRIVATE_SIGNING_TIMESTAMP_AUTHORITY. This should have been caught by the boot-time guard in buildCscTransport — the env var is required at seal time even when the TSP advertises signatures/timestamp.',
});
}
return { urls: envUrls };
};
/**
* Cheap boot-time predicate — used by `buildCscTransport` to decide
* whether the env TSA satisfies the "at least one source must be
* configured" invariant. Keeping the env parsing in one place avoids
* drift between the guard and the resolvers.
*/
export const isEnvTsaConfigured = (): boolean => {
return parseTsaEnv(NEXT_PRIVATE_SIGNING_TIMESTAMP_AUTHORITY()).length > 0;
};
const parseTsaEnv = (raw: string | undefined): string[] => {
if (!raw) {
return [];
}
return raw
.split(',')
.map((url) => url.trim())
.filter(Boolean);
};
@@ -0,0 +1,82 @@
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import type { DigestAlgorithm, TimestampAuthority } from '@libpdf/core';
import { hashOidForDigest } from './algorithm-resolver';
import { cscTimestamp } from './client/signatures';
import type { CscTransport } from './transport';
/**
* libpdf {@link TimestampAuthority} backed by the CSC TSP's
* `signatures/timestamp` endpoint (§11.10).
*
* Used only at sign time, per recipient, when {@link resolveCscSignTimeTsa}
* selects the TSP source — that is, when the TSP advertises
* `signatures/timestamp` in `info.methods`. The token wired in is the
* current recipient's own service-scope bearer (the same one authorising
* the `signatures/signHash` call alongside it), so the timestamp gets
* attributed to the same identity that just authorised the signature.
*
* Seal-time archival timestamps do not use this class — they go through
* the env-only path in `finalize-tsp-completion.ts`.
*
* Failure semantics: a single `signatures/timestamp` call. On any error
* (HTTP, schema, expired token) we surface `CSC_PROVIDER_NO_TSA` with the
* upstream message folded in. There's no try-in-order — at sign time the
* recipient is fixed, so there's no other token to fall through to.
*/
type CscTspTimestampAuthorityOptions = {
transport: CscTransport;
/** Decrypted service-scope access token for the current recipient. */
serviceToken: string;
/** Optional deadline for the `signatures/timestamp` call. */
signal?: AbortSignal;
};
export class CscTspTimestampAuthority implements TimestampAuthority {
private readonly transport: CscTransport;
private readonly serviceToken: string;
private readonly signal?: AbortSignal;
constructor(opts: CscTspTimestampAuthorityOptions) {
this.transport = opts.transport;
this.serviceToken = opts.serviceToken;
this.signal = opts.signal;
}
/**
* Request a CSC §11.10 timestamp for the supplied digest, authorised with
* the recipient's service-scope bearer. Returns the decoded TimeStampToken
* bytes. Throws `CSC_PROVIDER_NO_TSA` carrying the upstream error message
* on failure.
*
* `algorithm` is libpdf's `DigestAlgorithm` (`SHA-256` / `SHA-384` /
* `SHA-512`), translated to the matching `hashAlgo` OID via the existing
* {@link hashOidForDigest} mapping so the spec's OID-typed payload stays
* in one place.
*/
async timestamp(digest: Uint8Array, algorithm: DigestAlgorithm): Promise<Uint8Array> {
const hash = Buffer.from(digest).toString('base64');
const hashAlgo = hashOidForDigest(algorithm);
try {
const response = await cscTimestamp({
baseUrl: this.transport.serviceBaseUrl,
accessToken: this.serviceToken,
hash,
hashAlgo,
signal: this.signal,
});
return Buffer.from(response.timestamp, 'base64');
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
throw new AppError(AppErrorCode.CSC_PROVIDER_NO_TSA, {
message: `CSC TSP timestamp endpoint refused the recipient's service token: ${message}.`,
});
}
}
}
+3 -2
View File
@@ -12,12 +12,13 @@
"index.ts"
],
"scripts": {
"dev": "email dev --port 3002 --dir templates",
"dev": "react-router dev --config preview/vite.config.ts",
"preview:build": "react-router build --config preview/vite.config.ts",
"clean": "rimraf node_modules"
},
"dependencies": {
"@documenso/tailwind-config": "*",
"@documenso/nodemailer-resend": "4.0.0",
"@documenso/tailwind-config": "*",
"@react-email/body": "0.2.0",
"@react-email/button": "0.2.0",
"@react-email/code-block": "0.2.0",
+2
View File
@@ -0,0 +1,2 @@
/.react-router/
/build/
+9
View File
@@ -0,0 +1,9 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
html,
body {
margin: 0;
padding: 0;
}
@@ -0,0 +1,337 @@
import { SUPPORTED_LANGUAGE_CODES } from '@documenso/lib/constants/locales';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useNavigate } from 'react-router';
import type { FieldConfig } from '../lib/templates';
import { templates } from '../lib/templates';
import { viewports } from '../lib/viewports';
import { PropFields } from './prop-fields';
type Theme = 'light' | 'dark';
const GROUP_ORDER = ['Documents', 'Recipients', 'Organisations', 'Teams', 'Account', 'Admin'] as const;
const LANGUAGE_LABELS: Record<string, string> = {
en: 'English',
de: 'German',
fr: 'French',
es: 'Spanish',
it: 'Italian',
nl: 'Dutch',
pl: 'Polish',
'pt-BR': 'Portuguese (Brazil)',
ja: 'Japanese',
ko: 'Korean',
zh: 'Chinese',
};
const DEFAULT_COLORS = {
primary: '#a2e771',
primaryForeground: '#162c07',
background: '#ffffff',
foreground: '#0f172a',
};
type PlaygroundProps = {
slug: string;
fields: Record<string, FieldConfig>;
defaultProps: Record<string, unknown>;
};
export const EmailPlayground = ({ slug, fields, defaultProps }: PlaygroundProps) => {
const navigate = useNavigate();
const [props, setProps] = useState(defaultProps);
const [html, setHtml] = useState('');
const [loading, setLoading] = useState(false);
const [theme, setTheme] = useState<Theme>('light');
const [viewportIndex, setViewportIndex] = useState(2);
const [lang, setLang] = useState('en');
const [brandingEnabled, setBrandingEnabled] = useState(false);
const [colors, setColors] = useState(DEFAULT_COLORS);
const debounceRef = useRef<ReturnType<typeof setTimeout>>(undefined);
const groupedTemplates = useMemo(() => {
const entries = Object.entries(templates);
return GROUP_ORDER.map((group) => ({
group,
entries: entries.filter(([, def]) => def.group === group),
})).filter((section) => section.entries.length > 0);
}, []);
const fetchHtml = useCallback(
async (currentProps: Record<string, unknown>, currentLang: string, brandColors: typeof colors | null) => {
setLoading(true);
try {
const response = await fetch('/api/render', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
slug,
props: currentProps,
lang: currentLang,
colors: brandColors,
assetBaseUrl: window.location.origin,
}),
});
if (response.ok) {
setHtml(await response.text());
}
} finally {
setLoading(false);
}
},
[slug],
);
// Reset props when navigating to a different template.
useEffect(() => {
setProps(defaultProps);
}, [defaultProps]);
// Re-render on any input change (debounced).
useEffect(() => {
clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => {
void fetchHtml(props, lang, brandingEnabled ? colors : null);
}, 250);
return () => clearTimeout(debounceRef.current);
}, [props, lang, brandingEnabled, colors, fetchHtml]);
const handlePropChange = (key: string, value: unknown) => {
setProps((prev) => ({ ...prev, [key]: value }));
};
const handleColorChange = (key: keyof typeof colors, value: string) => {
setColors((prev) => ({ ...prev, [key]: value }));
};
// Force dark mode inside the iframe by neutralising the prefers-color-scheme
// media query (color-scheme alone doesn't trigger it inside an iframe).
const displayHtml = theme === 'dark' && html ? html.replaceAll(/prefers-color-scheme:\s*dark/g, 'min-width:0') : html;
const viewport = viewports[viewportIndex];
return (
<div className="flex h-screen w-screen overflow-hidden bg-neutral-100 font-sans text-neutral-900">
{/* Sidebar */}
<aside className="flex h-full w-60 flex-shrink-0 flex-col overflow-y-auto border-neutral-200 border-r bg-white">
<div className="border-neutral-200 border-b px-4 py-3">
<h1 className="font-semibold text-sm">Email Preview</h1>
<p className="text-neutral-500 text-xs">{Object.keys(templates).length} templates</p>
</div>
<nav className="flex-1 px-2 py-2">
{groupedTemplates.map((section) => (
<div key={section.group} className="mb-3">
<div className="px-2 py-1 font-medium text-neutral-400 text-xs uppercase tracking-wide">
{section.group}
</div>
{section.entries.map(([id, def]) => (
<button
key={id}
type="button"
onClick={() => navigate(`/${id}`)}
className={`block w-full rounded-md px-2 py-1.5 text-left text-sm transition-colors ${
slug === id ? 'bg-neutral-900 text-white' : 'text-neutral-700 hover:bg-neutral-100'
}`}
>
{def.name}
</button>
))}
</div>
))}
</nav>
</aside>
{/* Props panel */}
<section className="flex h-full w-72 flex-shrink-0 flex-col overflow-y-auto border-neutral-200 border-r bg-white px-4 py-3">
<h2 className="mb-3 font-medium text-neutral-500 text-xs uppercase tracking-wide">Props</h2>
<PropFields fields={fields} values={props} onChange={handlePropChange} />
</section>
{/* Main */}
<main className="flex h-full flex-1 flex-col overflow-hidden">
<Toolbar
theme={theme}
setTheme={setTheme}
viewportIndex={viewportIndex}
setViewportIndex={setViewportIndex}
lang={lang}
setLang={setLang}
brandingEnabled={brandingEnabled}
setBrandingEnabled={setBrandingEnabled}
colors={colors}
onColorChange={handleColorChange}
loading={loading}
/>
<div
className={`flex flex-1 items-start justify-center overflow-auto p-6 ${
theme === 'dark' ? 'bg-neutral-800' : 'bg-neutral-200'
}`}
>
<div
className="flex-shrink-0 overflow-hidden rounded-lg bg-white shadow-lg"
style={{ width: viewport.width }}
>
<iframe
title={`${viewport.name} ${theme}`}
srcDoc={displayHtml}
className="h-[calc(100vh-8rem)] w-full border-0"
style={{ colorScheme: theme }}
/>
</div>
</div>
</main>
</div>
);
};
type ToolbarProps = {
theme: Theme;
setTheme: (theme: Theme) => void;
viewportIndex: number;
setViewportIndex: (index: number) => void;
lang: string;
setLang: (lang: string) => void;
brandingEnabled: boolean;
setBrandingEnabled: (enabled: boolean) => void;
colors: typeof DEFAULT_COLORS;
onColorChange: (key: keyof typeof DEFAULT_COLORS, value: string) => void;
loading: boolean;
};
const Toolbar = (props: ToolbarProps) => {
return (
<div className="flex flex-wrap items-center gap-4 border-neutral-200 border-b bg-white px-4 py-2">
<SegmentedControl
label="Theme"
value={props.theme}
options={[
{ value: 'light', label: 'Light' },
{ value: 'dark', label: 'Dark' },
]}
onChange={(value) => props.setTheme(value as Theme)}
/>
<SegmentedControl
label="Viewport"
value={String(props.viewportIndex)}
options={viewports.map((viewport, index) => ({ value: String(index), label: viewport.name }))}
onChange={(value) => props.setViewportIndex(Number(value))}
/>
<label className="flex items-center gap-1.5 text-neutral-600 text-xs">
<span className="font-medium">Language</span>
<select
value={props.lang}
onChange={(event) => props.setLang(event.target.value)}
className="rounded-md border border-neutral-300 bg-white px-2 py-1 text-neutral-900 text-xs"
>
{SUPPORTED_LANGUAGE_CODES.map((code) => (
<option key={code} value={code}>
{LANGUAGE_LABELS[code] ?? code}
</option>
))}
</select>
</label>
<label className="flex items-center gap-1.5 text-neutral-600 text-xs">
<input
type="checkbox"
checked={props.brandingEnabled}
onChange={(event) => props.setBrandingEnabled(event.target.checked)}
/>
<span className="font-medium">Brand colours</span>
</label>
{props.brandingEnabled && (
<div className="flex items-center gap-3">
<ColorInput
label="Primary"
value={props.colors.primary}
onChange={(value) => props.onColorChange('primary', value)}
/>
<ColorInput
label="On primary"
value={props.colors.primaryForeground}
onChange={(value) => props.onColorChange('primaryForeground', value)}
/>
<ColorInput
label="Background"
value={props.colors.background}
onChange={(value) => props.onColorChange('background', value)}
/>
<ColorInput
label="Text"
value={props.colors.foreground}
onChange={(value) => props.onColorChange('foreground', value)}
/>
</div>
)}
<span className="ml-auto text-neutral-400 text-xs">{props.loading ? 'Rendering…' : ''}</span>
</div>
);
};
type SegmentedControlProps = {
label: string;
value: string;
options: { value: string; label: string }[];
onChange: (value: string) => void;
};
const SegmentedControl = (props: SegmentedControlProps) => {
return (
<div className="flex items-center gap-1.5">
<span className="font-medium text-neutral-600 text-xs">{props.label}</span>
<div className="flex overflow-hidden rounded-md border border-neutral-300">
{props.options.map((option) => (
<button
key={option.value}
type="button"
onClick={() => props.onChange(option.value)}
className={`px-2.5 py-1 text-xs transition-colors ${
props.value === option.value
? 'bg-neutral-900 text-white'
: 'bg-white text-neutral-700 hover:bg-neutral-100'
}`}
>
{option.label}
</button>
))}
</div>
</div>
);
};
type ColorInputProps = {
label: string;
value: string;
onChange: (value: string) => void;
};
const ColorInput = (props: ColorInputProps) => {
return (
<label className="flex items-center gap-1 text-neutral-600 text-xs">
<span>{props.label}</span>
<input
type="color"
value={props.value}
onChange={(event) => props.onChange(event.target.value)}
className="h-6 w-6 cursor-pointer rounded border border-neutral-300 bg-white p-0"
/>
</label>
);
};
@@ -0,0 +1,113 @@
import type { FieldConfig } from '../lib/templates';
type PropFieldsProps = {
fields: Record<string, FieldConfig>;
values: Record<string, unknown>;
onChange: (key: string, value: unknown) => void;
};
export const PropFields = ({ fields, values, onChange }: PropFieldsProps) => {
const entries = Object.entries(fields);
if (entries.length === 0) {
return <p className="text-neutral-400 text-xs">No editable props.</p>;
}
return (
<div className="grid gap-3">
{entries.map(([key, field]) => (
<PropField key={key} name={key} field={field} value={values[key]} onChange={(value) => onChange(key, value)} />
))}
</div>
);
};
type PropFieldProps = {
name: string;
field: FieldConfig;
value: unknown;
onChange: (value: unknown) => void;
};
const inputClass =
'w-full rounded-md border border-neutral-300 bg-white px-2 py-1 text-neutral-900 text-xs focus:border-neutral-500 focus:outline-none';
const PropField = ({ name, field, value, onChange }: PropFieldProps) => {
const id = `prop-${name}`;
return (
<div className="grid gap-1">
<label htmlFor={id} className="font-medium text-neutral-600 text-xs">
{field.label}
</label>
{field.type === 'text' && (
<input
id={id}
className={inputClass}
value={String(value ?? '')}
placeholder={field.placeholder}
onChange={(event) => onChange(event.target.value)}
/>
)}
{field.type === 'textarea' && (
<textarea
id={id}
className={`${inputClass} min-h-16 resize-y font-mono`}
value={String(value ?? '')}
placeholder={field.placeholder}
onChange={(event) => onChange(event.target.value)}
/>
)}
{field.type === 'number' && (
<input
id={id}
type="number"
className={inputClass}
value={value === undefined || value === null ? '' : String(value)}
placeholder={field.placeholder}
onChange={(event) => onChange(event.target.value === '' ? undefined : Number(event.target.value))}
/>
)}
{field.type === 'boolean' && (
<input
id={id}
type="checkbox"
className="h-4 w-4"
checked={Boolean(value)}
onChange={(event) => onChange(event.target.checked)}
/>
)}
{field.type === 'list' && (
<textarea
id={id}
className={`${inputClass} min-h-16 resize-y font-mono`}
value={Array.isArray(value) ? value.join('\n') : ''}
placeholder={field.placeholder}
onChange={(event) => onChange(event.target.value === '' ? [] : event.target.value.split('\n'))}
/>
)}
{field.type === 'select' && field.options && (
<select
id={id}
className={inputClass}
value={String(value ?? '')}
onChange={(event) => onChange(event.target.value)}
>
{field.options.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
)}
{field.description && <p className="text-neutral-400 text-xs">{field.description}</p>}
</div>
);
};
@@ -0,0 +1,12 @@
import { StrictMode, startTransition } from 'react';
import { hydrateRoot } from 'react-dom/client';
import { HydratedRouter } from 'react-router/dom';
startTransition(() => {
hydrateRoot(
document,
<StrictMode>
<HydratedRouter />
</StrictMode>,
);
});
@@ -0,0 +1,56 @@
import { PassThrough } from 'node:stream';
import { createReadableStreamFromReadable } from '@react-router/node';
import { isbot } from 'isbot';
import type { RenderToPipeableStreamOptions } from 'react-dom/server';
import { renderToPipeableStream } from 'react-dom/server';
import type { AppLoadContext, EntryContext } from 'react-router';
import { ServerRouter } from 'react-router';
export const streamTimeout = 5_000;
export default function handleRequest(
request: Request,
responseStatusCode: number,
responseHeaders: Headers,
routerContext: EntryContext,
_loadContext: AppLoadContext,
) {
return new Promise((resolve, reject) => {
let shellRendered = false;
const userAgent = request.headers.get('user-agent');
const readyOption: keyof RenderToPipeableStreamOptions =
(userAgent && isbot(userAgent)) || routerContext.isSpaMode ? 'onAllReady' : 'onShellReady';
const { pipe, abort } = renderToPipeableStream(<ServerRouter context={routerContext} url={request.url} />, {
[readyOption]() {
shellRendered = true;
const body = new PassThrough();
const stream = createReadableStreamFromReadable(body);
responseHeaders.set('Content-Type', 'text/html');
resolve(
new Response(stream, {
headers: responseHeaders,
status: responseStatusCode,
}),
);
pipe(body);
},
onShellError(error: unknown) {
reject(error);
},
onError(error: unknown) {
responseStatusCode = 500;
if (shellRendered) {
console.error(error);
}
},
});
setTimeout(abort, streamTimeout + 1000);
});
}
@@ -0,0 +1,407 @@
import type { ComponentType } from 'react';
import { AccessAuth2FAEmailTemplate } from '../../../templates/access-auth-2fa';
import { AdminUserCreatedTemplate } from '../../../templates/admin-user-created';
import { BulkSendCompleteEmail } from '../../../templates/bulk-send-complete';
import { ConfirmEmailTemplate } from '../../../templates/confirm-email';
import { ConfirmTeamEmailTemplate } from '../../../templates/confirm-team-email';
import { DocumentCancelTemplate } from '../../../templates/document-cancel';
import { DocumentCompletedEmailTemplate } from '../../../templates/document-completed';
import { DocumentCreatedFromDirectTemplateEmailTemplate } from '../../../templates/document-created-from-direct-template';
import { DocumentInviteEmailTemplate } from '../../../templates/document-invite';
import { DocumentPendingEmailTemplate } from '../../../templates/document-pending';
import { DocumentRecipientSignedEmailTemplate } from '../../../templates/document-recipient-signed';
import { DocumentRejectedEmail } from '../../../templates/document-rejected';
import { DocumentRejectionConfirmedEmail } from '../../../templates/document-rejection-confirmed';
import { DocumentReminderEmailTemplate } from '../../../templates/document-reminder';
import { DocumentSelfSignedEmailTemplate } from '../../../templates/document-self-signed';
import { DocumentSuperDeleteEmailTemplate } from '../../../templates/document-super-delete';
import { ForgotPasswordTemplate } from '../../../templates/forgot-password';
import { OrganisationAccountLinkConfirmationTemplate } from '../../../templates/organisation-account-link-confirmation';
import { OrganisationDeleteEmailTemplate } from '../../../templates/organisation-delete';
import { OrganisationInviteEmailTemplate } from '../../../templates/organisation-invite';
import { OrganisationJoinEmailTemplate } from '../../../templates/organisation-join';
import { OrganisationLeaveEmailTemplate } from '../../../templates/organisation-leave';
import { OrganisationLimitAlertEmailTemplate } from '../../../templates/organisation-limit-alert';
import { RecipientExpiredTemplate } from '../../../templates/recipient-expired';
import { RecipientRemovedFromDocumentTemplate } from '../../../templates/recipient-removed-from-document';
import { ResetPasswordTemplate } from '../../../templates/reset-password';
import { TeamDeleteEmailTemplate } from '../../../templates/team-delete';
import { TeamEmailRemovedTemplate } from '../../../templates/team-email-removed';
export type FieldType = 'text' | 'textarea' | 'number' | 'boolean' | 'select' | 'list';
export type FieldConfig = {
type: FieldType;
label: string;
description?: string;
placeholder?: string;
default: unknown;
options?: { label: string; value: string }[];
};
export type TemplateDefinition = {
/** Human label for the sidebar. */
name: string;
/** Loose grouping for the sidebar. */
group: 'Documents' | 'Recipients' | 'Organisations' | 'Teams' | 'Account' | 'Admin';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
component: ComponentType<any>;
/** Editable props surfaced in the preview UI. */
fields: Record<string, FieldConfig>;
};
// --- Reusable field presets ---
const documentNameField: FieldConfig = {
type: 'text',
label: 'Document name',
default: 'Open Source Pledge.pdf',
};
const recipientNameField: FieldConfig = {
type: 'text',
label: 'Recipient name',
default: 'Lucas Smith',
};
const roleField: FieldConfig = {
type: 'select',
label: 'Recipient role',
default: 'SIGNER',
options: [
{ label: 'Signer', value: 'SIGNER' },
{ label: 'Viewer', value: 'VIEWER' },
{ label: 'Approver', value: 'APPROVER' },
{ label: 'CC', value: 'CC' },
{ label: 'Assistant', value: 'ASSISTANT' },
],
};
/**
* Explicit template registry. Each entry maps a slug → component + editable
* `fields`. The slug is the route param (`/:slug`) and matches the source
* filename (sans extension).
*
* `fields` drives both the default preview values AND the editable inputs in
* the UI, so production templates stay free of preview-only defaults.
*/
export const templates: Record<string, TemplateDefinition> = {
// ---- Documents ----
'document-invite': {
name: 'Document invite',
group: 'Documents',
component: DocumentInviteEmailTemplate,
fields: {
inviterName: { type: 'text', label: 'Inviter name', default: 'Lucas Smith' },
inviterEmail: { type: 'text', label: 'Inviter email', default: 'lucas@documenso.com' },
documentName: documentNameField,
role: roleField,
customBody: {
type: 'textarea',
label: 'Custom message',
default: '',
description: 'Leave blank to use the default invite copy.',
},
},
},
'document-completed': {
name: 'Document completed',
group: 'Documents',
component: DocumentCompletedEmailTemplate,
fields: {
documentName: documentNameField,
customBody: { type: 'textarea', label: 'Custom message', default: '' },
},
},
'document-self-signed': {
name: 'Document self-signed',
group: 'Documents',
component: DocumentSelfSignedEmailTemplate,
fields: {
documentName: documentNameField,
},
},
'document-pending': {
name: 'Document pending',
group: 'Documents',
component: DocumentPendingEmailTemplate,
fields: {
documentName: documentNameField,
},
},
'document-reminder': {
name: 'Document reminder',
group: 'Documents',
component: DocumentReminderEmailTemplate,
fields: {
recipientName: recipientNameField,
documentName: documentNameField,
role: roleField,
customBody: { type: 'textarea', label: 'Custom message', default: '' },
},
},
'document-cancel': {
name: 'Document cancelled',
group: 'Documents',
component: DocumentCancelTemplate,
fields: {
inviterName: { type: 'text', label: 'Inviter name', default: 'Lucas Smith' },
documentName: documentNameField,
cancellationReason: {
type: 'textarea',
label: 'Cancellation reason',
default: '',
description: 'Optional. Blank renders no reason block.',
},
},
},
'document-rejected': {
name: 'Document rejected',
group: 'Documents',
component: DocumentRejectedEmail,
fields: {
recipientName: recipientNameField,
documentName: documentNameField,
documentUrl: { type: 'text', label: 'Document URL', default: 'https://documenso.com' },
rejectionReason: {
type: 'textarea',
label: 'Rejection reason',
default: 'The pledge amount is incorrect.',
description: 'Optional in production; blank renders no reason block.',
},
},
},
'document-rejection-confirmed': {
name: 'Document rejection confirmed',
group: 'Documents',
component: DocumentRejectionConfirmedEmail,
fields: {
recipientName: recipientNameField,
documentName: documentNameField,
documentOwnerName: { type: 'text', label: 'Document owner', default: 'Timur Ercan' },
reason: {
type: 'textarea',
label: 'Rejection reason',
default: 'The pledge amount is incorrect.',
description: 'Optional in production; blank renders no reason block.',
},
},
},
'document-created-from-direct-template': {
name: 'Document created (direct template)',
group: 'Documents',
component: DocumentCreatedFromDirectTemplateEmailTemplate,
fields: {
documentName: documentNameField,
},
},
'document-super-delete': {
name: 'Document deleted (admin)',
group: 'Documents',
component: DocumentSuperDeleteEmailTemplate,
fields: {
documentName: documentNameField,
},
},
'bulk-send-complete': {
name: 'Bulk send complete',
group: 'Documents',
component: BulkSendCompleteEmail,
fields: {
userName: { type: 'text', label: 'User name', default: 'Lucas Smith' },
templateName: { type: 'text', label: 'Template name', default: 'NDA Template' },
totalProcessed: { type: 'number', label: 'Total processed', default: 50 },
successCount: { type: 'number', label: 'Success count', default: 48 },
failedCount: { type: 'number', label: 'Failed count', default: 2 },
errors: {
type: 'list',
label: 'Errors',
default: ['Row 12: invalid email', 'Row 30: missing name'],
description: 'One error per line. Rendered when failed count > 0.',
},
},
},
// ---- Recipients ----
'document-recipient-signed': {
name: 'Recipient signed',
group: 'Recipients',
component: DocumentRecipientSignedEmailTemplate,
fields: {
documentName: documentNameField,
recipientName: recipientNameField,
},
},
'recipient-expired': {
name: 'Recipient expired',
group: 'Recipients',
component: RecipientExpiredTemplate,
fields: {
documentName: documentNameField,
recipientName: recipientNameField,
},
},
'recipient-removed-from-document': {
name: 'Recipient removed',
group: 'Recipients',
component: RecipientRemovedFromDocumentTemplate,
fields: {
documentName: documentNameField,
},
},
// ---- Organisations ----
'organisation-invite': {
name: 'Organisation invite',
group: 'Organisations',
component: OrganisationInviteEmailTemplate,
fields: {
senderName: { type: 'text', label: 'Sender name', default: 'Lucas Smith' },
organisationName: { type: 'text', label: 'Organisation name', default: 'Documenso' },
},
},
'organisation-join': {
name: 'Organisation join',
group: 'Organisations',
component: OrganisationJoinEmailTemplate,
fields: {
memberName: { type: 'text', label: 'Member name', default: 'Lucas Smith' },
organisationName: { type: 'text', label: 'Organisation name', default: 'Documenso' },
},
},
'organisation-leave': {
name: 'Organisation leave',
group: 'Organisations',
component: OrganisationLeaveEmailTemplate,
fields: {
memberName: { type: 'text', label: 'Member name', default: 'Lucas Smith' },
organisationName: { type: 'text', label: 'Organisation name', default: 'Documenso' },
},
},
'organisation-delete': {
name: 'Organisation delete',
group: 'Organisations',
component: OrganisationDeleteEmailTemplate,
fields: {
organisationName: { type: 'text', label: 'Organisation name', default: 'Documenso' },
},
},
'organisation-limit-alert': {
name: 'Organisation limit alert',
group: 'Organisations',
component: OrganisationLimitAlertEmailTemplate,
fields: {
organisationName: { type: 'text', label: 'Organisation name', default: 'Documenso' },
},
},
'organisation-account-link-confirmation': {
name: 'Account link confirmation',
group: 'Organisations',
component: OrganisationAccountLinkConfirmationTemplate,
fields: {
organisationName: { type: 'text', label: 'Organisation name', default: 'Documenso' },
},
},
// ---- Teams ----
'confirm-team-email': {
name: 'Confirm team email',
group: 'Teams',
component: ConfirmTeamEmailTemplate,
fields: {
teamName: { type: 'text', label: 'Team name', default: 'Documenso' },
},
},
'team-delete': {
name: 'Team delete',
group: 'Teams',
component: TeamDeleteEmailTemplate,
fields: {},
},
'team-email-removed': {
name: 'Team email removed',
group: 'Teams',
component: TeamEmailRemovedTemplate,
fields: {
teamName: { type: 'text', label: 'Team name', default: 'Documenso' },
teamEmail: { type: 'text', label: 'Team email', default: 'team@documenso.com' },
},
},
// ---- Account ----
'confirm-email': {
name: 'Confirm email',
group: 'Account',
component: ConfirmEmailTemplate,
fields: {
confirmationLink: {
type: 'text',
label: 'Confirmation link',
default: 'https://documenso.com/confirm',
},
},
},
'forgot-password': {
name: 'Forgot password',
group: 'Account',
component: ForgotPasswordTemplate,
fields: {
resetPasswordLink: {
type: 'text',
label: 'Reset link',
default: 'https://documenso.com/reset',
},
},
},
'reset-password': {
name: 'Reset password',
group: 'Account',
component: ResetPasswordTemplate,
fields: {
userName: { type: 'text', label: 'User name', default: 'Lucas Smith' },
userEmail: { type: 'text', label: 'User email', default: 'lucas@documenso.com' },
},
},
'access-auth-2fa': {
name: 'Access auth 2FA',
group: 'Account',
component: AccessAuth2FAEmailTemplate,
fields: {
documentTitle: { type: 'text', label: 'Document title', default: 'Open Source Pledge.pdf' },
code: { type: 'text', label: 'Code', default: '123456' },
userEmail: { type: 'text', label: 'User email', default: 'lucas@documenso.com' },
userName: { type: 'text', label: 'User name', default: 'Lucas Smith' },
expiresInMinutes: { type: 'number', label: 'Expires in (min)', default: 10 },
},
},
// ---- Admin ----
'admin-user-created': {
name: 'Admin user created',
group: 'Admin',
component: AdminUserCreatedTemplate,
fields: {
resetPasswordLink: {
type: 'text',
label: 'Reset link',
default: 'https://documenso.com/reset',
},
},
},
};
export type TemplateId = keyof typeof templates;
/** Extract the default prop values from a template's field config. */
export const getDefaultProps = (fields: Record<string, FieldConfig>): Record<string, unknown> => {
const props: Record<string, unknown> = {};
for (const [key, field] of Object.entries(fields)) {
props[key] = field.default;
}
return props;
};
export const getTemplate = (slug: string): TemplateDefinition | undefined => templates[slug];

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