Compare commits

...

31 Commits

Author SHA1 Message Date
Lucas Smith d08049ed3b v2.5.1 2026-01-27 20:25:31 +11:00
Lucas Smith 7a583aa7af fix: preserve prompt parameter in OAuth authorize URL builder (#2421)
The prompt option was being discarded for OAuth authorize URLs after
adding support for the NEXT_PRIVATE_OIDC_PROMPT env var. This meant
select_account (used elsewhere) was not being passed through.

Now defaults prompt to the provided option (or 'login'), and only
overwrites it when a valid OIDC prompt env var is set. Also adds a
type guard to validate the env var value.
2026-01-27 20:25:16 +11:00
David Nguyen b590076d85 fix: allow past due subscriptions (#2420)
Allow plans with past_due subscriptions to continue to use the platform
until the subscription becomes inactive.
2026-01-27 18:45:58 +11:00
Lucas Smith 65e30b88be fix: persist formValues in document creation endpoints (#2419) 2026-01-27 16:21:09 +11:00
Ted Liang 9c6ee88cc4 fix: security CVE-2026-23527 (#2399) 2026-01-27 15:52:34 +11:00
Lucas Smith 6028ad9158 chore: add translations (#2412)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2026-01-27 15:44:01 +11:00
Lucas Smith 7fc6f5bb6e fix: make teamId optional in support form validation (#2417)
The contact form accepts teamId as an optional param based on
where the user clicks "Support" from. Previously, when opened
from a non-team context, the null teamId would be parsed to NaN
and fail validation, causing the form to error out. Now the
validation only runs when a teamId is actually provided.
2026-01-27 15:00:53 +11:00
Jorge Ramirez 17b261df1f fix(api): add take parameter to template search query for pagination (#2396)
This PR fixes a bug in the `/api/v2/template` endpoint where the
pagination parameter `perPage` was being ignored. Previously, the
endpoint would return all matching templates regardless of the requested
limit, which could lead to performance issues and incorrect API
behavior.
2026-01-27 15:00:37 +11:00
Lucas Smith c732c85082 chore: add manual dispatch to publish workflow and remove chromium builds (#2415) 2026-01-27 14:15:04 +11:00
Lucas Smith 7d38e18f93 v2.5.0 2026-01-26 15:59:30 +11:00
Lucas Smith 0a3e0b8727 feat: validate signers have signature fields before distribution (#2411)
API users were inadvertently sending documents without signature fields,
causing confusion for recipients and breaking their signing flows.

- Add getRecipientsWithMissingFields helper in recipients.ts
- Add server-side validation in sendDocument to block distribution
- Fix v1 API to return 400 instead of 500 for validation errors
- Consolidate UI signature field checks to use isSignatureFieldType
- Add E2E tests for both v1 and v2 APIs
2026-01-26 15:22:12 +11:00
github-actions[bot] b538580a1e chore: extract translations (#2380) 2026-01-26 12:21:02 +11:00
Lucas Smith 42d6e1cbbd chore: upgrade libpdf (#2409) 2026-01-26 12:20:33 +11:00
Lucas Smith 67da488f63 chore: upgrade libpdf (#2408) 2026-01-23 21:38:48 +11:00
Lucas Smith fd3ebc08ec chore: upgrade libpdf (#2406) 2026-01-22 12:45:20 +11:00
Catalin Pit a7963b385a docs: add default recipients section (#2400) 2026-01-21 09:45:34 +02:00
Lucas Smith 9035240b4d refactor: replace pdf-sign with libpdf/core for PDF operations (#2403)
Migrate from @documenso/pdf-sign and @cantoo/pdf-lib to @libpdf/core
for all PDF manipulation and signing operations. This includes:

- New signing transports for Google Cloud KMS and local certificates
- Consolidated PDF operations using libpdf API
- Added TSA (timestamp authority) helper for digital signatures
- Removed deprecated flatten and insert utilities
- Updated tests to use new PDF library
2026-01-21 15:16:23 +11:00
Ephraim Duncan ed7a0011c7 fix: sync envelope state after direct link changes (#2257) 2026-01-21 14:43:24 +11:00
Ted Liang 158b36a9b7 fix: security CVE-2026-22817 CVE-2026-22818 (#2390) 2026-01-15 18:27:04 +11:00
Lucas Smith fabd69bd62 build: upgrade simplewebauthn packages from v9 to v13 (#2389)
The v9 packages are deprecated. This updates to v13 which includes
breaking API changes: optionsJSON wrapper for auth functions,
renamed properties (authenticator→credential), and base64 encoding
for credential IDs via isoBase64URL helper.
2026-01-15 14:22:37 +11:00
Lucas Smith c976e747e3 fix: dont flatten forms for templates (#2386)
Templates shouldn't have their form flattened until they're
converted to a document.
2026-01-14 12:06:28 +11:00
Lucas Smith 34f512bd55 docs: add OpenCode AI-assisted development guide (#2384)
Adds OpenCode support for AI-assisted development, including custom
commands and skills to help contributors maintain consistency and
streamline common workflows.

#### Changes
- Added "AI-Assisted Development with OpenCode" section to
CONTRIBUTING.md with:
  - Installation instructions and provider configuration
- Documentation for 8 custom commands (/implement, /continue,
/interview, /document, /commit, /create-plan, /create-scratch,
/create-justification)
  - Typical workflow guide
- Clear policy that AI-generated code must be reviewed before submission
- Added .agents/ directory for plans, scratches, and justifications
- Added .opencode/ commands and skills for the agent
- Added helper scripts for creating agent files
2026-01-14 10:10:20 +11:00
Karlo db913e95b6 fix: downgrade pdfjs-dist to version 5.4.296 and update react-pdf to version 10.3.0 (#2383) 2026-01-13 21:01:29 +11:00
Catalin Pit bb3e9583e4 feat: add default recipients for teams and orgs (#2248) 2026-01-13 20:32:00 +11:00
Lucas Smith 5bc73a7471 chore: npm audit fix (#2367) 2026-01-13 16:39:10 +11:00
Lucas Smith 06d7849146 chore: add translations (#2373) 2026-01-13 14:34:26 +11:00
Lucas Smith cef7987a72 feat: add audit logs to document details page (#2379)
- Add collapsible audit logs section with paginated table
- Add View JSON button to inspect raw audit log entries
- Display legacy document ID and recipient roles
- Add admin TRPC endpoint for fetching audit logs
- Add database index on envelopeId for DocumentAuditLog table

<img width="887" height="724" alt="image"
src="https://github.com/user-attachments/assets/aeb904c9-515f-49e1-9f8f-513aef455678"
/>
2026-01-13 14:18:10 +11:00
github-actions[bot] cf6f6bcea0 chore: extract translations (#2363) 2026-01-13 12:49:05 +11:00
Catalin Pit 2f27304750 refactor: simplify field dialog component (#2369) 2026-01-13 12:38:10 +11:00
Konrad 912530ca17 fix: mark document visibility options for translation (#2330) 2026-01-12 10:17:03 +11:00
Konrad a995961c4e fix: mark document auth types for translation (#2331) 2026-01-12 09:28:16 +11:00
173 changed files with 7677 additions and 3435 deletions
View File
View File
@@ -0,0 +1,76 @@
---
date: 2026-01-26
title: Validate Signer Fields On Distribute
---
## Summary
Validate that signers have at least one signature field before allowing document/envelope distribution via API, matching the existing UI behavior.
## Background
The API originally allowed distributing documents/envelopes without validating that signers had signature fields assigned. This was intentional - we thought API users might have specific flows where this flexibility was needed.
However, after running it this way for a while, we've observed that more often than not, API users inadvertently send documents without fields assigned. This causes confusion for their recipients (who receive a document with nothing to sign) and breaks their own systems expecting a completed signing flow.
## Problem
The API allowed distributing documents/envelopes even when signers had no signature fields assigned. This was inconsistent with the UI which validates this condition before allowing distribution.
## Solution
### 1. Create centralized validation helper
**File**: `packages/lib/utils/recipients.ts`
- Added `RECIPIENT_ROLES_THAT_REQUIRE_FIELDS` constant (currently only `SIGNER`)
- Added `getRecipientsWithMissingFields()` function that returns recipients missing required fields
- Uses existing `isSignatureFieldType` guard from `packages/prisma/guards/is-signature-field.ts`
### 2. Add server-side validation
**File**: `packages/lib/server-only/document/send-document.ts`
- Added validation check that throws `AppError` with `INVALID_REQUEST` code when signers are missing signature fields
- This blocks both v1 and v2 API distribution endpoints since they both use `sendDocument()`
### 3. Fix v1 API error handling
**File**: `packages/api/v1/implementation.ts`
- Changed `sendDocument` endpoint to use `AppError.toRestAPIError(err)` instead of always returning 500
- Now returns 400 for validation errors
### 4. Update UI to use shared helper
**Files**:
- `apps/remix/app/components/dialogs/envelope-distribute-dialog.tsx`
- `packages/ui/primitives/document-flow/add-fields.tsx`
### 5. Consolidate `hasSignatureField` checks
Updated to use `isSignatureFieldType` guard (checks both `SIGNATURE` and `FREE_SIGNATURE`):
- `apps/remix/app/components/general/document-signing/document-signing-form.tsx`
- `apps/remix/app/components/general/envelope-signing/envelope-signer-form.tsx`
- `apps/remix/app/components/embed/multisign/multi-sign-document-signing-view.tsx`
- `apps/remix/app/components/embed/embed-direct-template-client-page.tsx`
- `apps/remix/app/components/embed/embed-document-signing-page-v1.tsx`
### 6. Add E2E tests
**Files**:
- `packages/app-tests/e2e/api/v1/document-sending.spec.ts` - 5 new tests
- `packages/app-tests/e2e/api/v2/distribute-validation.spec.ts` - 8 new tests
## Test Coverage
- Distribution fails when signer has no fields
- Distribution fails when signer has only non-signature fields
- Distribution succeeds with SIGNATURE field
- Distribution succeeds with FREE_SIGNATURE field (v1 only via Prisma)
- Distribution succeeds when VIEWER/CC/APPROVER have no fields
- Distribution fails when one of multiple signers is missing signature field
- Distribution succeeds when all signers have signature fields
@@ -0,0 +1,186 @@
---
date: 2026-01-14
title: Simplewebauthn V13 Upgrade
---
## Overview
Upgrade SimpleWebAuthn packages from v9.x to v13.x to address the deprecation of `@simplewebauthn/types` and take advantage of new features and improvements.
## Current State
The codebase currently uses:
- `@simplewebauthn/browser@9.x`
- `@simplewebauthn/server@9.x`
- `@simplewebauthn/types@9.x`
## Breaking Changes Summary (v9 → v13)
### v10.0.0 Breaking Changes
1. **Minimum Node version raised to Node v20**
2. **`generateRegistrationOptions()` now expects `Base64URLString` for `excludeCredentials` IDs** (no more `type: 'public-key'` needed)
3. **`generateAuthenticationOptions()` now expects `Base64URLString` for `allowCredentials` IDs**
4. **`credentialID` returned from verification methods is now `Base64URLString`** instead of `Uint8Array`
5. **`AuthenticatorDevice.credentialID` is now `Base64URLString`**
6. **`rpID` is now required when calling `generateAuthenticationOptions()`**
7. **`generateRegistrationOptions()` will generate random user IDs** if not provided
8. **`user.id` is treated as base64url string in `startRegistration()`**
9. **`userHandle` is treated as base64url string in `startAuthentication()`**
### v11.0.0 Breaking Changes
1. **Positional arguments in `startRegistration()` and `startAuthentication()` replaced by object**
- Before: `startRegistration(options)`
- After: `startRegistration({ optionsJSON: options })`
- Before: `startAuthentication(options)`
- After: `startAuthentication({ optionsJSON: options })`
2. **`AuthenticatorDevice` type renamed to `WebAuthnCredential`**
- `credentialID``credential.id`
- `credentialPublicKey``credential.publicKey`
3. **`verifyRegistrationResponse()` returns `registrationInfo.credential` instead of individual properties**
- `credentialID``credential.id`
- `credentialPublicKey``credential.publicKey`
- `counter``credential.counter`
- `transports` are now in `credential.transports`
4. **`verifyAuthenticationResponse()` uses `credential` argument instead of `authenticator`**
### v13.0.0 Breaking Changes
1. **`@simplewebauthn/types` package is retired**
- Types are now exported from `@simplewebauthn/browser` and `@simplewebauthn/server`
- Import types from `@simplewebauthn/server` instead
## Files to Update
### Package Changes
1. Remove `@simplewebauthn/types` dependency
2. Update `@simplewebauthn/browser` to `^13.2.2`
3. Update `@simplewebauthn/server` to `^13.2.2`
### Server-side Files
#### 1. `packages/lib/server-only/auth/create-passkey-registration-options.ts`
- Change import from `@simplewebauthn/types` to `@simplewebauthn/server`
- Remove `type: 'public-key'` from `excludeCredentials` items
- Update `userID` to use `isoUint8Array.fromUTF8String()` for proper encoding
#### 2. `packages/lib/server-only/auth/create-passkey-authentication-options.ts`
- Change import from `@simplewebauthn/types` to `@simplewebauthn/server`
- Remove `type: 'public-key'` from `allowCredentials` items
#### 3. `packages/lib/server-only/auth/create-passkey-signin-options.ts`
- No changes needed (already using correct options)
#### 4. `packages/lib/server-only/auth/create-passkey.ts`
- Change import from `@simplewebauthn/types` to `@simplewebauthn/server`
- Update to use new `registrationInfo.credential` structure:
- `credentialID``credential.id`
- `credentialPublicKey``credential.publicKey`
- `counter``credential.counter`
- Note: `credential.id` is now a `Base64URLString`, so `Buffer.from(credentialID)` needs updating
#### 5. `packages/lib/server-only/document/is-recipient-authorized.ts`
- Update `verifyAuthenticationResponse()` to use `credential` instead of `authenticator`:
- Change `authenticator: { credentialID, credentialPublicKey, counter }` to `credential: { id, publicKey, counter }`
- Since `credential.id` is now base64url string, convert stored `credentialId` buffer to base64url
#### 6. `packages/auth/server/routes/passkey.ts`
- Update `verifyAuthenticationResponse()` to use `credential` instead of `authenticator`
- Same changes as `is-recipient-authorized.ts`
#### 7. `packages/trpc/server/auth-router/create-passkey.ts`
- Change import from `@simplewebauthn/types` to `@simplewebauthn/server`
### Browser-side Files
#### 8. `apps/remix/app/components/dialogs/passkey-create-dialog.tsx`
- Update `startRegistration()` call:
- Before: `startRegistration(passkeyRegistrationOptions)`
- After: `startRegistration({ optionsJSON: passkeyRegistrationOptions })`
#### 9. `apps/remix/app/components/forms/signin.tsx`
- Update `startAuthentication()` call:
- Before: `startAuthentication(options)`
- After: `startAuthentication({ optionsJSON: options })`
#### 10. `apps/remix/app/components/general/document-signing/document-signing-auth-passkey.tsx`
- Update `startAuthentication()` call:
- Before: `startAuthentication(options)`
- After: `startAuthentication({ optionsJSON: options })`
### Database/Schema Considerations
The database stores `credentialId` as `Bytes`. The new API returns `credential.id` as `Base64URLString`. We need to:
1. When **storing** a new passkey: Convert from `Base64URLString` to `Buffer`
2. When **passing to verification**: Convert from `Buffer` to `Base64URLString`
Use `isoBase64URL` helper from `@simplewebauthn/server/helpers` for these conversions.
## Implementation Steps
### Step 1: Update package.json dependencies
```bash
npm uninstall @simplewebauthn/types
npm install @simplewebauthn/browser@^13.2.2 @simplewebauthn/server@^13.2.2
```
### Step 2: Update type imports
Replace all `@simplewebauthn/types` imports with `@simplewebauthn/server`
### Step 3: Update browser-side API calls
- `startRegistration(options)``startRegistration({ optionsJSON: options })`
- `startAuthentication(options)``startAuthentication({ optionsJSON: options })`
### Step 4: Update server-side registration
- Update `excludeCredentials` format (remove `type: 'public-key'`)
- Update `userID` encoding if needed
- Update `verifyRegistrationResponse()` result handling for new `credential` structure
### Step 5: Update server-side authentication
- Update `allowCredentials` format (remove `type: 'public-key'`)
- Update `verifyAuthenticationResponse()` to use `credential` instead of `authenticator`
- Handle `Base64URLString` for `credential.id`
### Step 6: Update credential storage/retrieval
- When storing: Convert `Base64URLString` to `Buffer`
- When reading: Convert `Buffer` to `Base64URLString`
### Step 7: Test passkey flows
1. Test passkey creation
2. Test passkey sign-in
3. Test passkey authentication for document signing
4. Test passkey deletion
## Code Examples
### Converting stored Buffer to Base64URLString for verification
```typescript
import { isoBase64URL } from '@simplewebauthn/server/helpers';
// When reading from database (Buffer) and passing to verification
const credential = {
id: isoBase64URL.fromBuffer(passkey.credentialId),
publicKey: new Uint8Array(passkey.credentialPublicKey),
counter: Number(passkey.counter),
transports: passkey.transports,
};
```
### Converting Base64URLString to Buffer for storage
```typescript
import { isoBase64URL } from '@simplewebauthn/server/helpers';
// When storing from registration response
const credentialIdBuffer = Buffer.from(
isoBase64URL.toBuffer(registrationInfo.credential.id)
);
```
## Risks and Mitigations
1. **Database compatibility**: The `credentialId` is stored as `Bytes` in the database. The new API uses `Base64URLString`. We need proper conversion functions.
- **Mitigation**: Use `isoBase64URL.fromBuffer()` and `isoBase64URL.toBuffer()` for conversions
2. **Existing passkeys**: Existing passkeys should continue to work as long as conversion is done correctly.
- **Mitigation**: Test with existing passkeys after upgrade
3. **Browser compatibility**: v10+ requires newer browser APIs.
- **Mitigation**: `browserSupportsWebAuthn()` already handles this check
View File
+12
View File
@@ -59,6 +59,18 @@ NEXT_PRIVATE_SIGNING_GCLOUD_HSM_PUBLIC_CRT_FILE_PATH=
NEXT_PRIVATE_SIGNING_GCLOUD_HSM_PUBLIC_CRT_FILE_CONTENTS=
# OPTIONAL: The path to the Google Cloud Credentials file to use for the gcloud-hsm signing transport.
NEXT_PRIVATE_SIGNING_GCLOUD_APPLICATION_CREDENTIALS_CONTENTS=
# OPTIONAL: The path to the certificate chain file for the gcloud-hsm signing transport.
NEXT_PRIVATE_SIGNING_GCLOUD_HSM_CERT_CHAIN_FILE_PATH=
# OPTIONAL: The base64-encoded contents of the certificate chain for the gcloud-hsm signing transport.
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: 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.
NEXT_PUBLIC_SIGNING_CONTACT_INFO=
# OPTIONAL: Set to "true" to use the legacy adbe.pkcs7.detached subfilter instead of ETSI.CAdES.detached.
NEXT_PRIVATE_USE_LEGACY_SIGNING_SUBFILTER=
# [[STORAGE]]
# OPTIONAL: Defines the storage transport to use. Available options: database (default) | s3
+22 -118
View File
@@ -3,6 +3,12 @@ name: Publish Docker
on:
push:
branches: ['release']
workflow_dispatch:
inputs:
tag:
description: 'Git tag to build and publish (e.g., v1.0.0)'
required: true
type: string
jobs:
build_and_publish_platform_containers:
@@ -18,6 +24,7 @@ jobs:
steps:
- uses: actions/checkout@v4
with:
ref: ${{ inputs.tag || github.ref }}
fetch-tags: true
- name: Login to DockerHub
@@ -38,8 +45,11 @@ jobs:
BUILD_PLATFORM: ${{ matrix.os == 'warp-ubuntu-latest-arm64-4x' && 'arm64' || 'amd64' }}
NEXT_PRIVATE_TELEMETRY_KEY: ${{ secrets.NEXT_PRIVATE_TELEMETRY_KEY }}
NEXT_PRIVATE_TELEMETRY_HOST: ${{ secrets.NEXT_PRIVATE_TELEMETRY_HOST }}
APP_VERSION: ${{ inputs.tag || '' }}
run: |
APP_VERSION="$(git name-rev --tags --name-only $(git rev-parse HEAD) | head -n 1 | sed 's/\^0//')"
if [ -z "$APP_VERSION" ]; then
APP_VERSION="$(git name-rev --tags --name-only $(git rev-parse HEAD) | head -n 1 | sed 's/\^0//')"
fi
GIT_SHA="$(git rev-parse HEAD)"
docker build \
@@ -65,47 +75,6 @@ jobs:
env:
BUILD_PLATFORM: ${{ matrix.os == 'warp-ubuntu-latest-arm64-4x' && 'arm64' || 'amd64' }}
- name: Build the chromium docker image
env:
BUILD_PLATFORM: ${{ matrix.os == 'warp-ubuntu-latest-arm64-4x' && 'arm64' || 'amd64' }}
run: |
APP_VERSION="$(git name-rev --tags --name-only $(git rev-parse HEAD) | head -n 1 | sed 's/\^0//')"
GIT_SHA="$(git rev-parse HEAD)"
docker build \
-f ./docker/Dockerfile.chromium \
--progress=plain \
--build-arg TAG="$GIT_SHA" \
-t "documenso/documenso-$BUILD_PLATFORM:latest-chromium" \
-t "documenso/documenso-$BUILD_PLATFORM:$GIT_SHA-chromium" \
-t "documenso/documenso-$BUILD_PLATFORM:$APP_VERSION-chromium" \
-t "ghcr.io/documenso/documenso-$BUILD_PLATFORM:latest-chromium" \
-t "ghcr.io/documenso/documenso-$BUILD_PLATFORM:$GIT_SHA-chromium" \
-t "ghcr.io/documenso/documenso-$BUILD_PLATFORM:$APP_VERSION-chromium" \
.
- name: Push the chromium docker image to DockerHub
env:
BUILD_PLATFORM: ${{ matrix.os == 'warp-ubuntu-latest-arm64-4x' && 'arm64' || 'amd64' }}
run: |
APP_VERSION="$(git name-rev --tags --name-only $(git rev-parse HEAD) | head -n 1 | sed 's/\^0//')"
GIT_SHA="$(git rev-parse HEAD)"
docker push "documenso/documenso-$BUILD_PLATFORM:latest-chromium"
docker push "documenso/documenso-$BUILD_PLATFORM:$GIT_SHA-chromium"
docker push "documenso/documenso-$BUILD_PLATFORM:$APP_VERSION-chromium" \
- name: Push the chromium docker image to GitHub Container Registry
env:
BUILD_PLATFORM: ${{ matrix.os == 'warp-ubuntu-latest-arm64-4x' && 'arm64' || 'amd64' }}
run: |
APP_VERSION="$(git name-rev --tags --name-only $(git rev-parse HEAD) | head -n 1 | sed 's/\^0//')"
GIT_SHA="$(git rev-parse HEAD)"
docker push "ghcr.io/documenso/documenso-$BUILD_PLATFORM:latest-chromium"
docker push "ghcr.io/documenso/documenso-$BUILD_PLATFORM:$GIT_SHA-chromium"
docker push "ghcr.io/documenso/documenso-$BUILD_PLATFORM:$APP_VERSION-chromium"
create_and_publish_manifest:
name: Create and publish manifest
runs-on: ubuntu-latest
@@ -114,6 +83,7 @@ jobs:
- name: Checkout
uses: actions/checkout@v4
with:
ref: ${{ inputs.tag || github.ref }}
fetch-tags: true
- name: Login to DockerHub
@@ -130,8 +100,12 @@ jobs:
password: ${{ secrets.GH_TOKEN }}
- name: Create and push DockerHub manifest
env:
APP_VERSION: ${{ inputs.tag || '' }}
run: |
APP_VERSION="$(git name-rev --tags --name-only $(git rev-parse HEAD) | head -n 1 | sed 's/\^0//')"
if [ -z "$APP_VERSION" ]; then
APP_VERSION="$(git name-rev --tags --name-only $(git rev-parse HEAD) | head -n 1 | sed 's/\^0//')"
fi
GIT_SHA="$(git rev-parse HEAD)"
# Check if the version is stable (no rc or beta in the version)
@@ -166,46 +140,13 @@ jobs:
docker manifest push documenso/documenso:$GIT_SHA
docker manifest push documenso/documenso:$APP_VERSION
- name: Create and push DockerHub chromium manifest
run: |
APP_VERSION="$(git name-rev --tags --name-only $(git rev-parse HEAD) | head -n 1 | sed 's/\^0//')"
GIT_SHA="$(git rev-parse HEAD)"
# Check if the version is stable (no rc or beta in the version)
if [[ "$APP_VERSION" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
docker manifest create \
documenso/documenso:latest-chromium \
--amend documenso/documenso-amd64:latest-chromium \
--amend documenso/documenso-arm64:latest-chromium
docker manifest push documenso/documenso:latest-chromium
fi
if [[ "$APP_VERSION" =~ ^v[0-9]+\.[0-9]+\.[0-9]+-rc\.[0-9]+$ ]]; then
docker manifest create \
documenso/documenso:rc-chromium \
--amend documenso/documenso-amd64:rc-chromium \
--amend documenso/documenso-arm64:rc-chromium
docker manifest push documenso/documenso:rc-chromium
fi
docker manifest create \
documenso/documenso:$GIT_SHA-chromium \
--amend documenso/documenso-amd64:$GIT_SHA-chromium \
--amend documenso/documenso-arm64:$GIT_SHA-chromium
docker manifest create \
documenso/documenso:$APP_VERSION-chromium \
--amend documenso/documenso-amd64:$APP_VERSION-chromium \
--amend documenso/documenso-arm64:$APP_VERSION-chromium
docker manifest push documenso/documenso:$GIT_SHA-chromium
docker manifest push documenso/documenso:$APP_VERSION-chromium
- name: Create and push Github Container Registry manifest
env:
APP_VERSION: ${{ inputs.tag || '' }}
run: |
APP_VERSION="$(git name-rev --tags --name-only $(git rev-parse HEAD) | head -n 1 | sed 's/\^0//')"
if [ -z "$APP_VERSION" ]; then
APP_VERSION="$(git name-rev --tags --name-only $(git rev-parse HEAD) | head -n 1 | sed 's/\^0//')"
fi
GIT_SHA="$(git rev-parse HEAD)"
# Check if the version is stable (no rc or beta in the version)
@@ -239,40 +180,3 @@ jobs:
docker manifest push ghcr.io/documenso/documenso:$GIT_SHA
docker manifest push ghcr.io/documenso/documenso:$APP_VERSION
- name: Create and push Github Container Registry chromium manifest
run: |
APP_VERSION="$(git name-rev --tags --name-only $(git rev-parse HEAD) | head -n 1 | sed 's/\^0//')"
GIT_SHA="$(git rev-parse HEAD)"
# Check if the version is stable (no rc or beta in the version)
if [[ "$APP_VERSION" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
docker manifest create \
ghcr.io/documenso/documenso:latest-chromium \
--amend ghcr.io/documenso/documenso-amd64:latest-chromium \
--amend ghcr.io/documenso/documenso-arm64:latest-chromium
docker manifest push ghcr.io/documenso/documenso:latest-chromium
fi
if [[ "$APP_VERSION" =~ ^v[0-9]+\.[0-9]+\.[0-9]+-rc\.[0-9]+$ ]]; then
docker manifest create \
ghcr.io/documenso/documenso:rc-chromium \
--amend ghcr.io/documenso/documenso-amd64:rc-chromium \
--amend ghcr.io/documenso/documenso-arm64:rc-chromium
docker manifest push ghcr.io/documenso/documenso:rc-chromium
fi
docker manifest create \
ghcr.io/documenso/documenso:$GIT_SHA-chromium \
--amend ghcr.io/documenso/documenso-amd64:$GIT_SHA-chromium \
--amend ghcr.io/documenso/documenso-arm64:$GIT_SHA-chromium
docker manifest create \
ghcr.io/documenso/documenso:$APP_VERSION-chromium \
--amend ghcr.io/documenso/documenso-amd64:$APP_VERSION-chromium \
--amend ghcr.io/documenso/documenso-arm64:$APP_VERSION-chromium
docker manifest push ghcr.io/documenso/documenso:$GIT_SHA-chromium
docker manifest push ghcr.io/documenso/documenso:$APP_VERSION-chromium
+80
View File
@@ -0,0 +1,80 @@
---
description: Add and commit changes using conventional commits
allowed-tools: Bash, Read, Glob, Grep
---
Create a git commit for the current changes using the Conventional Commits standard.
## Process
1. **Analyze the changes** by running:
- `git status` to see all modified/untracked files
- `git diff` to see unstaged changes
- `git diff --staged` to see already-staged changes
- `git log --oneline -5` to see recent commit style
2. **Stage appropriate files**:
- Stage all related changes with `git add`
- Do NOT stage files that appear to contain secrets (.env, credentials, API keys, tokens)
- If you detect potential secrets, warn the user and skip those files
3. **Determine the commit type** based on the changes:
- `feat`: New feature or capability
- `fix`: Bug fix
- `docs`: Documentation only
- `style`: Formatting, whitespace (not CSS)
- `refactor`: Code restructuring without behavior change
- `perf`: Performance improvement
- `test`: Adding or updating tests
- `build`: Build system or dependencies
- `ci`: CI/CD configuration
- `chore`: Maintenance tasks, tooling, config
NOTE: Do not use a scope for commits
4. **Write the commit message**:
- **Subject line**: `<type>: <description>`
- Use imperative mood ("add" not "added")
- Lowercase, no period at end
- Max 50 characters if possible, 72 hard limit
- **Body** (if needed): Explain _why_, not _what_
- Wrap at 72 characters
- Separate from subject with blank line
## Commit Format
```
<type>[scope]: <subject>
[optional body explaining WHY this change was made]
```
## Examples
Simple change:
```
fix: handle empty input in parser without throwing
```
With body:
```
feat: add streaming response support
Large responses were causing memory issues in production.
Streaming allows processing chunks incrementally.
```
## Rules
- NEVER commit files that may contain secrets
- NEVER use `git commit --amend` unless the user explicitly requests it
- NEVER use `--no-verify` to skip hooks
- If the pre-commit hook fails, fix the issues and create a NEW commit
- If there are no changes to commit, inform the user and stop
- Use a HEREDOC to pass the commit message to ensure proper formatting
## Execute
Run the git commands to analyze, stage, and commit the changes now.
+112
View File
@@ -0,0 +1,112 @@
---
description: Continue implementing a spec from a previous session
argument-hint: <spec-file-path>
---
You are continuing implementation of a specification that was started in a previous session. Work autonomously until the feature is complete and tests pass.
## Your Task
1. **Read the spec** at `$ARGUMENTS`
2. **Read CODE_STYLE.md** for formatting conventions
3. **Assess current state**:
- Check git status for uncommitted changes
- Run tests to see what's passing/failing (if E2E tests exist)
- Review any existing implementation
4. **Determine what remains** by comparing the spec to the current state
5. **Plan remaining work** using TodoWrite
6. **Continue implementing** until complete
## Assessing Current State
Run these commands to understand where the previous session left off:
```bash
git status # See uncommitted changes
git log --oneline -10 # See recent commits
npm run typecheck -w @documenso/remix # Check for type errors
npm run lint:fix # Check for linting issues
```
Review the code that's already been written to understand:
- What's already implemented
- What's partially done
- What's not started yet
## Implementation Guidelines
### During Implementation
- Follow CODE_STYLE.md strictly (2-space indent, double quotes, braces always, etc.)
- Follow workspace rules for TypeScript, React, TRPC patterns, and Remix conventions
- Mark todos complete as you finish each task
- Commit logical chunks of work
### Code Quality
- No stubbed implementations
- Handle edge cases and error conditions
- Include descriptive error messages with context
- Use async/await for all I/O operations
- Use AppError class when throwing errors
- Use Zod for validation and react-hook-form for forms
### Testing
**Important**: E2E tests are time-consuming. Only write tests for non-trivial functionality.
- Write E2E tests in `packages/app-tests/e2e/` using Playwright
- Test critical user flows and edge cases
- Follow existing E2E test patterns in the codebase
- Use descriptive test names that explain what is being tested
- Skip tests for trivial changes (simple UI tweaks, minor refactors, etc.)
## Autonomous Workflow
Work continuously through these steps:
1. **Implement** - Write the code for the current task
2. **Typecheck** - Run `npm run typecheck -w @documenso/remix` to verify types
3. **Lint** - Run `npm run lint:fix` to fix linting issues
4. **Test** - If non-trivial, run E2E tests: `npm run test:dev -w @documenso/app-tests`
5. **Fix** - If tests fail, fix and re-run
6. **Repeat** - Move to next task
## Stopping Conditions
**Stop and report success when:**
- All spec requirements are implemented
- Typecheck passes
- Lint passes
- E2E tests pass (if written for non-trivial functionality)
**Stop and ask for help when:**
- The spec is ambiguous and you need clarification
- You encounter a blocking issue you cannot resolve
- You need to make a decision that significantly deviates from the spec
- External dependencies are missing
## Commands
```bash
# Type checking
npm run typecheck -w @documenso/remix
# Linting
npm run lint:fix
# E2E Tests (only for non-trivial work)
npm run test:dev -w @documenso/app-tests # Run E2E tests in dev mode
npm run test-ui:dev -w @documenso/app-tests # Run E2E tests with UI
npm run test:e2e # Run full E2E test suite
# Development
npm run dev # Start dev server
```
## Begin
Read the spec file and CODE_STYLE.md, assess the current implementation state, then continue where the previous session left off. Use TodoWrite to track your progress throughout.
@@ -0,0 +1,75 @@
---
description: Create a new justification file in .agents/justifications/
argument-hint: <justification-slug> [content]
---
You are creating a new justification file in the `.agents/justifications/` directory.
## Your Task
1. **Determine the slug** - Use `$ARGUMENTS` as the file slug (kebab-case recommended)
2. **Gather content** - Collect or generate the justification content
3. **Create the file** - Use the create-justification script to generate the file
## Usage
The script will automatically:
- Generate a unique three-word ID (e.g., `swift-emerald-river`)
- Create frontmatter with current date and formatted title
- Save the file as `{id}-{slug}.md` in `.agents/justifications/`
## Creating the File
### Option 1: Direct Content
If you have the content ready, run:
```bash
npx tsx scripts/create-justification.ts "$ARGUMENTS" "Your justification content here"
```
### Option 2: Multi-line Content (Heredoc)
For multi-line content, use heredoc:
```bash
npx tsx scripts/create-justification.ts "$ARGUMENTS" << HEREDOC
Your multi-line
justification content
goes here
HEREDOC
```
### Option 3: Pipe Content
You can also pipe content:
```bash
echo "Your content" | npx tsx scripts/create-justification.ts "$ARGUMENTS"
```
## File Format
The created file will have:
```markdown
---
date: 2026-01-13
title: Justification Title
---
Your content here
```
The title is automatically formatted from the slug (e.g., `architecture-decision``Architecture Decision`).
## Guidelines
- Use descriptive slugs in kebab-case (e.g., `tech-stack-choice`, `api-design-rationale`)
- Include clear reasoning and context for the decision
- The unique ID ensures no filename conflicts
- Files are automatically dated for organization
## Begin
Create a justification file using the slug from `$ARGUMENTS` and appropriate content documenting the reasoning or justification.
+76
View File
@@ -0,0 +1,76 @@
---
description: Create a new plan file in .agents/plans/
argument-hint: <plan-slug> [content]
---
You are creating a new plan file in the `.agents/plans/` directory.
## Your Task
1. **Determine the slug** - Use `$ARGUMENTS` as the file slug (kebab-case recommended)
2. **Gather content** - Collect or generate the plan content
3. **Create the file** - Use the create-plan script to generate the file
## Usage
The script will automatically:
- Generate a unique three-word ID (e.g., `happy-blue-moon`)
- Create frontmatter with current date and formatted title
- Save the file as `{id}-{slug}.md` in `.agents/plans/`
## Creating the File
### Option 1: Direct Content
If you have the content ready, run:
```bash
npx tsx scripts/create-plan.ts "$ARGUMENTS" "Your plan content here"
```
### Option 2: Multi-line Content (Heredoc)
For multi-line content, use heredoc:
```bash
npx tsx scripts/create-plan.ts "$ARGUMENTS" << HEREDOC
Your multi-line
plan content
goes here
HEREDOC
```
### Option 3: Pipe Content
You can also pipe content:
```bash
echo "Your content" | npx tsx scripts/create-plan.ts "$ARGUMENTS"
```
## File Format
The created file will have:
```markdown
---
date: 2026-01-13
title: Plan Title
---
Your content here
```
The title is automatically formatted from the slug (e.g., `my-feature``My Feature`).
## Guidelines
- Use descriptive slugs in kebab-case (e.g., `user-authentication`, `api-integration`)
- Include clear, actionable plan content
- The unique ID ensures no filename conflicts
- Files are automatically dated for organization
## Begin
Create a plan file using the slug from `$ARGUMENTS` and appropriate content for the planning task.
+75
View File
@@ -0,0 +1,75 @@
---
description: Create a new scratch file in .agents/scratches/
argument-hint: <scratch-slug> [content]
---
You are creating a new scratch file in the `.agents/scratches/` directory.
## Your Task
1. **Determine the slug** - Use `$ARGUMENTS` as the file slug (kebab-case recommended)
2. **Gather content** - Collect or generate the scratch content
3. **Create the file** - Use the create-scratch script to generate the file
## Usage
The script will automatically:
- Generate a unique three-word ID (e.g., `calm-teal-cloud`)
- Create frontmatter with current date and formatted title
- Save the file as `{id}-{slug}.md` in `.agents/scratches/`
## Creating the File
### Option 1: Direct Content
If you have the content ready, run:
```bash
npx tsx scripts/create-scratch.ts "$ARGUMENTS" "Your scratch content here"
```
### Option 2: Multi-line Content (Heredoc)
For multi-line content, use heredoc:
```bash
npx tsx scripts/create-scratch.ts "$ARGUMENTS" << HEREDOC
Your multi-line
scratch content
goes here
HEREDOC
```
### Option 3: Pipe Content
You can also pipe content:
```bash
echo "Your content" | npx tsx scripts/create-scratch.ts "$ARGUMENTS"
```
## File Format
The created file will have:
```markdown
---
date: 2026-01-13
title: Scratch Title
---
Your content here
```
The title is automatically formatted from the slug (e.g., `quick-notes``Quick Notes`).
## Guidelines
- Use descriptive slugs in kebab-case (e.g., `exploration-ideas`, `temporary-notes`)
- Scratch files are for temporary notes, explorations, or ideas
- The unique ID ensures no filename conflicts
- Files are automatically dated for organization
## Begin
Create a scratch file using the slug from `$ARGUMENTS` and appropriate content for notes or exploration.
+201
View File
@@ -0,0 +1,201 @@
---
description: Generate MDX documentation for a module or feature
argument-hint: <module-path-or-feature>
---
You are creating proper MDX documentation for a module or feature in Documenso using Nextra.
## Your Task
1. **Identify the scope** - What does `$ARGUMENTS` refer to? (file, directory, or feature name)
2. **Read the source code** - Understand the public API, types, and behavior
3. **Read existing docs** - Check if there's documentation to update or reference
4. **Write comprehensive documentation** - Create or update MDX docs in the appropriate location
5. **Update navigation** - Add entry to `_meta.js` if creating a new page
## Documentation Structure
Create documentation in the appropriate location:
- **Developer docs**: `apps/documentation/pages/developers/`
- **User docs**: `apps/documentation/pages/users/`
### File Format
All documentation files must be `.mdx` files with frontmatter:
```mdx
---
title: Page Title
description: Brief description for SEO and meta tags
---
# Page Title
Content starts here...
```
### Navigation
Each directory should have a `_meta.js` file that defines the navigation structure:
```javascript
export default {
index: 'Introduction',
'feature-name': 'Feature Name',
'another-feature': 'Another Feature',
};
```
If creating a new page, add it to the appropriate `_meta.js` file.
### Documentation Format
````mdx
---
title: <Module|Feature Name>
description: Brief description of what this does and when to use it
---
# <Module|Feature Name>
Brief description of what this module/feature does and when to use it.
## Installation
If there are specific packages or imports needed:
```bash
npm install @documenso/package-name
```
## Quick Start
```jsx
// Minimal working example
import { Component } from '@documenso/package';
const Example = () => {
return <Component />;
};
```
## API Reference
### Component/Function Name
Description of what it does.
#### Props/Parameters
| Prop/Param | Type | Description |
| ---------- | -------------------- | ------------------------- |
| prop | `string` | Description of the prop |
| optional | `boolean` (optional) | Optional prop description |
#### Example
```jsx
import { Component } from '@documenso/package';
<Component prop="value" optional={true} />;
```
### Types
#### `TypeName`
```typescript
type TypeName = {
property: string;
optional?: boolean;
};
```
## Examples
### Common Use Case
```jsx
// Full working example
```
### Advanced Usage
```jsx
// More complex example
```
## Related
- [Link to related documentation](/developers/path)
- [Another related page](/users/path)
````
## Guidelines
### Content Quality
- **Be accurate** - Verify behavior by reading the code
- **Be complete** - Document all public API surface
- **Be practical** - Include real, working examples
- **Be concise** - Don't over-explain obvious things
- **Be user-focused** - Write for the target audience (developers or users)
### Code Examples
- Use appropriate language tags: `jsx`, `tsx`, `typescript`, `bash`, `json`
- Show imports when not obvious
- Include expected output in comments where helpful
- Progress from simple to complex
- Use real examples from the codebase when possible
### Formatting
- Always include frontmatter with `title` and `description`
- Use proper markdown headers (h1 for title, h2 for sections)
- Use tables for props/parameters documentation (matching existing style)
- Use code fences with appropriate language tags
- Use Nextra components when appropriate:
- `<Callout type="info">` for notes
- `<Steps>` for step-by-step instructions
- Use relative links for internal documentation (e.g., `/developers/embedding/react`)
### Nextra Components
You can import and use Nextra components:
```jsx
import { Callout, Steps } from 'nextra/components';
<Callout type="info">
This is an informational note.
</Callout>
<Steps>
<Steps.Step>First step</Steps.Step>
<Steps.Step>Second step</Steps.Step>
</Steps>
```
### Maintenance
- Include types inline so docs don't get stale
- Reference source file locations for complex behavior
- Keep examples up-to-date with the codebase
- Update `_meta.js` when adding new pages
## Process
1. **Explore the code** - Read source files to understand the API
2. **Identify the audience** - Is this for developers or users?
3. **Check existing docs** - Look for similar pages to match style
4. **Draft the structure** - Outline sections before writing
5. **Write content** - Fill in each section with frontmatter
6. **Add examples** - Create working code samples
7. **Update navigation** - Add to `_meta.js` if needed
8. **Review** - Read through for clarity and accuracy
## Begin
Analyze `$ARGUMENTS`, read the relevant source code, check existing documentation patterns, and create comprehensive MDX documentation following the Documenso documentation style.
+100
View File
@@ -0,0 +1,100 @@
---
description: Implement a spec from the plans directory
argument-hint: <spec-file-path>
---
You are implementing a specification from the `.agents/plans/` directory. Work autonomously until the feature is complete and tests pass.
## Your Task
1. **Read the spec** at `$ARGUMENTS`
2. **Read CODE_STYLE.md** for formatting conventions
3. **Plan the implementation** using the TodoWrite tool to break down the work
4. **Implement the feature** following the spec and code style
5. **Write E2E tests** only for non-trivial functionality (E2E tests are time-consuming)
6. **Run tests** and fix any failures
7. **Run typecheck and lint** and fix any issues
## Implementation Guidelines
### Before Coding
- Understand the spec's goals and scope
- Identify the desired API from usage examples in the spec
- Review related existing code to understand patterns
- Break the work into discrete tasks using TodoWrite
### During Implementation
- Follow CODE_STYLE.md strictly (2-space indent, double quotes, braces always, etc.)
- Follow workspace rules for TypeScript, React, TRPC patterns, and Remix conventions
- Mark todos complete as you finish each task
- Commit logical chunks of work
### Code Quality
- No stubbed implementations
- Handle edge cases and error conditions
- Include descriptive error messages with context
- Use async/await for all I/O operations
- Use AppError class when throwing errors
- Use Zod for validation and react-hook-form for forms
### Testing
**Important**: E2E tests are time-consuming. Only write tests for non-trivial functionality.
- Write E2E tests in `packages/app-tests/e2e/` using Playwright
- Test critical user flows and edge cases
- Follow existing E2E test patterns in the codebase
- Use descriptive test names that explain what is being tested
- Skip tests for trivial changes (simple UI tweaks, minor refactors, etc.)
## Autonomous Workflow
Work continuously through these steps:
1. **Implement** - Write the code for the current task
2. **Typecheck** - Run `npm run typecheck -w @documenso/remix` to verify types
3. **Lint** - Run `npm run lint:fix` to fix linting issues
4. **Test** - If non-trivial, run E2E tests: `npm run test:dev -w @documenso/app-tests`
5. **Fix** - If tests fail, fix and re-run
6. **Repeat** - Move to next task
## Stopping Conditions
**Stop and report success when:**
- All spec requirements are implemented
- Typecheck passes
- Lint passes
- E2E tests pass (if written for non-trivial functionality)
**Stop and ask for help when:**
- The spec is ambiguous and you need clarification
- You encounter a blocking issue you cannot resolve
- You need to make a decision that significantly deviates from the spec
- External dependencies are missing
## Commands
```bash
# Type checking
npm run typecheck -w @documenso/remix
# Linting
npm run lint:fix
# E2E Tests (only for non-trivial work)
npm run test:dev -w @documenso/app-tests # Run E2E tests in dev mode
npm run test-ui:dev -w @documenso/app-tests # Run E2E tests with UI
npm run test:e2e # Run full E2E test suite
# Development
npm run dev # Start dev server
```
## Begin
Read the spec file and CODE_STYLE.md, then start implementing. Use TodoWrite to track your progress throughout.
+57
View File
@@ -0,0 +1,57 @@
---
description: Deep-dive interview to flesh out a spec or design document
agent: build
argument-hint: <file-path>
---
You are conducting a thorough interview to help flesh out and complete a specification or design document.
## Your Task
1. **Read the document** at `$ARGUMENTS`
2. **Analyze it deeply** - identify gaps, ambiguities, unexplored edge cases, and areas needing clarification
3. **Interview the user** by providing a question with some pre-determined options
4. **Write the completed spec** back to the file when the interview is complete
## Interview Guidelines
### Question Quality
- Ask **non-obvious, insightful questions** - avoid surface-level queries
- Focus on: technical implementation details, architectural decisions, edge cases, error handling, UX implications, security considerations, performance tradeoffs, integration points, migration strategies, rollback plans
- Each question should reveal something that would otherwise be missed
- Challenge assumptions embedded in the document
- Explore second and third-order consequences of design decisions
- Use the Web Search and other tools where required to ground questions (e.g. package recommendations)
### Question Strategy
- Start by identifying the 3-5 most critical unknowns or ambiguities
- Use the AskUserQuestion tool with well-crafted options that represent real tradeoffs
- When appropriate, offer multiple valid approaches with their pros/cons as options
- Don't ask about things that are already clearly specified
- Probe deeper when answers reveal new areas of uncertainty
### Topics to Explore (as relevant)
- **Technical**: Data models, API contracts, state management, concurrency, caching, validation
- **UX**: Error states, loading states, empty states, edge cases, accessibility, mobile considerations
- **Operations**: Deployment, monitoring, alerting, debugging, logging, feature flags
- **Security**: Auth, authz, input validation, rate limiting, audit trails
- **Scale**: Performance bottlenecks, data growth, traffic spikes, graceful degradation
- **Integration**: Dependencies, backwards compatibility, versioning, migration path
- **Failure modes**: What happens when X fails? How do we recover? What's the blast radius?
### Interview Flow
1. Ask 2-4 questions at a time (use multiple questions in one when they're related)
2. After each round, incorporate answers and identify follow-up questions
3. Continue until all critical areas are addressed
4. Signal when you believe the interview is complete, but offer to go deeper
## Output
When the interview is complete:
1. Synthesize all gathered information
2. Rewrite/expand the original document with the new details
3. Preserve the document's original structure where sensible, but reorganize if needed
4. Add new sections for areas that weren't originally covered
5. Write the completed spec back to `$ARGUMENTS`
Begin by reading the file and identifying your first set of deep questions.
@@ -0,0 +1,56 @@
---
name: create-justification
description: Create a new justification file in .agents/justifications/ with a unique three-word ID, frontmatter, and formatted title
license: MIT
compatibility: opencode
metadata:
audience: agents
workflow: decision-making
---
## What I do
I help you create new justification files in the `.agents/justifications/` directory. Each justification file gets:
- A unique three-word identifier (e.g., `swift-emerald-river`)
- Frontmatter with the current date and formatted title
- Content you provide
## How to use
Run the script with a slug and content:
```bash
npx tsx scripts/create-justification.ts "decision-name" "Justification content here"
```
Or use heredoc for multi-line content:
```bash
npx tsx scripts/create-justification.ts "decision-name" << HEREDOC
Multi-line
justification content
goes here
HEREDOC
```
## File format
Files are created as: `{three-word-id}-{slug}.md`
Example: `swift-emerald-river-decision-name.md`
The file includes frontmatter:
```markdown
---
date: 2026-01-13
title: Decision Name
---
Your content here
```
## When to use me
Use this skill when you need to document the reasoning or justification for a decision, approach, or architectural choice. The unique ID ensures no filename conflicts, and the frontmatter provides metadata for organization.
+56
View File
@@ -0,0 +1,56 @@
---
name: create-plan
description: Create a new plan file in .agents/plans/ with a unique three-word ID, frontmatter, and formatted title
license: MIT
compatibility: opencode
metadata:
audience: agents
workflow: planning
---
## What I do
I help you create new plan files in the `.agents/plans/` directory. Each plan file gets:
- A unique three-word identifier (e.g., `happy-blue-moon`)
- Frontmatter with the current date and formatted title
- Content you provide
## How to use
Run the script with a slug and content:
```bash
npx tsx scripts/create-plan.ts "feature-name" "Plan content here"
```
Or use heredoc for multi-line content:
```bash
npx tsx scripts/create-plan.ts "feature-name" << HEREDOC
Multi-line
plan content
goes here
HEREDOC
```
## File format
Files are created as: `{three-word-id}-{slug}.md`
Example: `happy-blue-moon-feature-name.md`
The file includes frontmatter:
```markdown
---
date: 2026-01-13
title: Feature Name
---
Your content here
```
## When to use me
Use this skill when you need to create a new plan document for a feature, task, or project. The unique ID ensures no filename conflicts, and the frontmatter provides metadata for organization.
+56
View File
@@ -0,0 +1,56 @@
---
name: create-scratch
description: Create a new scratch file in .agents/scratches/ with a unique three-word ID, frontmatter, and formatted title
license: MIT
compatibility: opencode
metadata:
audience: agents
workflow: exploration
---
## What I do
I help you create new scratch files in the `.agents/scratches/` directory. Each scratch file gets:
- A unique three-word identifier (e.g., `calm-teal-cloud`)
- Frontmatter with the current date and formatted title
- Content you provide
## How to use
Run the script with a slug and content:
```bash
npx tsx scripts/create-scratch.ts "note-name" "Scratch content here"
```
Or use heredoc for multi-line content:
```bash
npx tsx scripts/create-scratch.ts "note-name" << HEREDOC
Multi-line
scratch content
goes here
HEREDOC
```
## File format
Files are created as: `{three-word-id}-{slug}.md`
Example: `calm-teal-cloud-note-name.md`
The file includes frontmatter:
```markdown
---
date: 2026-01-13
title: Note Name
---
Your content here
```
## When to use me
Use this skill when you need to create a temporary note, exploration document, or scratch pad for ideas. The unique ID ensures no filename conflicts, and the frontmatter provides metadata for organization.
+2
View File
@@ -11,6 +11,8 @@
- `npm run format` - Format code with Prettier
- `npm run dev` - Start development server for Remix app
**Important:** Do not run `npm run build` to verify changes unless explicitly asked. Builds take a long time (~2 minutes). Use `npx tsc --noEmit` for type checking specific packages if needed.
## Code Style Guidelines
- Use TypeScript for all code; prefer `type` over `interface`
+50
View File
@@ -52,3 +52,53 @@ You can build the project with:
```bash
npm run build
```
## AI-Assisted Development with OpenCode
We use [OpenCode](https://opencode.ai) for AI-assisted development. OpenCode provides custom commands and skills to help maintain consistency and streamline common workflows.
OpenCode works with most major AI providers (Anthropic, OpenAI, Google, etc.) or you can use [Zen](https://opencode.ai/zen) for optimized coding models. Configure your preferred provider in the OpenCode settings.
> **Important**: All AI-generated code must be thoroughly reviewed by the contributor before submitting a PR. You are responsible for understanding and validating every line of code you submit. If we detect that contributors are simply throwing AI-generated code over the wall without proper review, they will be blocked from the repository.
### Getting Started
1. Install OpenCode (see [opencode.ai](https://opencode.ai) for other install methods):
```bash
curl -fsSL https://opencode.ai/install | bash
```
2. Configure your AI provider (or use Zen for optimized models)
3. Run `opencode` in the project root
### Available Commands
Use these commands in OpenCode by typing the command name:
| Command | Description |
| ------------------------------ | -------------------------------------------------------- |
| `/implement <spec-path>` | Implement a spec from `.agents/plans/` autonomously |
| `/continue <spec-path>` | Continue implementing a spec from a previous session |
| `/interview <file-path>` | Deep-dive interview to flesh out a spec or design |
| `/document <module-path>` | Generate MDX documentation for a module or feature |
| `/commit` | Create a conventional commit for staged changes |
| `/create-plan <slug>` | Create a new plan file in `.agents/plans/` |
| `/create-scratch <slug>` | Create a scratch file for notes in `.agents/scratches/` |
| `/create-justification <slug>` | Create a justification file in `.agents/justifications/` |
### Typical Workflow
1. **Create a plan**: Use `/create-plan my-feature` to draft a spec for a new feature
2. **Flesh out the spec**: Use `/interview .agents/plans/<file>.md` to refine requirements
3. **Implement**: Use `/implement .agents/plans/<file>.md` to build the feature
4. **Continue if needed**: Use `/continue .agents/plans/<file>.md` to pick up where you left off
5. **Commit**: Use `/commit` to create a conventional commit
### Agent Files
The `.agents/` directory stores AI-generated artifacts:
- **`.agents/plans/`** - Feature specs and implementation plans
- **`.agents/scratches/`** - Temporary notes and explorations
- **`.agents/justifications/`** - Decision rationale and technical justifications
These files use a unique ID format (`{word}-{word}-{word}-{slug}.md`) to prevent conflicts.
@@ -291,10 +291,13 @@ For AI setup specifics, see the [AI Recipient & Field Detection (Self-hosting)](
| `NEXT_PUBLIC_SUPPORT_EMAIL` | The support email address displayed to users (default `support@documenso.com`). |
| `NEXT_PRIVATE_DATABASE_URL` | The URL for the primary database connection (with connection pooling). |
| `NEXT_PRIVATE_DIRECT_DATABASE_URL` | The URL for the direct database connection (without connection pooling). |
| `NEXT_PRIVATE_SIGNING_TRANSPORT` | The signing transport to use. Available options: local (default) |
| `NEXT_PRIVATE_SIGNING_TRANSPORT` | The signing transport to use. Available options: local (default), gcloud-hsm |
| `NEXT_PRIVATE_SIGNING_PASSPHRASE` | The passphrase for the key file. |
| `NEXT_PRIVATE_SIGNING_LOCAL_FILE_CONTENTS` | The base64-encoded contents of the key file will be used instead of the file path. |
| `NEXT_PRIVATE_SIGNING_LOCAL_FILE_PATH` | The path to the key file, default `/opt/documenso/cert.p12`. |
| `NEXT_PRIVATE_SIGNING_TIMESTAMP_AUTHORITY` | Comma-separated list of timestamp authority URLs for PDF signing. Enables LTV and archival timestamps. |
| `NEXT_PUBLIC_SIGNING_CONTACT_INFO` | Contact info to embed in PDF signatures. Defaults to the webapp URL. |
| `NEXT_PRIVATE_USE_LEGACY_SIGNING_SUBFILTER` | Set to "true" to use the legacy adbe.pkcs7.detached subfilter instead of ETSI.CAdES.detached. |
| `NEXT_PUBLIC_UPLOAD_TRANSPORT` | The transport for file uploads (database or s3). |
| `NEXT_PRIVATE_UPLOAD_ENDPOINT` | The endpoint for the S3 storage transport (for third-party S3-compatible providers). |
| `NEXT_PRIVATE_UPLOAD_FORCE_PATH_STYLE` | Whether to force path-style URLs for the S3 storage transport. |
@@ -53,15 +53,21 @@ Have the Certificate Authority sign the Certificate Signing Request.
Configure your instance to use the new certificate by configuring the following environment variables in your `.env` file:
| Environment Variable | Description |
| :-------------------------------------------------------------- | :---------------------------------------------------------------------------------------------------------------------------------------- |
| `NEXT_PRIVATE_SIGNING_TRANSPORT` | The transport used for document signing. Available options: local (default), gcloud-hsm |
| `NEXT_PRIVATE_SIGNING_PASSPHRASE` | The passphrase for the local file-based signing transport. This field is optional. |
| `NEXT_PRIVATE_SIGNING_LOCAL_FILE_PATH` | The local file path to the .p12 file to use for the local signing transport. This field is optional. |
| `NEXT_PRIVATE_SIGNING_LOCAL_FILE_CONTENTS` | The base64-encoded contents of the .p12 file to use for the local signing transport. This field is optional. |
| `NEXT_PRIVATE_SIGNING_GCLOUD_HSM_KEY_PATH` | The Google Cloud HSM key path for the gcloud-hsm signing transport. This field is optional. |
| `NEXT_PRIVATE_SIGNING_GCLOUD_HSM _PUBLIC_CRT_FILE_PATH` | The path to the Google Cloud HSM public certificate file to use for the gcloud-hsm signing transport. This field is optional. |
| `NEXT_PRIVATE_SIGNING_GCLOUD_HSM _PUBLIC_CRT_FILE_CONTENTS` | The base64-encoded contents of the Google Cloud HSM public certificate file for the gcloud-hsm signing transport. This field is optional. |
| `NEXT_PRIVATE_SIGNING_GCLOUD_ APPLICATION_CREDENTIALS_CONTENTS` | The Google Cloud Credentials file path for the gcloud-hsm signing transport. This field is optional. |
| Environment Variable | Description |
| :------------------------------------------------------------- | :---------------------------------------------------------------------------------------------------------------------------------------- |
| `NEXT_PRIVATE_SIGNING_TRANSPORT` | The transport used for document signing. Available options: local (default), gcloud-hsm |
| `NEXT_PRIVATE_SIGNING_PASSPHRASE` | The passphrase for the local file-based signing transport. This field is optional. |
| `NEXT_PRIVATE_SIGNING_LOCAL_FILE_PATH` | The local file path to the .p12 file to use for the local signing transport. This field is optional. |
| `NEXT_PRIVATE_SIGNING_LOCAL_FILE_CONTENTS` | The base64-encoded contents of the .p12 file to use for the local signing transport. This field is optional. |
| `NEXT_PRIVATE_SIGNING_GCLOUD_HSM_KEY_PATH` | The Google Cloud HSM key path for the gcloud-hsm signing transport. This field is optional. |
| `NEXT_PRIVATE_SIGNING_GCLOUD_HSM_PUBLIC_CRT_FILE_PATH` | The path to the Google Cloud HSM public certificate file to use for the gcloud-hsm signing transport. This field is optional. |
| `NEXT_PRIVATE_SIGNING_GCLOUD_HSM_PUBLIC_CRT_FILE_CONTENTS` | The base64-encoded contents of the Google Cloud HSM public certificate file for the gcloud-hsm signing transport. This field is optional. |
| `NEXT_PRIVATE_SIGNING_GCLOUD_APPLICATION_CREDENTIALS_CONTENTS` | The base64-encoded Google Cloud Credentials for the gcloud-hsm signing transport. This field is optional. |
| `NEXT_PRIVATE_SIGNING_GCLOUD_HSM_CERT_CHAIN_FILE_PATH` | The path to the certificate chain file for the gcloud-hsm signing transport. This field is optional. |
| `NEXT_PRIVATE_SIGNING_GCLOUD_HSM_CERT_CHAIN_CONTENTS` | The base64-encoded contents of the certificate chain for the gcloud-hsm signing transport. This field is optional. |
| `NEXT_PRIVATE_SIGNING_GCLOUD_HSM_SECRET_MANAGER_CERT_PATH` | The Google Secret Manager path to retrieve the certificate for the gcloud-hsm signing transport. This field is optional. |
| `NEXT_PRIVATE_SIGNING_TIMESTAMP_AUTHORITY` | Comma-separated list of timestamp authority URLs for PDF signing. Enables LTV and archival timestamps. This field is optional. |
| `NEXT_PUBLIC_SIGNING_CONTACT_INFO` | Contact info to embed in PDF signatures. Defaults to the webapp URL. This field is optional. |
| `NEXT_PRIVATE_USE_LEGACY_SIGNING_SUBFILTER` | Set to "true" to use the legacy adbe.pkcs7.detached subfilter instead of ETSI.CAdES.detached. This field is optional. |
</Steps>
@@ -5,4 +5,5 @@ export default {
fields: 'Document Fields',
'email-preferences': 'Email Preferences',
'ai-detection': 'AI Recipient & Field Detection',
'default-recipients': 'Default Recipients',
};
@@ -0,0 +1,45 @@
---
title: Default Document Recipients
description: Learn how to set default recipients with various roles for your documents.
---
import { Callout, Steps } from 'nextra/components';
# Default Document Recipients
Documenso allows you to set default recipients for your documents. This is useful when you require specific recipients to be added to every document you send.
You can add default recipients with the same roles as the recipients you can add when sending a document:
- **Signer** - The recipient will be required to sign the document.
- **Approver** - The recipient will be required to approve the document.
- **Viewer** - The recipient will be required to view the document.
- **CC** - The recipient will receive a copy of the document.
You can set default recipients at the organisation or team level.
### Organisation level
To set default recipients at the organisation level, navigate to the organisation settings page and click the "Document" tab under the "Preferences" section.
Then scroll down to the "Default Recipients" section and add the recipients you want to be included in every document you send.
![A screenshot of the organisation's default recipients page](/default-recipients/organisation-default-recipients-select-step.webp)
The recipients are added with the "CC" role by default, but you can select a different role for each recipient.
![A screenshot of the organisation's default recipients page when selecting the role of the recipient](/default-recipients/organisation-default-recipients-role-step.webp)
### Team level
Setting the default recipients at the team level follows the same process as setting them at the organisation level.
<Callout type="info">
Setting the default recipients at the team level will override organisation-level defaults.
</Callout>
To set default recipients at the team level, navigate to the team settings page and click the "Document" tab under the "Preferences" section.
Then scroll down to the "Default Recipients" section. By default, the team will inherit the default recipients from the organisation. You can override these defaults by adding the recipients you want to be added to every document you send.
![A screenshot of the team's default recipients page](/default-recipients/team-default-recipients.webp)
Binary file not shown.

After

Width:  |  Height:  |  Size: 555 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 928 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 897 KiB

@@ -3,13 +3,7 @@ import { useEffect, useMemo, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { useLingui } from '@lingui/react/macro';
import { Trans } from '@lingui/react/macro';
import {
DocumentDistributionMethod,
DocumentStatus,
EnvelopeType,
FieldType,
RecipientRole,
} from '@prisma/client';
import { DocumentDistributionMethod, DocumentStatus, EnvelopeType } from '@prisma/client';
import { AnimatePresence, motion } from 'framer-motion';
import { InfoIcon } from 'lucide-react';
import { useForm } from 'react-hook-form';
@@ -20,6 +14,7 @@ import * as z from 'zod';
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
import { getRecipientsWithMissingFields } from '@documenso/lib/utils/recipients';
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';
@@ -140,14 +135,7 @@ export const EnvelopeDistributeDialog = ({
);
const recipientsMissingSignatureFields = useMemo(
() =>
recipientsWithIndex.filter(
(recipient) =>
recipient.role === RecipientRole.SIGNER &&
!envelope.fields.some(
(field) => field.type === FieldType.SIGNATURE && field.recipientId === recipient.id,
),
),
() => getRecipientsWithMissingFields(recipientsWithIndex, envelope.fields),
[recipientsWithIndex, envelope.fields],
);
@@ -73,7 +73,9 @@ export const PasskeyCreateDialog = ({ trigger, onSuccess, ...props }: PasskeyCre
try {
const passkeyRegistrationOptions = await createPasskeyRegistrationOptions();
const registrationResult = await startRegistration(passkeyRegistrationOptions);
const registrationResult = await startRegistration({
optionsJSON: passkeyRegistrationOptions,
});
await createPasskey({
passkeyName,
@@ -82,9 +82,7 @@ export const SignFieldCheckboxDialog = createCallable<
<Dialog open={true} onOpenChange={(value) => (!value ? call.end(null) : null)}>
<DialogContent position="center">
<DialogHeader>
<DialogTitle>
<Trans>Sign Checkbox Field</Trans>
</DialogTitle>
<DialogTitle>{fieldMeta.label || <Trans>Select Options</Trans>}</DialogTitle>
<DialogDescription
className={cn('mt-4', {
@@ -143,7 +141,7 @@ export const SignFieldCheckboxDialog = createCallable<
<div className="flex items-center">
<Checkbox
id={`checkbox-value-${index}`}
className="data-[state=checked]:bg-primary border-foreground/30 h-5 w-5"
className="h-5 w-5 border-foreground/30 data-[state=checked]:bg-primary"
checked={field.value.checked}
onCheckedChange={(checked) => {
field.onChange({
@@ -154,7 +152,7 @@ export const SignFieldCheckboxDialog = createCallable<
/>
<label
className="text-muted-foreground ml-2 w-full text-sm"
className="ml-2 w-full text-sm text-muted-foreground"
htmlFor={`checkbox-value-${index}`}
>
{value.value}
@@ -174,7 +172,7 @@ export const SignFieldCheckboxDialog = createCallable<
</Button>
<Button type="submit">
<Trans>Sign</Trans>
<Trans>Confirm</Trans>
</Button>
</DialogFooter>
</fieldset>
@@ -50,11 +50,11 @@ export const SignFieldEmailDialog = createCallable<SignFieldEmailDialogProps, st
<DialogContent>
<DialogHeader>
<DialogTitle>
<Trans>Sign Email</Trans>
<Trans>Enter Email</Trans>
</DialogTitle>
<DialogDescription className="mt-4">
<Trans>Sign your email into the field</Trans>
<Trans>Please enter your email address</Trans>
</DialogDescription>
</DialogHeader>
@@ -83,7 +83,7 @@ export const SignFieldEmailDialog = createCallable<SignFieldEmailDialogProps, st
</Button>
<Button type="submit">
<Trans>Sign</Trans>
<Trans>Enter</Trans>
</Button>
</DialogFooter>
</fieldset>
@@ -48,11 +48,11 @@ export const SignFieldInitialsDialog = createCallable<SignFieldInitialsDialogPro
<DialogContent>
<DialogHeader>
<DialogTitle>
<Trans>Sign Initials</Trans>
<Trans>Enter Initials</Trans>
</DialogTitle>
<DialogDescription className="mt-4">
<Trans>Sign your initials into the field</Trans>
<Trans>Please enter your initials</Trans>
</DialogDescription>
</DialogHeader>
@@ -84,7 +84,7 @@ export const SignFieldInitialsDialog = createCallable<SignFieldInitialsDialogPro
</Button>
<Button type="submit">
<Trans>Sign</Trans>
<Trans>Enter</Trans>
</Button>
</DialogFooter>
</fieldset>
@@ -47,11 +47,11 @@ export const SignFieldNameDialog = createCallable<SignFieldNameDialogProps, stri
<DialogContent>
<DialogHeader>
<DialogTitle>
<Trans>Sign Name</Trans>
<Trans>Enter Name</Trans>
</DialogTitle>
<DialogDescription className="mt-4">
<Trans>Sign your full name into the field</Trans>
<Trans>Please enter your full name</Trans>
</DialogDescription>
</DialogHeader>
@@ -80,7 +80,7 @@ export const SignFieldNameDialog = createCallable<SignFieldNameDialogProps, stri
</Button>
<Button type="submit">
<Trans>Sign</Trans>
<Trans>Enter</Trans>
</Button>
</DialogFooter>
</fieldset>
@@ -22,7 +22,6 @@ import {
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
@@ -107,12 +106,10 @@ export const SignFieldNumberDialog = createCallable<SignFieldNumberDialogProps,
<Dialog open={true} onOpenChange={(value) => (!value ? call.end(null) : null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>
<Trans>Sign Number Field</Trans>
</DialogTitle>
<DialogTitle>{fieldMeta.label || <Trans>Enter Number</Trans>}</DialogTitle>
<DialogDescription className="mt-4">
<Trans>Insert a value into the number field</Trans>
<Trans>Please enter a number</Trans>
</DialogDescription>
</DialogHeader>
@@ -127,8 +124,6 @@ export const SignFieldNumberDialog = createCallable<SignFieldNumberDialogProps,
name="number"
render={({ field, fieldState }) => (
<FormItem>
{fieldMeta.label && <FormLabel>{fieldMeta.label}</FormLabel>}
<FormControl>
<Input
placeholder={fieldMeta.placeholder ?? t`Enter your number here`}
@@ -150,7 +145,7 @@ export const SignFieldNumberDialog = createCallable<SignFieldNumberDialogProps,
</Button>
<Button type="submit">
<Trans>Sign</Trans>
<Trans>Enter</Trans>
</Button>
</DialogFooter>
</fieldset>
@@ -22,7 +22,6 @@ import {
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Textarea } from '@documenso/ui/primitives/textarea';
@@ -52,12 +51,10 @@ export const SignFieldTextDialog = createCallable<SignFieldTextDialogProps, stri
<Dialog open={true} onOpenChange={(value) => (!value ? call.end(null) : null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>
<Trans>Sign Text Field</Trans>
</DialogTitle>
<DialogTitle>{fieldMeta?.label || <Trans>Enter Text</Trans>}</DialogTitle>
<DialogDescription className="mt-4">
<Trans>Insert a value into the text field</Trans>
<Trans>Please enter a value</Trans>
</DialogDescription>
</DialogHeader>
@@ -72,8 +69,6 @@ export const SignFieldTextDialog = createCallable<SignFieldTextDialogProps, stri
name="text"
render={({ field, fieldState }) => (
<FormItem>
{fieldMeta?.label && <FormLabel>{fieldMeta?.label}</FormLabel>}
<FormControl>
<Textarea
id="custom-text"
@@ -89,7 +84,7 @@ export const SignFieldTextDialog = createCallable<SignFieldTextDialogProps, stri
{fieldMeta?.characterLimit !== undefined &&
fieldMeta?.characterLimit > 0 &&
!fieldState.error && (
<div className="text-muted-foreground text-sm">
<div className="text-sm text-muted-foreground">
<Plural
value={fieldMeta?.characterLimit - (field.value?.length ?? 0)}
one="# character remaining"
@@ -107,7 +102,7 @@ export const SignFieldTextDialog = createCallable<SignFieldTextDialogProps, stri
</Button>
<Button type="submit">
<Trans>Sign</Trans>
<Trans>Enter</Trans>
</Button>
</DialogFooter>
</fieldset>
@@ -54,6 +54,8 @@ type TemplateDirectLinkDialogProps = {
directLink?: Pick<TemplateDirectLink, 'token' | 'enabled'> | null;
recipients: Recipient[];
trigger?: React.ReactNode;
onCreateSuccess?: () => Promise<void> | void;
onDeleteSuccess?: () => Promise<void> | void;
};
type TemplateDirectLinkStep = 'ONBOARD' | 'SELECT_RECIPIENT' | 'MANAGE' | 'CONFIRM_DELETE';
@@ -63,6 +65,8 @@ export const TemplateDirectLinkDialog = ({
directLink,
recipients,
trigger,
onCreateSuccess,
onDeleteSuccess,
}: TemplateDirectLinkDialogProps) => {
const { toast } = useToast();
const { quota, remaining } = useLimits();
@@ -97,6 +101,7 @@ export const TemplateDirectLinkDialog = ({
} = trpcReact.template.createTemplateDirectLink.useMutation({
onSuccess: async (data) => {
await revalidate();
await onCreateSuccess?.();
setToken(data.token);
setIsEnabled(data.enabled);
@@ -142,6 +147,7 @@ export const TemplateDirectLinkDialog = ({
trpcReact.template.deleteTemplateDirectLink.useMutation({
onSuccess: async () => {
await revalidate();
await onDeleteSuccess?.();
setOpen(false);
setToken(null);
@@ -234,7 +240,7 @@ export const TemplateDirectLinkDialog = ({
</div>
<h3 className="font-semibold">{_(step.title)}</h3>
<p className="text-muted-foreground mt-1 text-sm">{_(step.description)}</p>
<p className="mt-1 text-sm text-muted-foreground">{_(step.description)}</p>
</li>
))}
</ul>
@@ -320,13 +326,13 @@ export const TemplateDirectLinkDialog = ({
onClick={async () => onRecipientTableRowClick(row.id)}
>
<TableCell>
<div className="text-muted-foreground text-sm">
<div className="text-sm text-muted-foreground">
<p>{row.name}</p>
<p className="text-muted-foreground/70 text-xs">{row.email}</p>
<p className="text-xs text-muted-foreground/70">{row.email}</p>
</div>
</TableCell>
<TableCell className="text-muted-foreground text-sm">
<TableCell className="text-sm text-muted-foreground">
{_(RECIPIENT_ROLES_DESCRIPTION[row.role].roleName)}
</TableCell>
@@ -350,7 +356,7 @@ export const TemplateDirectLinkDialog = ({
<DialogFooter className="mx-auto">
<div className="flex flex-col items-center justify-center">
{validDirectTemplateRecipients.length !== 0 && (
<p className="text-muted-foreground text-sm">
<p className="text-sm text-muted-foreground">
<Trans>Or</Trans>
</p>
)}
@@ -392,7 +398,7 @@ export const TemplateDirectLinkDialog = ({
<TooltipTrigger tabIndex={-1} className="ml-2">
<InfoIcon className="h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="text-foreground z-9999 max-w-md p-4">
<TooltipContent className="z-9999 max-w-md p-4 text-foreground">
<Trans>
Disabling direct link signing will prevent anyone from accessing the
link.
@@ -3,8 +3,14 @@ import { useEffect, useLayoutEffect, useState } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import type { DocumentMeta, EnvelopeItem, Recipient, Signature } from '@prisma/client';
import { type Field, FieldType } from '@prisma/client';
import {
type DocumentMeta,
type EnvelopeItem,
type Field,
FieldType,
type Recipient,
type Signature,
} from '@prisma/client';
import { LucideChevronDown, LucideChevronUp } from 'lucide-react';
import { DateTime } from 'luxon';
import { useSearchParams } from 'react-router';
@@ -18,6 +24,7 @@ import {
isRequiredField,
} from '@documenso/lib/utils/advanced-fields-helpers';
import { validateFieldsInserted } from '@documenso/lib/utils/fields';
import { isSignatureFieldType } from '@documenso/prisma/guards/is-signature-field';
import { trpc } from '@documenso/trpc/react';
import type {
TRemovedSignedFieldWithTokenMutationSchema,
@@ -96,7 +103,7 @@ export const EmbedDirectTemplateClientPage = ({
const highestPendingPageNumber = Math.max(...pendingFields.map((field) => field.page));
const hasSignatureField = localFields.some((field) => field.type === FieldType.SIGNATURE);
const hasSignatureField = localFields.some((field) => isSignatureFieldType(field.type));
const signatureValid = !hasSignatureField || (signature && signature.trim() !== '');
@@ -4,13 +4,14 @@ import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import type { DocumentMeta, EnvelopeItem } from '@prisma/client';
import { type Field, FieldType, RecipientRole, SigningStatus } from '@prisma/client';
import { type Field, RecipientRole, SigningStatus } from '@prisma/client';
import { LucideChevronDown, LucideChevronUp } from 'lucide-react';
import { useThrottleFn } from '@documenso/lib/client-only/hooks/use-throttle-fn';
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
import { isFieldUnsignedAndRequired } from '@documenso/lib/utils/advanced-fields-helpers';
import { validateFieldsInserted } from '@documenso/lib/utils/fields';
import { isSignatureFieldType } from '@documenso/prisma/guards/is-signature-field';
import type { RecipientWithFields } from '@documenso/prisma/types/recipient-with-fields';
import { trpc } from '@documenso/trpc/react';
import {
@@ -115,7 +116,7 @@ export const EmbedSignDocumentV1ClientPage = ({
[fields],
);
const hasSignatureField = fields.some((field) => field.type === FieldType.SIGNATURE);
const hasSignatureField = fields.some((field) => isSignatureFieldType(field.type));
const signatureValid = !hasSignatureField || (signature && signature.trim() !== '');
@@ -3,12 +3,13 @@ import { useState } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { DocumentStatus, FieldType, SigningStatus } from '@prisma/client';
import { DocumentStatus, SigningStatus } from '@prisma/client';
import { Loader, LucideChevronDown, LucideChevronUp, X } from 'lucide-react';
import { P, match } from 'ts-pattern';
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { isSignatureFieldType } from '@documenso/prisma/guards/is-signature-field';
import { trpc } from '@documenso/trpc/react';
import type {
TRemovedSignedFieldWithTokenMutationSchema,
@@ -83,7 +84,7 @@ export const MultiSignDocumentSigningView = ({
const { mutateAsync: completeDocumentWithToken } =
trpc.recipient.completeDocumentWithToken.useMutation();
const hasSignatureField = document?.fields.some((field) => field.type === FieldType.SIGNATURE);
const hasSignatureField = document?.fields.some((field) => isSignatureFieldType(field.type));
const [pendingFields, completedFields] = [
document?.fields.filter((field) => field.recipient.signingStatus !== SigningStatus.SIGNED) ??
@@ -3,7 +3,7 @@ import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react/macro';
import { Trans } from '@lingui/react/macro';
import type { TeamGlobalSettings } from '@prisma/client';
import { DocumentVisibility, OrganisationType } from '@prisma/client';
import { DocumentVisibility, OrganisationType, type RecipientRole } from '@prisma/client';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
@@ -17,14 +17,19 @@ import {
isValidLanguageCode,
} from '@documenso/lib/constants/i18n';
import { TIME_ZONES } from '@documenso/lib/constants/time-zones';
import type { TDefaultRecipients } from '@documenso/lib/types/default-recipients';
import { ZDefaultRecipientsSchema } from '@documenso/lib/types/default-recipients';
import {
type TDocumentMetaDateFormat,
ZDocumentMetaTimezoneSchema,
} from '@documenso/lib/types/document-meta';
import { isPersonalLayout } from '@documenso/lib/utils/organisations';
import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter';
import { extractTeamSignatureSettings } from '@documenso/lib/utils/teams';
import { DocumentSignatureSettingsTooltip } from '@documenso/ui/components/document/document-signature-settings-tooltip';
import { RecipientRoleSelect } from '@documenso/ui/components/recipient/recipient-role-select';
import { Alert } from '@documenso/ui/primitives/alert';
import { AvatarWithText } from '@documenso/ui/primitives/avatar';
import { Button } from '@documenso/ui/primitives/button';
import { Combobox } from '@documenso/ui/primitives/combobox';
import {
@@ -45,6 +50,10 @@ import {
SelectValue,
} from '@documenso/ui/primitives/select';
import { useOptionalCurrentTeam } from '~/providers/team';
import { DefaultRecipientsMultiSelectCombobox } from '../general/default-recipients-multiselect-combobox';
/**
* Can't infer this from the schema since we need to keep the schema inside the component to allow
* it to be dynamic.
@@ -58,6 +67,7 @@ export type TDocumentPreferencesFormSchema = {
includeSigningCertificate: boolean | null;
includeAuditLog: boolean | null;
signatureTypes: DocumentSignatureType[];
defaultRecipients: TDefaultRecipients | null;
delegateDocumentOwnership: boolean | null;
aiFeaturesEnabled: boolean | null;
};
@@ -74,6 +84,7 @@ type SettingsSubset = Pick<
| 'typedSignatureEnabled'
| 'uploadSignatureEnabled'
| 'drawSignatureEnabled'
| 'defaultRecipients'
| 'delegateDocumentOwnership'
| 'aiFeaturesEnabled'
>;
@@ -94,6 +105,7 @@ export const DocumentPreferencesForm = ({
const { t } = useLingui();
const { user, organisations } = useSession();
const currentOrganisation = useCurrentOrganisation();
const optionalTeam = useOptionalCurrentTeam();
const isPersonalLayoutMode = isPersonalLayout(organisations);
const isPersonalOrganisation = currentOrganisation.type === OrganisationType.PERSONAL;
@@ -111,6 +123,7 @@ export const DocumentPreferencesForm = ({
signatureTypes: z.array(z.nativeEnum(DocumentSignatureType)).min(canInherit ? 0 : 1, {
message: msg`At least one signature type must be enabled`.id,
}),
defaultRecipients: ZDefaultRecipientsSchema.nullable(),
delegateDocumentOwnership: z.boolean().nullable(),
aiFeaturesEnabled: z.boolean().nullable(),
});
@@ -128,6 +141,9 @@ export const DocumentPreferencesForm = ({
includeSigningCertificate: settings.includeSigningCertificate,
includeAuditLog: settings.includeAuditLog,
signatureTypes: extractTeamSignatureSettings({ ...settings }),
defaultRecipients: settings.defaultRecipients
? ZDefaultRecipientsSchema.parse(settings.defaultRecipients)
: null,
delegateDocumentOwnership: settings.delegateDocumentOwnership,
aiFeaturesEnabled: settings.aiFeaturesEnabled,
},
@@ -519,6 +535,94 @@ export const DocumentPreferencesForm = ({
)}
/>
<FormField
control={form.control}
name="defaultRecipients"
render={({ field }) => {
const recipients = field.value ?? [];
return (
<FormItem className="flex-1">
<FormLabel>
<Trans>Default Recipients</Trans>
</FormLabel>
{canInherit && (
<Select
value={field.value === null ? '-1' : '0'}
onValueChange={(value) => field.onChange(value === '-1' ? null : [])}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={'-1'}>
<Trans>Inherit from organisation</Trans>
</SelectItem>
<SelectItem value={'0'}>
<Trans>Override organisation settings</Trans>
</SelectItem>
</SelectContent>
</Select>
)}
{(field.value !== null || !canInherit) && (
<div className="space-y-4">
<DefaultRecipientsMultiSelectCombobox
listValues={recipients}
onChange={field.onChange}
organisationId={!canInherit ? currentOrganisation.id : undefined}
teamId={canInherit ? optionalTeam?.id : undefined}
/>
{recipients.map((recipient, index) => {
return (
<div
key={recipient.email}
className="flex items-center justify-between gap-3 rounded-lg border p-3"
>
<AvatarWithText
avatarFallback={recipientAbbreviation(recipient)}
primaryText={
<span className="text-sm font-medium">
{recipient.name || recipient.email}
</span>
}
secondaryText={
recipient.name ? (
<span className="text-xs text-muted-foreground">
{recipient.email}
</span>
) : undefined
}
className="flex-1"
/>
<div className="flex items-center gap-2">
<RecipientRoleSelect
value={recipient.role}
onValueChange={(role: RecipientRole) => {
field.onChange(
recipients.map((recipient, idx) =>
idx === index ? { ...recipient, role } : recipient,
),
);
}}
/>
</div>
</div>
);
})}
</div>
)}
<FormDescription>
<Trans>Recipients that will be automatically added to new documents.</Trans>
</FormDescription>
</FormItem>
);
}}
/>
<FormField
control={form.control}
name="delegateDocumentOwnership"
+1 -1
View File
@@ -171,7 +171,7 @@ export const SignInForm = ({
const { options, sessionId } = await createPasskeySigninOptions();
const credential = await startAuthentication(options);
const credential = await startAuthentication({ optionsJSON: options });
await authClient.passkey.signIn({
credential: JSON.stringify(credential),
@@ -0,0 +1,90 @@
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { RecipientRole } from '@prisma/client';
import type { TDefaultRecipient } from '@documenso/lib/types/default-recipients';
import { trpc } from '@documenso/trpc/react';
import { MultiSelect, type Option } from '@documenso/ui/primitives/multiselect';
type DefaultRecipientsMultiSelectComboboxProps = {
listValues: TDefaultRecipient[];
onChange: (_values: TDefaultRecipient[]) => void;
teamId?: number;
organisationId?: string;
};
export const DefaultRecipientsMultiSelectCombobox = ({
listValues,
onChange,
teamId,
organisationId,
}: DefaultRecipientsMultiSelectComboboxProps) => {
const { _ } = useLingui();
const { data: organisationData, isLoading: isLoadingOrganisation } =
trpc.organisation.member.find.useQuery(
{
organisationId: organisationId!,
query: '',
page: 1,
perPage: 100,
},
{
enabled: !!organisationId,
},
);
const { data: teamData, isLoading: isLoadingTeam } = trpc.team.member.find.useQuery(
{
teamId: teamId!,
query: '',
page: 1,
perPage: 100,
},
{
enabled: !!teamId,
},
);
const members = organisationId ? organisationData?.data : teamData?.data;
const isLoading = organisationId ? isLoadingOrganisation : isLoadingTeam;
const options = members?.map((member) => ({
value: member.email,
label: member.name ? `${member.name} (${member.email})` : member.email,
}));
const value = listValues.map((recipient) => ({
value: recipient.email,
label: recipient.name ? `${recipient.name} (${recipient.email})` : recipient.email,
}));
const onSelectionChange = (selected: Option[]) => {
const updatedRecipients = selected.map((option) => {
const existingRecipient = listValues.find((r) => r.email === option.value);
const member = members?.find((m) => m.email === option.value);
return {
email: option.value,
name: member?.name || option.value,
role: existingRecipient?.role ?? RecipientRole.CC,
};
});
onChange(updatedRecipients);
};
return (
<MultiSelect
commandProps={{ label: _(msg`Select recipients`) }}
options={options}
value={value}
onChange={onSelectionChange}
placeholder={_(msg`Select recipients`)}
hideClearAllButton
hidePlaceholderWhenSelected
loadingIndicator={isLoading ? <p className="text-center text-sm">Loading...</p> : undefined}
emptyIndicator={<p className="text-center text-sm">No members found</p>}
/>
);
};
@@ -90,7 +90,7 @@ export const DocumentSigningAuthPasskey = ({
preferredPasskeyId: passkeyId,
});
const authenticationResponse = await startAuthentication(options);
const authenticationResponse = await startAuthentication({ optionsJSON: options });
await onReauthFormSubmit({
type: DocumentAuth.PASSKEY,
@@ -146,7 +146,7 @@ export const DocumentSigningAuthPasskey = ({
if (passkeyData.isInitialLoading || (passkeyData.isError && passkeyData.passkeys.length === 0)) {
return (
<div className="flex h-28 items-center justify-center">
<Loader className="text-muted-foreground h-6 w-6 animate-spin" />
<Loader className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
);
}
@@ -3,7 +3,7 @@ import { useId, useMemo, useState } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { type Field, FieldType, type Recipient, RecipientRole } from '@prisma/client';
import { type Field, type Recipient, RecipientRole } from '@prisma/client';
import { Controller, useForm } from 'react-hook-form';
import { useNavigate } from 'react-router';
@@ -11,6 +11,7 @@ import type { DocumentAndSender } from '@documenso/lib/server-only/document/get-
import type { TRecipientAccessAuth } from '@documenso/lib/types/document-auth';
import { isFieldUnsignedAndRequired } from '@documenso/lib/utils/advanced-fields-helpers';
import { sortFieldsByPosition } from '@documenso/lib/utils/fields';
import { isSignatureFieldType } from '@documenso/prisma/guards/is-signature-field';
import type { RecipientWithFields } from '@documenso/prisma/types/recipient-with-fields';
import { FieldToolTip } from '@documenso/ui/components/field/field-tooltip';
import { Button } from '@documenso/ui/primitives/button';
@@ -78,7 +79,7 @@ export const DocumentSigningForm = ({
[fields],
);
const hasSignatureField = fields.some((field) => field.type === FieldType.SIGNATURE);
const hasSignatureField = fields.some((field) => isSignatureFieldType(field.type));
const uninsertedFields = useMemo(() => {
return sortFieldsByPosition(fieldsRequiringValidation.filter((field) => !field.inserted));
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
DragDropContext,
@@ -7,28 +7,23 @@ import {
Droppable,
type SensorAPI,
} from '@hello-pangea/dnd';
import { zodResolver } from '@hookform/resolvers/zod';
import { plural } from '@lingui/core/macro';
import { Trans, useLingui } from '@lingui/react/macro';
import { DocumentSigningOrder, EnvelopeType, RecipientRole, SendStatus } from '@prisma/client';
import { motion } from 'framer-motion';
import { GripVerticalIcon, HelpCircleIcon, PlusIcon, SparklesIcon, TrashIcon } from 'lucide-react';
import { useFieldArray, useForm, useWatch } from 'react-hook-form';
import { useFieldArray, useWatch } from 'react-hook-form';
import { useRevalidator, useSearchParams } from 'react-router';
import { isDeepEqual, prop, sortBy } from 'remeda';
import { z } from 'zod';
import { isDeepEqual } from 'remeda';
import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value';
import { ZEditorRecipientsFormSchema } from '@documenso/lib/client-only/hooks/use-editor-recipients';
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { useSession } from '@documenso/lib/client-only/providers/session';
import type { TDetectedRecipientSchema } from '@documenso/lib/server-only/ai/envelope/detect-recipients/schema';
import {
ZRecipientActionAuthTypesSchema,
ZRecipientAuthOptionsSchema,
} from '@documenso/lib/types/document-auth';
import { ZRecipientEmailSchema } from '@documenso/lib/types/recipient';
import { ZRecipientAuthOptionsSchema } from '@documenso/lib/types/document-auth';
import { nanoid } from '@documenso/lib/universal/id';
import { canRecipientBeModified as utilCanRecipientBeModified } from '@documenso/lib/utils/recipients';
import { trpc } from '@documenso/trpc/react';
@@ -67,26 +62,9 @@ import { AiFeaturesEnableDialog } from '~/components/dialogs/ai-features-enable-
import { AiRecipientDetectionDialog } from '~/components/dialogs/ai-recipient-detection-dialog';
import { useCurrentTeam } from '~/providers/team';
const ZEnvelopeRecipientsForm = z.object({
signers: z.array(
z.object({
formId: z.string().min(1),
id: z.number().optional(),
email: ZRecipientEmailSchema,
name: z.string(),
role: z.nativeEnum(RecipientRole),
signingOrder: z.number().optional(),
actionAuth: z.array(ZRecipientActionAuthTypesSchema).optional().default([]),
}),
),
signingOrder: z.nativeEnum(DocumentSigningOrder),
allowDictateNextSigner: z.boolean().default(false),
});
type TEnvelopeRecipientsForm = z.infer<typeof ZEnvelopeRecipientsForm>;
export const EnvelopeEditorRecipientForm = () => {
const { envelope, setRecipientsDebounced, updateEnvelope } = useCurrentEnvelopeEditor();
const { envelope, setRecipientsDebounced, updateEnvelope, editorRecipients } =
useCurrentEnvelopeEditor();
const organisation = useCurrentOrganisation();
const team = useCurrentTeam();
@@ -145,7 +123,6 @@ export const EnvelopeEditorRecipientForm = () => {
const debouncedRecipientSearchQuery = useDebouncedValue(recipientSearchQuery, 500);
const initialId = useId();
const $sensorApi = useRef<SensorAPI | null>(null);
const isFirstRender = useRef(true);
const { recipients, fields } = envelope;
@@ -161,42 +138,7 @@ export const EnvelopeEditorRecipientForm = () => {
const recipientSuggestions = recipientSuggestionsData?.results || [];
const defaultRecipients = [
{
formId: initialId,
name: '',
email: '',
role: RecipientRole.SIGNER,
signingOrder: 1,
actionAuth: [],
},
];
const form = useForm<TEnvelopeRecipientsForm>({
resolver: zodResolver(ZEnvelopeRecipientsForm),
mode: 'onChange', // Used for autosave purposes, maybe can try onBlur instead?
defaultValues: {
signers:
recipients.length > 0
? sortBy(
recipients.map((recipient, index) => ({
id: recipient.id,
formId: String(recipient.id),
name: recipient.name,
email: recipient.email,
role: recipient.role,
signingOrder: recipient.signingOrder ?? index + 1,
actionAuth:
ZRecipientAuthOptionsSchema.parse(recipient.authOptions)?.actionAuth ?? undefined,
})),
[prop('signingOrder'), 'asc'],
[prop('id'), 'asc'],
)
: defaultRecipients,
signingOrder: envelope.documentMeta.signingOrder,
allowDictateNextSigner: envelope.documentMeta.allowDictateNextSigner,
},
});
const { form } = editorRecipients;
const recipientHasAuthSettings = useMemo(() => {
const recipientHasAuthOptions = recipients.find((recipient) => {
@@ -588,7 +530,7 @@ export const EnvelopeEditorRecipientForm = () => {
return;
}
const validatedFormValues = ZEnvelopeRecipientsForm.safeParse(formValues);
const validatedFormValues = ZEditorRecipientsFormSchema.safeParse(formValues);
if (!validatedFormValues.success) {
return;
@@ -848,246 +790,205 @@ export const EnvelopeEditorRecipientForm = () => {
ref={provided.innerRef}
className="flex w-full flex-col gap-y-2"
>
{signers.map((signer, index) => (
<Draggable
key={`${signer.nativeId}-${signer.signingOrder}`}
draggableId={signer['nativeId']}
index={index}
isDragDisabled={
!isSigningOrderSequential ||
isSubmitting ||
!canRecipientBeModified(signer.id) ||
!signer.signingOrder
}
>
{(provided, snapshot) => (
<div
ref={provided.innerRef}
{...provided.draggableProps}
{...provided.dragHandleProps}
className={cn('py-1', {
'pointer-events-none rounded-md bg-widget-foreground pt-2':
snapshot.isDragging,
})}
>
<motion.fieldset
data-native-id={signer.id}
disabled={isSubmitting || !canRecipientBeModified(signer.id)}
className={cn('pb-2', {
'border-b pb-4':
showAdvancedSettings && index !== signers.length - 1,
'pt-2': showAdvancedSettings && index === 0,
'pr-3': isSigningOrderSequential,
{signers.map((signer, index) => {
const isDirectRecipient =
envelope.type === EnvelopeType.TEMPLATE &&
envelope.directLink !== null &&
signer.id === envelope.directLink.directTemplateRecipientId;
return (
<Draggable
key={`${signer.nativeId}-${signer.signingOrder}`}
draggableId={signer['nativeId']}
index={index}
isDragDisabled={
!isSigningOrderSequential ||
isSubmitting ||
!canRecipientBeModified(signer.id) ||
!signer.signingOrder
}
>
{(provided, snapshot) => (
<div
ref={provided.innerRef}
{...provided.draggableProps}
{...provided.dragHandleProps}
className={cn('py-1', {
'pointer-events-none rounded-md bg-widget-foreground pt-2':
snapshot.isDragging,
})}
>
<div className="flex flex-row items-center gap-x-2">
{isSigningOrderSequential && (
<motion.fieldset
data-native-id={signer.id}
disabled={isSubmitting || !canRecipientBeModified(signer.id)}
className={cn('pb-2', {
'border-b pb-4':
showAdvancedSettings && index !== signers.length - 1,
'pt-2': showAdvancedSettings && index === 0,
'pr-3': isSigningOrderSequential,
})}
>
<div className="flex flex-row items-center gap-x-2">
{isSigningOrderSequential && (
<FormField
control={form.control}
name={`signers.${index}.signingOrder`}
render={({ field }) => (
<FormItem
className={cn(
'mt-auto flex items-center gap-x-1 space-y-0',
{
'mb-6':
form.formState.errors.signers?.[index] &&
!form.formState.errors.signers[index]?.signingOrder,
},
)}
>
<GripVerticalIcon className="h-5 w-5 flex-shrink-0 opacity-40" />
<FormControl>
<Input
type="number"
max={signers.length}
data-testid="signing-order-input"
className={cn(
'w-10 text-center',
'[appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none',
)}
{...field}
onChange={(e) => {
field.onChange(e);
handleSigningOrderChange(index, e.target.value);
}}
onBlur={(e) => {
field.onBlur();
handleSigningOrderChange(index, e.target.value);
}}
disabled={
snapshot.isDragging ||
isSubmitting ||
!canRecipientBeModified(signer.id)
}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
<FormField
control={form.control}
name={`signers.${index}.signingOrder`}
name={`signers.${index}.email`}
render={({ field }) => (
<FormItem
className={cn(
'mt-auto flex items-center gap-x-1 space-y-0',
{
'mb-6':
form.formState.errors.signers?.[index] &&
!form.formState.errors.signers[index]?.signingOrder,
},
)}
className={cn('relative w-full', {
'mb-6':
form.formState.errors.signers?.[index] &&
!form.formState.errors.signers[index]?.email,
})}
>
<GripVerticalIcon className="h-5 w-5 flex-shrink-0 opacity-40" />
{!showAdvancedSettings && index === 0 && (
<FormLabel>
<Trans>Email</Trans>
</FormLabel>
)}
<FormControl>
<Input
type="number"
max={signers.length}
data-testid="signing-order-input"
className={cn(
'w-10 text-center',
'[appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none',
)}
{...field}
onChange={(e) => {
field.onChange(e);
handleSigningOrderChange(index, e.target.value);
}}
onBlur={(e) => {
field.onBlur();
handleSigningOrderChange(index, e.target.value);
}}
<RecipientAutoCompleteInput
type="email"
placeholder={t`Email`}
value={field.value}
disabled={
snapshot.isDragging ||
isSubmitting ||
!canRecipientBeModified(signer.id)
!canRecipientBeModified(signer.id) ||
isDirectRecipient
}
options={recipientSuggestions}
onSelect={(suggestion) =>
handleRecipientAutoCompleteSelect(index, suggestion)
}
onSearchQueryChange={(query) => {
field.onChange(query);
setRecipientSearchQuery(query);
}}
loading={isLoading}
data-testid="signer-email-input"
maxLength={254}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
<FormField
control={form.control}
name={`signers.${index}.email`}
render={({ field }) => (
<FormItem
className={cn('relative w-full', {
'mb-6':
form.formState.errors.signers?.[index] &&
!form.formState.errors.signers[index]?.email,
})}
>
{!showAdvancedSettings && index === 0 && (
<FormLabel>
<Trans>Email</Trans>
</FormLabel>
)}
<FormControl>
<RecipientAutoCompleteInput
type="email"
placeholder={t`Email`}
value={field.value}
disabled={
snapshot.isDragging ||
isSubmitting ||
!canRecipientBeModified(signer.id)
}
options={recipientSuggestions}
onSelect={(suggestion) =>
handleRecipientAutoCompleteSelect(index, suggestion)
}
onSearchQueryChange={(query) => {
field.onChange(query);
setRecipientSearchQuery(query);
}}
loading={isLoading}
data-testid="signer-email-input"
maxLength={254}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name={`signers.${index}.name`}
render={({ field }) => (
<FormItem
className={cn('w-full', {
'mb-6':
form.formState.errors.signers?.[index] &&
!form.formState.errors.signers[index]?.name,
})}
>
{!showAdvancedSettings && index === 0 && (
<FormLabel>
<Trans>Name</Trans>
</FormLabel>
)}
<FormControl>
<RecipientAutoCompleteInput
type="text"
placeholder={t`Recipient ${index + 1}`}
{...field}
disabled={
snapshot.isDragging ||
isSubmitting ||
!canRecipientBeModified(signer.id)
}
options={recipientSuggestions}
onSelect={(suggestion) =>
handleRecipientAutoCompleteSelect(index, suggestion)
}
onSearchQueryChange={(query) => {
field.onChange(query);
setRecipientSearchQuery(query);
}}
loading={isLoading}
maxLength={255}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name={`signers.${index}.role`}
render={({ field }) => (
<FormItem
className={cn('mt-auto w-fit', {
'mb-6':
form.formState.errors.signers?.[index] &&
!form.formState.errors.signers[index]?.role,
})}
>
<FormControl>
<RecipientRoleSelect
{...field}
isAssistantEnabled={isSigningOrderSequential}
onValueChange={(value) => {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
handleRoleChange(index, value as RecipientRole);
field.onChange(value);
}}
disabled={
snapshot.isDragging ||
isSubmitting ||
!canRecipientBeModified(signer.id)
}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button
variant="ghost"
className={cn('mt-auto px-2', {
'mb-6': form.formState.errors.signers?.[index],
})}
data-testid="remove-signer-button"
disabled={
snapshot.isDragging ||
isSubmitting ||
!canRecipientBeModified(signer.id) ||
signers.length === 1
}
onClick={() => onRemoveSigner(index)}
>
<TrashIcon className="h-4 w-4" />
</Button>
</div>
{showAdvancedSettings &&
organisation.organisationClaim.flags.cfr21 && (
<FormField
control={form.control}
name={`signers.${index}.actionAuth`}
name={`signers.${index}.name`}
render={({ field }) => (
<FormItem
className={cn('mt-2 w-full', {
className={cn('w-full', {
'mb-6':
form.formState.errors.signers?.[index] &&
!form.formState.errors.signers[index]?.actionAuth,
'pl-6': isSigningOrderSequential,
!form.formState.errors.signers[index]?.name,
})}
>
{!showAdvancedSettings && index === 0 && (
<FormLabel>
<Trans>Name</Trans>
</FormLabel>
)}
<FormControl>
<RecipientAutoCompleteInput
type="text"
placeholder={t`Recipient ${index + 1}`}
{...field}
disabled={
snapshot.isDragging ||
isSubmitting ||
!canRecipientBeModified(signer.id) ||
isDirectRecipient
}
options={recipientSuggestions}
onSelect={(suggestion) =>
handleRecipientAutoCompleteSelect(index, suggestion)
}
onSearchQueryChange={(query) => {
field.onChange(query);
setRecipientSearchQuery(query);
}}
loading={isLoading}
maxLength={255}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name={`signers.${index}.role`}
render={({ field }) => (
<FormItem
className={cn('mt-auto w-fit', {
'mb-6':
form.formState.errors.signers?.[index] &&
!form.formState.errors.signers[index]?.role,
})}
>
<FormControl>
<RecipientActionAuthSelect
<RecipientRoleSelect
{...field}
onValueChange={field.onChange}
isAssistantEnabled={isSigningOrderSequential}
onValueChange={(value) => {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
handleRoleChange(index, value as RecipientRole);
field.onChange(value);
}}
disabled={
snapshot.isDragging ||
isSubmitting ||
@@ -1100,12 +1001,63 @@ export const EnvelopeEditorRecipientForm = () => {
</FormItem>
)}
/>
)}
</motion.fieldset>
</div>
)}
</Draggable>
))}
<Button
variant="ghost"
className={cn('mt-auto px-2', {
'mb-6': form.formState.errors.signers?.[index],
})}
data-testid="remove-signer-button"
disabled={
snapshot.isDragging ||
isSubmitting ||
!canRecipientBeModified(signer.id) ||
signers.length === 1 ||
isDirectRecipient
}
onClick={() => onRemoveSigner(index)}
>
<TrashIcon className="h-4 w-4" />
</Button>
</div>
{showAdvancedSettings &&
organisation.organisationClaim.flags.cfr21 && (
<FormField
control={form.control}
name={`signers.${index}.actionAuth`}
render={({ field }) => (
<FormItem
className={cn('mt-2 w-full', {
'mb-6':
form.formState.errors.signers?.[index] &&
!form.formState.errors.signers[index]?.actionAuth,
'pl-6': isSigningOrderSequential,
})}
>
<FormControl>
<RecipientActionAuthSelect
{...field}
onValueChange={field.onChange}
disabled={
snapshot.isDragging ||
isSubmitting ||
!canRecipientBeModified(signer.id)
}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
</motion.fieldset>
</div>
)}
</Draggable>
);
})}
{provided.placeholder}
</div>
@@ -341,7 +341,9 @@ export const EnvelopeEditorSettingsDialog = ({
{/* Sidebar. */}
<div className="flex w-80 flex-col border-r bg-accent/20">
<DialogHeader className="p-6 pb-4">
<DialogTitle>Document Settings</DialogTitle>
<DialogTitle>
<Trans>Document Settings</Trans>
</DialogTitle>
</DialogHeader>
<nav className="col-span-12 mb-8 flex flex-wrap items-center justify-start gap-x-2 gap-y-4 px-4 md:col-span-3 md:w-full md:flex-col md:items-start md:gap-y-2">
@@ -306,7 +306,7 @@ export const EnvelopeEditorUploadPage = () => {
ref={provided.innerRef}
{...provided.draggableProps}
style={provided.draggableProps.style}
className={`bg-accent/50 flex items-center justify-between rounded-lg p-3 transition-shadow ${
className={`flex items-center justify-between rounded-lg bg-accent/50 p-3 transition-shadow ${
snapshot.isDragging ? 'shadow-md' : ''
}`}
>
@@ -332,7 +332,7 @@ export const EnvelopeEditorUploadPage = () => {
<p className="text-sm font-medium">{localFile.title}</p>
)}
<div className="text-muted-foreground text-xs">
<div className="text-xs text-muted-foreground">
{localFile.isUploading ? (
<Trans>Uploading</Trans>
) : localFile.isError ? (
@@ -345,13 +345,13 @@ export const EnvelopeEditorUploadPage = () => {
<div className="flex items-center space-x-2">
{localFile.isUploading && (
<div className="flex h-6 w-10 items-center justify-center">
<Loader2 className="text-muted-foreground h-4 w-4 animate-spin" />
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
</div>
)}
{localFile.isError && (
<div className="flex h-6 w-10 items-center justify-center">
<FileWarningIcon className="text-destructive h-4 w-4" />
<FileWarningIcon className="h-4 w-4 text-destructive" />
</div>
)}
@@ -81,7 +81,7 @@ export default function EnvelopeEditor() {
isAutosaving,
flushAutosave,
relativePath,
editorFields,
syncEnvelope,
} = useCurrentEnvelopeEditor();
const [searchParams, setSearchParams] = useSearchParams();
@@ -278,6 +278,8 @@ export default function EnvelopeEditor() {
templateId={mapSecondaryIdToTemplateId(envelope.secondaryId)}
directLink={envelope.directLink}
recipients={envelope.recipients}
onCreateSuccess={async () => await syncEnvelope()}
onDeleteSuccess={async () => await syncEnvelope()}
trigger={
<Button variant="ghost" size="sm" className="w-full justify-start">
<LinkIcon className="mr-2 h-4 w-4" />
@@ -1,8 +1,9 @@
import { useMemo } from 'react';
import { Plural, Trans } from '@lingui/react/macro';
import { FieldType, RecipientRole } from '@prisma/client';
import { RecipientRole } from '@prisma/client';
import { isSignatureFieldType } from '@documenso/prisma/guards/is-signature-field';
import { Input } from '@documenso/ui/primitives/input';
import { Label } from '@documenso/ui/primitives/label';
import { RadioGroup, RadioGroupItem } from '@documenso/ui/primitives/radio-group';
@@ -30,7 +31,7 @@ export default function EnvelopeSignerForm() {
const { isNameLocked, isEmailLocked } = useEmbedSigningContext() || {};
const hasSignatureField = useMemo(() => {
return recipientFields.some((field) => field.type === FieldType.SIGNATURE);
return recipientFields.some((field) => isSignatureFieldType(field.type));
}, [recipientFields]);
const isSubmitting = false;
@@ -21,17 +21,17 @@ export default function DocumentEditSkeleton() {
</div>
<div className="mt-4 grid h-[80vh] max-h-[60rem] w-full grid-cols-12 gap-x-8">
<div className="dark:bg-background border-border col-span-12 rounded-xl border-2 bg-white/50 p-2 before:rounded-xl lg:col-span-6 xl:col-span-7">
<div className="col-span-12 rounded-xl border-2 border-border bg-white/50 p-2 before:rounded-xl lg:col-span-6 xl:col-span-7 dark:bg-background">
<div className="flex h-[80vh] max-h-[60rem] flex-col items-center justify-center">
<Loader className="text-documenso h-12 w-12 animate-spin" />
<Loader className="h-12 w-12 animate-spin text-documenso" />
<p className="text-muted-foreground mt-4">
<p className="mt-4 text-muted-foreground">
<Trans>Loading document...</Trans>
</p>
</div>
</div>
<div className="bg-background border-border col-span-12 rounded-xl border-2 before:rounded-xl lg:col-span-6 xl:col-span-5" />
<div className="col-span-12 rounded-xl border-2 border-border bg-background before:rounded-xl lg:col-span-6 xl:col-span-5" />
</div>
</div>
);
@@ -0,0 +1,210 @@
import { useMemo, useState } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { DateTime } from 'luxon';
import type { DateTimeFormatOptions } from 'luxon';
import { useSearchParams } from 'react-router';
import { UAParser } from 'ua-parser-js';
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
import type { TDocumentAuditLog } from '@documenso/lib/types/document-audit-logs';
import { ZUrlSearchParamsSchema } from '@documenso/lib/types/search-params';
import { formatDocumentAuditLogAction } from '@documenso/lib/utils/document-audit-logs';
import { trpc } from '@documenso/trpc/react';
import { CopyTextButton } from '@documenso/ui/components/common/copy-text-button';
import { Button } from '@documenso/ui/primitives/button';
import type { DataTableColumnDef } from '@documenso/ui/primitives/data-table';
import { DataTable } from '@documenso/ui/primitives/data-table';
import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@documenso/ui/primitives/dialog';
import { Skeleton } from '@documenso/ui/primitives/skeleton';
import { TableCell } from '@documenso/ui/primitives/table';
import { useToast } from '@documenso/ui/primitives/use-toast';
export type AdminDocumentLogsTableProps = {
envelopeId: string;
};
const dateFormat: DateTimeFormatOptions = {
...DateTime.DATETIME_SHORT,
hourCycle: 'h12',
};
export const AdminDocumentLogsTable = ({ envelopeId }: AdminDocumentLogsTableProps) => {
const { _, i18n } = useLingui();
const { toast } = useToast();
const [searchParams] = useSearchParams();
const updateSearchParams = useUpdateSearchParams();
const [selectedAuditLog, setSelectedAuditLog] = useState<TDocumentAuditLog | null>(null);
const parsedSearchParams = ZUrlSearchParamsSchema.parse(Object.fromEntries(searchParams ?? []));
const { data, isLoading, isLoadingError } = trpc.admin.document.findAuditLogs.useQuery(
{
envelopeId,
page: parsedSearchParams.page,
perPage: parsedSearchParams.perPage,
},
{
placeholderData: (previousData) => previousData,
},
);
const onPaginationChange = (page: number, perPage: number) => {
updateSearchParams({
page,
perPage,
});
};
const results = data ?? {
data: [],
perPage: 10,
currentPage: 1,
totalPages: 1,
};
const columns = useMemo(() => {
const parser = new UAParser();
return [
{
header: _(msg`Time`),
accessorKey: 'createdAt',
cell: ({ row }) => i18n.date(row.original.createdAt, dateFormat),
},
{
header: _(msg`User`),
accessorKey: 'name',
cell: ({ row }) =>
row.original.name || row.original.email ? (
<div>
{row.original.name && (
<p className="truncate" title={row.original.name}>
{row.original.name}
</p>
)}
{row.original.email && (
<p className="truncate" title={row.original.email}>
{row.original.email}
</p>
)}
</div>
) : (
<p>N/A</p>
),
},
{
header: _(msg`Action`),
accessorKey: 'type',
cell: ({ row }) => (
<span>{formatDocumentAuditLogAction(i18n, row.original).description}</span>
),
},
{
header: _(msg`IP Address`),
accessorKey: 'ipAddress',
},
{
header: _(msg`Browser`),
cell: ({ row }) => {
if (!row.original.userAgent) {
return 'N/A';
}
parser.setUA(row.original.userAgent);
const result = parser.getResult();
return result.browser.name ?? 'N/A';
},
},
{
header: '',
id: 'actions',
cell: ({ row }) => (
<Button variant="link" size="sm" onClick={() => setSelectedAuditLog(row.original)}>
<Trans>View JSON</Trans>
</Button>
),
},
] satisfies DataTableColumnDef<(typeof results)['data'][number]>[];
}, []);
return (
<>
<DataTable
columns={columns}
data={results.data}
perPage={results.perPage}
currentPage={results.currentPage}
totalPages={results.totalPages}
onPaginationChange={onPaginationChange}
error={{
enable: isLoadingError,
}}
skeleton={{
enable: isLoading,
rows: 3,
component: (
<>
<TableCell>
<Skeleton className="h-4 w-12 rounded-full" />
</TableCell>
<TableCell className="w-1/2 py-4 pr-4">
<div className="ml-2 flex flex-grow flex-col">
<Skeleton className="h-4 w-1/3 max-w-[8rem]" />
<Skeleton className="mt-1 h-4 w-1/2 max-w-[12rem]" />
</div>
</TableCell>
<TableCell>
<Skeleton className="h-4 w-12 rounded-full" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-10 rounded-full" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-10 rounded-full" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-8 rounded-full" />
</TableCell>
</>
),
}}
>
{(table) => <DataTablePagination additionalInformation="VisibleCount" table={table} />}
</DataTable>
<Dialog open={selectedAuditLog !== null} onOpenChange={() => setSelectedAuditLog(null)}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>
<Trans>Audit Log Details</Trans>
</DialogTitle>
</DialogHeader>
{selectedAuditLog && (
<div className="group relative">
<div className="absolute right-2 top-2 opacity-0 transition-opacity group-hover:opacity-100">
<CopyTextButton
value={JSON.stringify(selectedAuditLog, null, 2)}
onCopySuccess={() => toast({ title: _(msg`Copied to clipboard`) })}
/>
</div>
<pre className="max-h-[60vh] overflow-auto whitespace-pre-wrap break-all rounded-lg border border-border bg-muted/50 p-4 font-mono text-xs leading-relaxed text-foreground">
{JSON.stringify(selectedAuditLog, null, 2)}
</pre>
</div>
)}
</DialogContent>
</Dialog>
</>
);
};
@@ -6,6 +6,7 @@ import { DateTime } from 'luxon';
import { Link, redirect } from 'react-router';
import { unsafeGetEntireEnvelope } from '@documenso/lib/server-only/admin/get-entire-document';
import { mapSecondaryIdToDocumentId } from '@documenso/lib/utils/envelope';
import { trpc } from '@documenso/trpc/react';
import {
Accordion,
@@ -26,6 +27,7 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
import { AdminDocumentDeleteDialog } from '~/components/dialogs/admin-document-delete-dialog';
import { DocumentStatus } from '~/components/general/document/document-status';
import { AdminDocumentJobsTable } from '~/components/tables/admin-document-jobs-table';
import { AdminDocumentLogsTable } from '~/components/tables/admin-document-logs-table';
import { AdminDocumentRecipientItemTable } from '~/components/tables/admin-document-recipient-item-table';
import type { Route } from './+types/documents.$id';
@@ -87,6 +89,10 @@ export default function AdminDocumentDetailsPage({ loaderData }: Route.Component
</div>
<div className="mt-4 text-sm text-muted-foreground">
<div>
<Trans>Document ID</Trans>: {mapSecondaryIdToDocumentId(envelope.secondaryId)}
</div>
<div>
<Trans>Created on</Trans>: {i18n.date(envelope.createdAt, DateTime.DATETIME_MED)}
</div>
@@ -156,6 +162,9 @@ export default function AdminDocumentDetailsPage({ loaderData }: Route.Component
<Badge size="small" variant="neutral">
{recipient.email}
</Badge>
<Badge size="small" variant="secondary">
{recipient.role}
</Badge>
</div>
</AccordionTrigger>
@@ -175,6 +184,22 @@ export default function AdminDocumentDetailsPage({ loaderData }: Route.Component
<hr className="my-4" />
<Accordion type="single" collapsible className="w-full">
<AccordionItem value="audit-logs" className="rounded-lg border">
<AccordionTrigger className="px-4">
<h2 className="text-lg font-semibold">
<Trans>Audit Logs</Trans>
</h2>
</AccordionTrigger>
<AccordionContent className="border-t px-4 pt-4">
<AdminDocumentLogsTable envelopeId={envelope.id} />
</AccordionContent>
</AccordionItem>
</Accordion>
<hr className="my-4" />
{envelope && <AdminDocumentDeleteDialog envelopeId={envelope.id} />}
</div>
);
@@ -57,6 +57,7 @@ export default function OrganisationSettingsDocumentPage() {
includeSigningCertificate,
includeAuditLog,
signatureTypes,
defaultRecipients,
delegateDocumentOwnership,
aiFeaturesEnabled,
} = data;
@@ -83,6 +84,7 @@ export default function OrganisationSettingsDocumentPage() {
includeSenderDetails,
includeSigningCertificate,
includeAuditLog,
defaultRecipients,
typedSignatureEnabled: signatureTypes.includes(DocumentSignatureType.TYPE),
uploadSignatureEnabled: signatureTypes.includes(DocumentSignatureType.UPLOAD),
drawSignatureEnabled: signatureTypes.includes(DocumentSignatureType.DRAW),
@@ -106,7 +106,7 @@ export default function DocumentEditPage() {
/>
{recipients.length > 0 && (
<div className="text-muted-foreground flex items-center">
<div className="flex items-center text-muted-foreground">
<Users2 className="mr-2 h-5 w-5" />
<StackAvatarsWithTooltip
@@ -50,6 +50,7 @@ export default function TeamsSettingsPage() {
includeSigningCertificate,
includeAuditLog,
signatureTypes,
defaultRecipients,
delegateDocumentOwnership,
aiFeaturesEnabled,
} = data;
@@ -64,6 +65,7 @@ export default function TeamsSettingsPage() {
includeSenderDetails,
includeSigningCertificate,
includeAuditLog,
defaultRecipients,
aiFeaturesEnabled,
...(signatureTypes.length === 0
? {
Binary file not shown.
+9 -10
View File
@@ -36,16 +36,16 @@
"@lingui/react": "^5.6.0",
"@oslojs/crypto": "^1.0.1",
"@oslojs/encoding": "^1.1.0",
"@react-router/node": "^7.9.6",
"@react-router/serve": "^7.9.6",
"@simplewebauthn/browser": "^9.0.1",
"@simplewebauthn/server": "^9.0.3",
"@react-router/node": "^7.12.0",
"@react-router/serve": "^7.12.0",
"@simplewebauthn/browser": "^13.2.2",
"@simplewebauthn/server": "^13.2.2",
"@tanstack/react-query": "5.90.10",
"autoprefixer": "^10.4.22",
"colord": "^2.9.3",
"content-disposition": "^1.0.1",
"framer-motion": "^12.23.24",
"hono": "4.10.6",
"hono": "4.11.4",
"hono-rate-limiter": "^0.4.2",
"hono-react-router-adapter": "^0.6.5",
"input-otp": "^1.4.2",
@@ -65,7 +65,7 @@
"react-hotkeys-hook": "^4.6.2",
"react-icons": "^5.5.0",
"react-rnd": "^10.5.2",
"react-router": "^7.9.6",
"react-router": "^7.12.0",
"recharts": "^2.15.4",
"remeda": "^2.32.0",
"remix-themes": "^2.0.4",
@@ -81,14 +81,13 @@
"@babel/preset-typescript": "^7.28.5",
"@lingui/babel-plugin-lingui-macro": "^5.6.0",
"@lingui/vite-plugin": "^5.6.0",
"@react-router/dev": "^7.9.6",
"@react-router/remix-routes-option-adapter": "^7.9.6",
"@react-router/dev": "^7.12.0",
"@react-router/remix-routes-option-adapter": "^7.12.0",
"@rollup/plugin-babel": "^6.1.0",
"@rollup/plugin-commonjs": "^28.0.9",
"@rollup/plugin-json": "^6.1.0",
"@rollup/plugin-node-resolve": "^16.0.3",
"@rollup/plugin-typescript": "^12.3.0",
"@simplewebauthn/types": "^9.0.1",
"@types/content-disposition": "^0.5.9",
"@types/formidable": "^3.4.6",
"@types/luxon": "^3.7.1",
@@ -107,5 +106,5 @@
"vite-plugin-babel-macros": "^1.0.6",
"vite-tsconfig-paths": "^5.1.4"
},
"version": "2.4.0"
"version": "2.5.1"
}
-2
View File
@@ -67,7 +67,6 @@ export default defineConfig({
'node_modules',
'@napi-rs/canvas',
'@node-rs/bcrypt',
'@documenso/pdf-sign',
'sharp',
'playwright',
'playwright-core',
@@ -98,7 +97,6 @@ export default defineConfig({
external: [
'@napi-rs/canvas',
'@node-rs/bcrypt',
'@documenso/pdf-sign',
'@aws-sdk/cloudfront-signer',
'nodemailer',
/playwright/,
Binary file not shown.
+60 -50
View File
@@ -42,16 +42,17 @@ NEXT_PRIVATE_SIGNING_PASSPHRASE="<your-certificate-password>"
4. Set up your signing certificate. You have three options:
**Option A: Generate Certificate Inside Container (Recommended)**
Start your containers first, then generate a self-signed certificate:
```bash
# Start containers
docker-compose up -d
# Set certificate password securely (won't appear in command history)
read -s -p "Enter certificate password: " CERT_PASS
echo
# Generate certificate inside container using environment variable
docker exec -e CERT_PASS="$CERT_PASS" -it documenso-production-documenso-1 bash -c "
openssl req -x509 -nodes -days 365 -newkey rsa:2048 \
@@ -63,19 +64,19 @@ NEXT_PRIVATE_SIGNING_PASSPHRASE="<your-certificate-password>"
-passout env:CERT_PASS && \
rm /tmp/private.key /tmp/certificate.crt
"
# Restart container
docker-compose restart documenso
```
**Option B: Use Existing Certificate**
If you have an existing `.p12` certificate, update the volume binding in `compose.yml`:
```yaml
volumes:
- /path/to/your/cert.p12:/opt/documenso/cert.p12:ro
```
5. Run the following command to start the containers:
@@ -157,7 +158,6 @@ If you encounter errors related to certificate access, here are common solutions
docker exec -it <container_name> ls -la /opt/documenso/cert.p12
```
### Container Logs
Check application logs for detailed error information:
@@ -202,45 +202,55 @@ The environment variables listed above are a subset of those that are available
Here's a markdown table documenting all the provided environment variables:
| Variable | Description |
| -------------------------------------------- | --------------------------------------------------------------------------------------------------- |
| `PORT` | The port to run the Documenso application on, defaults to `3000`. |
| `NEXTAUTH_SECRET` | The secret key used by NextAuth.js for encryption and signing. |
| `NEXT_PRIVATE_ENCRYPTION_KEY` | The primary encryption key for symmetric encryption and decryption (at least 32 characters). |
| `NEXT_PRIVATE_ENCRYPTION_SECONDARY_KEY` | The secondary encryption key for symmetric encryption and decryption (at least 32 characters). |
| `NEXT_PRIVATE_GOOGLE_CLIENT_ID` | The Google client ID for Google authentication (optional). |
| `NEXT_PRIVATE_GOOGLE_CLIENT_SECRET` | The Google client secret for Google authentication (optional). |
| `NEXT_PUBLIC_WEBAPP_URL` | The URL for the web application. |
| `NEXT_PRIVATE_DATABASE_URL` | The URL for the primary database connection (with connection pooling). |
| `NEXT_PRIVATE_DIRECT_DATABASE_URL` | The URL for the direct database connection (without connection pooling). |
| `NEXT_PRIVATE_SIGNING_TRANSPORT` | The signing transport to use. Available options: local (default) |
| `NEXT_PRIVATE_SIGNING_PASSPHRASE` | The passphrase for the key file. |
| `NEXT_PRIVATE_SIGNING_LOCAL_FILE_CONTENTS` | The base64-encoded contents of the key file, will be used instead of file path. |
| `NEXT_PRIVATE_SIGNING_LOCAL_FILE_PATH` | The path to the key file, default `/opt/documenso/cert.p12`. |
| `NEXT_PUBLIC_UPLOAD_TRANSPORT` | The transport to use for file uploads (database or s3). |
| `NEXT_PRIVATE_UPLOAD_ENDPOINT` | The endpoint for the S3 storage transport (for third-party S3-compatible providers). |
| `NEXT_PRIVATE_UPLOAD_FORCE_PATH_STYLE` | Whether to force path-style URLs for the S3 storage transport. |
| `NEXT_PRIVATE_UPLOAD_REGION` | The region for the S3 storage transport (defaults to us-east-1). |
| `NEXT_PRIVATE_UPLOAD_BUCKET` | The bucket to use for the S3 storage transport. |
| `NEXT_PRIVATE_UPLOAD_ACCESS_KEY_ID` | The access key ID for the S3 storage transport. |
| `NEXT_PRIVATE_UPLOAD_SECRET_ACCESS_KEY` | The secret access key for the S3 storage transport. |
| `NEXT_PRIVATE_SMTP_TRANSPORT` | The transport to use for sending emails (smtp-auth, smtp-api, resend, or mailchannels). |
| `NEXT_PRIVATE_SMTP_HOST` | The host for the SMTP server for SMTP transports. |
| `NEXT_PRIVATE_SMTP_PORT` | The port for the SMTP server for SMTP transports. |
| `NEXT_PRIVATE_SMTP_USERNAME` | The username for the SMTP server for the `smtp-auth` transport. |
| `NEXT_PRIVATE_SMTP_PASSWORD` | The password for the SMTP server for the `smtp-auth` transport. |
| `NEXT_PRIVATE_SMTP_APIKEY_USER` | The API key user for the SMTP server for the `smtp-api` transport. |
| `NEXT_PRIVATE_SMTP_APIKEY` | The API key for the SMTP server for the `smtp-api` transport. |
| `NEXT_PRIVATE_SMTP_SECURE` | Whether to force the use of TLS for the SMTP server for SMTP transports. |
| `NEXT_PRIVATE_SMTP_UNSAFE_IGNORE_TLS` | If true, then no TLS will be used (even if STARTTLS is supported) |
| `NEXT_PRIVATE_SMTP_FROM_ADDRESS` | The email address for the "from" address. |
| `NEXT_PRIVATE_SMTP_FROM_NAME` | The sender name for the "from" address. |
| `NEXT_PRIVATE_RESEND_API_KEY` | The API key for Resend.com for the `resend` transport. |
| `NEXT_PRIVATE_MAILCHANNELS_API_KEY` | The optional API key for MailChannels (if using a proxy) for the `mailchannels` transport. |
| `NEXT_PRIVATE_MAILCHANNELS_ENDPOINT` | The optional endpoint for the MailChannels API (if using a proxy) for the `mailchannels` transport. |
| `NEXT_PRIVATE_MAILCHANNELS_DKIM_DOMAIN` | The domain for DKIM signing with MailChannels for the `mailchannels` transport. |
| `NEXT_PRIVATE_MAILCHANNELS_DKIM_SELECTOR` | The selector for DKIM signing with MailChannels for the `mailchannels` transport. |
| `NEXT_PRIVATE_MAILCHANNELS_DKIM_PRIVATE_KEY` | The private key for DKIM signing with MailChannels for the `mailchannels` transport. |
| `NEXT_PUBLIC_DOCUMENT_SIZE_UPLOAD_LIMIT` | The maximum document upload limit displayed to the user (in MB). |
| `NEXT_PUBLIC_POSTHOG_KEY` | The optional PostHog key for analytics and feature flags. |
| `NEXT_PUBLIC_DISABLE_SIGNUP` | Whether to disable user signups through the /signup page. |
| Variable | Description |
| -------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- |
| `PORT` | The port to run the Documenso application on, defaults to `3000`. |
| `NEXTAUTH_SECRET` | The secret key used by NextAuth.js for encryption and signing. |
| `NEXT_PRIVATE_ENCRYPTION_KEY` | The primary encryption key for symmetric encryption and decryption (at least 32 characters). |
| `NEXT_PRIVATE_ENCRYPTION_SECONDARY_KEY` | The secondary encryption key for symmetric encryption and decryption (at least 32 characters). |
| `NEXT_PRIVATE_GOOGLE_CLIENT_ID` | The Google client ID for Google authentication (optional). |
| `NEXT_PRIVATE_GOOGLE_CLIENT_SECRET` | The Google client secret for Google authentication (optional). |
| `NEXT_PUBLIC_WEBAPP_URL` | The URL for the web application. |
| `NEXT_PRIVATE_DATABASE_URL` | The URL for the primary database connection (with connection pooling). |
| `NEXT_PRIVATE_DIRECT_DATABASE_URL` | The URL for the direct database connection (without connection pooling). |
| `NEXT_PRIVATE_SIGNING_TRANSPORT` | The signing transport to use. Available options: local (default), gcloud-hsm |
| `NEXT_PRIVATE_SIGNING_PASSPHRASE` | The passphrase for the key file. |
| `NEXT_PRIVATE_SIGNING_LOCAL_FILE_CONTENTS` | The base64-encoded contents of the key file, will be used instead of file path. |
| `NEXT_PRIVATE_SIGNING_LOCAL_FILE_PATH` | The path to the key file, default `/opt/documenso/cert.p12`. |
| `NEXT_PRIVATE_SIGNING_GCLOUD_HSM_KEY_PATH` | The Google Cloud HSM key path for the gcloud-hsm signing transport. |
| `NEXT_PRIVATE_SIGNING_GCLOUD_HSM_PUBLIC_CRT_FILE_PATH` | The path to the Google Cloud HSM public certificate file for the gcloud-hsm transport. |
| `NEXT_PRIVATE_SIGNING_GCLOUD_HSM_PUBLIC_CRT_FILE_CONTENTS` | The base64-encoded Google Cloud HSM public certificate for the gcloud-hsm transport. |
| `NEXT_PRIVATE_SIGNING_GCLOUD_APPLICATION_CREDENTIALS_CONTENTS` | The base64-encoded Google Cloud Credentials for the gcloud-hsm transport. |
| `NEXT_PRIVATE_SIGNING_GCLOUD_HSM_CERT_CHAIN_FILE_PATH` | The path to the certificate chain file for the gcloud-hsm transport. |
| `NEXT_PRIVATE_SIGNING_GCLOUD_HSM_CERT_CHAIN_CONTENTS` | The base64-encoded certificate chain for the gcloud-hsm transport. |
| `NEXT_PRIVATE_SIGNING_GCLOUD_HSM_SECRET_MANAGER_CERT_PATH` | The Google Secret Manager path to retrieve the certificate for the gcloud-hsm transport. |
| `NEXT_PRIVATE_SIGNING_TIMESTAMP_AUTHORITY` | Comma-separated list of timestamp authority URLs for PDF signing (enables LTV). |
| `NEXT_PUBLIC_SIGNING_CONTACT_INFO` | Contact info to embed in PDF signatures. Defaults to the webapp URL. |
| `NEXT_PRIVATE_USE_LEGACY_SIGNING_SUBFILTER` | Set to "true" to use legacy adbe.pkcs7.detached subfilter instead of ETSI.CAdES.detached. |
| `NEXT_PUBLIC_UPLOAD_TRANSPORT` | The transport to use for file uploads (database or s3). |
| `NEXT_PRIVATE_UPLOAD_ENDPOINT` | The endpoint for the S3 storage transport (for third-party S3-compatible providers). |
| `NEXT_PRIVATE_UPLOAD_FORCE_PATH_STYLE` | Whether to force path-style URLs for the S3 storage transport. |
| `NEXT_PRIVATE_UPLOAD_REGION` | The region for the S3 storage transport (defaults to us-east-1). |
| `NEXT_PRIVATE_UPLOAD_BUCKET` | The bucket to use for the S3 storage transport. |
| `NEXT_PRIVATE_UPLOAD_ACCESS_KEY_ID` | The access key ID for the S3 storage transport. |
| `NEXT_PRIVATE_UPLOAD_SECRET_ACCESS_KEY` | The secret access key for the S3 storage transport. |
| `NEXT_PRIVATE_SMTP_TRANSPORT` | The transport to use for sending emails (smtp-auth, smtp-api, resend, or mailchannels). |
| `NEXT_PRIVATE_SMTP_HOST` | The host for the SMTP server for SMTP transports. |
| `NEXT_PRIVATE_SMTP_PORT` | The port for the SMTP server for SMTP transports. |
| `NEXT_PRIVATE_SMTP_USERNAME` | The username for the SMTP server for the `smtp-auth` transport. |
| `NEXT_PRIVATE_SMTP_PASSWORD` | The password for the SMTP server for the `smtp-auth` transport. |
| `NEXT_PRIVATE_SMTP_APIKEY_USER` | The API key user for the SMTP server for the `smtp-api` transport. |
| `NEXT_PRIVATE_SMTP_APIKEY` | The API key for the SMTP server for the `smtp-api` transport. |
| `NEXT_PRIVATE_SMTP_SECURE` | Whether to force the use of TLS for the SMTP server for SMTP transports. |
| `NEXT_PRIVATE_SMTP_UNSAFE_IGNORE_TLS` | If true, then no TLS will be used (even if STARTTLS is supported) |
| `NEXT_PRIVATE_SMTP_FROM_ADDRESS` | The email address for the "from" address. |
| `NEXT_PRIVATE_SMTP_FROM_NAME` | The sender name for the "from" address. |
| `NEXT_PRIVATE_RESEND_API_KEY` | The API key for Resend.com for the `resend` transport. |
| `NEXT_PRIVATE_MAILCHANNELS_API_KEY` | The optional API key for MailChannels (if using a proxy) for the `mailchannels` transport. |
| `NEXT_PRIVATE_MAILCHANNELS_ENDPOINT` | The optional endpoint for the MailChannels API (if using a proxy) for the `mailchannels` transport. |
| `NEXT_PRIVATE_MAILCHANNELS_DKIM_DOMAIN` | The domain for DKIM signing with MailChannels for the `mailchannels` transport. |
| `NEXT_PRIVATE_MAILCHANNELS_DKIM_SELECTOR` | The selector for DKIM signing with MailChannels for the `mailchannels` transport. |
| `NEXT_PRIVATE_MAILCHANNELS_DKIM_PRIVATE_KEY` | The private key for DKIM signing with MailChannels for the `mailchannels` transport. |
| `NEXT_PUBLIC_DOCUMENT_SIZE_UPLOAD_LIMIT` | The maximum document upload limit displayed to the user (in MB). |
| `NEXT_PUBLIC_POSTHOG_KEY` | The optional PostHog key for analytics and feature flags. |
| `NEXT_PUBLIC_DISABLE_SIGNUP` | Whether to disable user signups through the /signup page. |
+1 -1
View File
@@ -11,7 +11,7 @@ CERT_PATH="${NEXT_PRIVATE_SIGNING_LOCAL_FILE_PATH:-/opt/documenso/cert.p12}"
if [ -f "$CERT_PATH" ] && [ -r "$CERT_PATH" ]; then
printf "✅ Certificate file found and readable - document signing is ready!\n"
else
printf "⚠️ Certificate not found or not readable\n"
printf "⚠️ Certificate not found or not readable\n"
printf "💡 Tip: Documenso will still start, but document signing will be unavailable\n"
printf "🔧 Check: http://localhost:3000/api/certificate-status for detailed status\n"
fi
+937 -1157
View File
File diff suppressed because it is too large Load Diff
+6 -7
View File
@@ -5,7 +5,7 @@
"apps/*",
"packages/*"
],
"version": "2.4.0",
"version": "2.5.1",
"scripts": {
"postinstall": "patch-package",
"build": "turbo run build",
@@ -61,10 +61,11 @@
"dotenv-cli": "^11.0.0",
"eslint": "^8.57.0",
"husky": "^9.1.7",
"inngest-cli": "^1.16.1",
"lint-staged": "^16.2.7",
"nanoid": "^5.1.6",
"nodemailer": "^7.0.10",
"pdfjs-dist": "5.4.449",
"pdfjs-dist": "5.4.296",
"pino": "^9.14.0",
"pino-pretty": "^13.1.2",
"playwright": "1.56.1",
@@ -72,11 +73,10 @@
"prisma": "^6.19.0",
"prisma-extension-kysely": "^3.0.0",
"prisma-json-types-generator": "^3.6.2",
"prisma-kysely": "^2.2.1",
"prisma-kysely": "^2.3.0",
"rimraf": "^6.1.2",
"superjson": "^2.2.5",
"syncpack": "^14.0.0-alpha.27",
"trpc-to-openapi": "2.4.0",
"turbo": "^1.13.4",
"vite": "^7.2.4",
"vite-plugin-static-copy": "^3.1.4",
@@ -85,12 +85,11 @@
},
"dependencies": {
"@ai-sdk/google-vertex": "3.0.81",
"@documenso/pdf-sign": "^0.1.0",
"@documenso/prisma": "*",
"@libpdf/core": "^0.2.2",
"@lingui/conf": "^5.6.0",
"@lingui/core": "^5.6.0",
"ai": "^5.0.104",
"inngest-cli": "^1.13.7",
"luxon": "^3.7.2",
"patch-package": "^8.0.1",
"posthog-node": "4.18.0",
@@ -99,7 +98,7 @@
"zod": "^3.25.76"
},
"overrides": {
"pdfjs-dist": "5.4.449",
"pdfjs-dist": "5.4.296",
"typescript": "5.6.2",
"zod": "^3.25.76"
}
+5 -57
View File
@@ -41,7 +41,7 @@ import {
ZTextFieldMeta,
} from '@documenso/lib/types/field-meta';
import { getFileServerSide } from '@documenso/lib/universal/upload/get-file.server';
import { putPdfFileServerSide } from '@documenso/lib/universal/upload/put-file.server';
import { putNormalizedPdfFileServerSide } from '@documenso/lib/universal/upload/put-file.server';
import {
getPresignGetUrl,
getPresignPostUrl,
@@ -796,6 +796,7 @@ export const ApiContractV1Implementation = tsr.router(ApiContractV1, {
title: body.title,
},
attachments: body.attachments,
formValues: body.formValues,
requestMetadata: metadata,
});
@@ -822,7 +823,7 @@ export const ApiContractV1Implementation = tsr.router(ApiContractV1, {
formValues: body.formValues,
});
const newDocumentData = await putPdfFileServerSide({
const newDocumentData = await putNormalizedPdfFileServerSide({
name: fileName,
type: 'application/pdf',
arrayBuffer: async () => Promise.resolve(prefilled),
@@ -911,61 +912,13 @@ export const ApiContractV1Implementation = tsr.router(ApiContractV1, {
title: body.title,
...body.meta,
},
formValues: body.formValues,
requestMetadata: metadata,
});
} catch (err) {
return AppError.toRestAPIError(err);
}
if (envelope.envelopeItems.length !== 1) {
throw new Error('API V1 does not support envelopes');
}
const firstEnvelopeDocumentData = await prisma.envelopeItem.findFirstOrThrow({
where: {
envelopeId: envelope.id,
},
include: {
documentData: true,
},
});
if (body.formValues) {
const fileName = envelope.title.endsWith('.pdf') ? envelope.title : `${envelope.title}.pdf`;
const pdf = await getFileServerSide(firstEnvelopeDocumentData.documentData);
const prefilled = await insertFormValuesInPdf({
pdf: Buffer.from(pdf),
formValues: body.formValues,
});
const newDocumentData = await putPdfFileServerSide({
name: fileName,
type: 'application/pdf',
arrayBuffer: async () => Promise.resolve(prefilled),
});
await prisma.envelope.update({
where: {
id: envelope.id,
},
data: {
formValues: body.formValues,
envelopeItems: {
update: {
where: {
id: firstEnvelopeDocumentData.id,
},
data: {
documentDataId: newDocumentData.id,
},
},
},
},
});
}
if (body.authOptions) {
await prisma.envelope.update({
where: {
@@ -1089,12 +1042,7 @@ export const ApiContractV1Implementation = tsr.router(ApiContractV1, {
},
};
} catch (err) {
return {
status: 500,
body: {
message: 'An error has occured while sending the document for signing',
},
};
return AppError.toRestAPIError(err);
}
}),
@@ -4,7 +4,11 @@ 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 { seedPendingDocumentWithFullFields } from '@documenso/prisma/seed/documents';
import { FieldType, RecipientRole } from '@documenso/prisma/client';
import {
seedBlankDocument,
seedPendingDocumentWithFullFields,
} from '@documenso/prisma/seed/documents';
import { seedUser } from '@documenso/prisma/seed/users';
test.describe('Document API', () => {
@@ -145,4 +149,293 @@ test.describe('Document API', () => {
ownerDocumentCompleted: false,
});
});
test('sendDocument: should fail when signer has no signature field', async ({ request }) => {
const { user, team } = await seedUser();
// Create a blank document and get it with envelope items
const blankDocument = await seedBlankDocument(user, team.id);
const document = await prisma.envelope.findUniqueOrThrow({
where: { id: blankDocument.id },
include: { envelopeItems: true },
});
// Add a signer recipient without any fields
await prisma.recipient.create({
data: {
email: 'signer@example.com',
name: 'Test Signer',
role: RecipientRole.SIGNER,
token: 'test-token-1',
envelopeId: document.id,
},
});
const { token } = await createApiToken({
userId: user.id,
teamId: team.id,
tokenName: 'test',
expiresIn: null,
});
const response = await request.post(
`${NEXT_PUBLIC_WEBAPP_URL()}/api/v1/documents/${mapSecondaryIdToDocumentId(document.secondaryId)}/send`,
{
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
data: {},
},
);
expect(response.ok()).toBeFalsy();
expect(response.status()).toBe(400);
});
test('sendDocument: should fail when signer has only non-signature fields', async ({
request,
}) => {
const { user, team } = await seedUser();
// Create a blank document and get it with envelope items
const blankDocument = await seedBlankDocument(user, team.id);
const document = await prisma.envelope.findUniqueOrThrow({
where: { id: blankDocument.id },
include: { envelopeItems: true },
});
// Add a signer recipient with only a TEXT field (not signature)
const recipient = await prisma.recipient.create({
data: {
email: 'signer@example.com',
name: 'Test Signer',
role: RecipientRole.SIGNER,
token: 'test-token-2',
envelopeId: document.id,
},
});
// Add a TEXT field (not a signature field)
await prisma.field.create({
data: {
type: FieldType.TEXT,
page: 1,
positionX: 100,
positionY: 100,
width: 50,
height: 50,
customText: '',
inserted: false,
recipientId: recipient.id,
envelopeId: document.id,
envelopeItemId: document.envelopeItems[0].id,
},
});
const { token } = await createApiToken({
userId: user.id,
teamId: team.id,
tokenName: 'test',
expiresIn: null,
});
const response = await request.post(
`${NEXT_PUBLIC_WEBAPP_URL()}/api/v1/documents/${mapSecondaryIdToDocumentId(document.secondaryId)}/send`,
{
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
data: {},
},
);
expect(response.ok()).toBeFalsy();
expect(response.status()).toBe(400);
});
test('sendDocument: should succeed when signer has signature field', async ({ request }) => {
const { user, team } = await seedUser();
const { document } = await seedPendingDocumentWithFullFields({
owner: user,
recipients: ['signer@example.com'],
teamId: team.id,
});
const { token } = await createApiToken({
userId: user.id,
teamId: team.id,
tokenName: 'test',
expiresIn: null,
});
const response = await request.post(
`${NEXT_PUBLIC_WEBAPP_URL()}/api/v1/documents/${mapSecondaryIdToDocumentId(document.secondaryId)}/send`,
{
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
data: {},
},
);
expect(response.ok()).toBeTruthy();
expect(response.status()).toBe(200);
});
test('sendDocument: should succeed when signer has FREE_SIGNATURE field', async ({ request }) => {
const { user, team } = await seedUser();
// Create a blank document and get it with envelope items
const blankDocument = await seedBlankDocument(user, team.id);
const document = await prisma.envelope.findUniqueOrThrow({
where: { id: blankDocument.id },
include: { envelopeItems: true },
});
// Add a signer recipient
const recipient = await prisma.recipient.create({
data: {
email: 'signer@example.com',
name: 'Test Signer',
role: RecipientRole.SIGNER,
token: 'test-token-3',
envelopeId: document.id,
},
});
// Add a FREE_SIGNATURE field
await prisma.field.create({
data: {
type: FieldType.FREE_SIGNATURE,
page: 1,
positionX: 100,
positionY: 100,
width: 50,
height: 50,
customText: '',
inserted: false,
recipientId: recipient.id,
envelopeId: document.id,
envelopeItemId: document.envelopeItems[0].id,
},
});
const { token } = await createApiToken({
userId: user.id,
teamId: team.id,
tokenName: 'test',
expiresIn: null,
});
const response = await request.post(
`${NEXT_PUBLIC_WEBAPP_URL()}/api/v1/documents/${mapSecondaryIdToDocumentId(document.secondaryId)}/send`,
{
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
data: {},
},
);
expect(response.ok()).toBeTruthy();
expect(response.status()).toBe(200);
});
test('sendDocument: should succeed when non-signer roles have no fields', async ({ request }) => {
const { user, team } = await seedUser();
// Create a blank document and get it with envelope items
const blankDocument = await seedBlankDocument(user, team.id);
const document = await prisma.envelope.findUniqueOrThrow({
where: { id: blankDocument.id },
include: { envelopeItems: true },
});
// Add a signer with signature field
const signer = await prisma.recipient.create({
data: {
email: 'signer@example.com',
name: 'Test Signer',
role: RecipientRole.SIGNER,
token: 'test-token-4',
envelopeId: document.id,
},
});
await prisma.field.create({
data: {
type: FieldType.SIGNATURE,
page: 1,
positionX: 1,
positionY: 1,
width: 1,
height: 1,
customText: '',
inserted: false,
recipientId: signer.id,
envelopeId: document.id,
envelopeItemId: document.envelopeItems[0].id,
fieldMeta: { type: 'signature', fontSize: 14 },
},
});
// Add a viewer without any fields
await prisma.recipient.create({
data: {
email: 'viewer@example.com',
name: 'Test Viewer',
role: RecipientRole.VIEWER,
token: 'test-token-5',
envelopeId: document.id,
},
});
// Add an approver without any fields
await prisma.recipient.create({
data: {
email: 'approver@example.com',
name: 'Test Approver',
role: RecipientRole.APPROVER,
token: 'test-token-6',
envelopeId: document.id,
},
});
// Add a CC without any fields
await prisma.recipient.create({
data: {
email: 'cc@example.com',
name: 'Test CC',
role: RecipientRole.CC,
token: 'test-token-7',
envelopeId: document.id,
},
});
const { token } = await createApiToken({
userId: user.id,
teamId: team.id,
tokenName: 'test',
expiresIn: null,
});
const response = await request.post(
`${NEXT_PUBLIC_WEBAPP_URL()}/api/v1/documents/${mapSecondaryIdToDocumentId(document.secondaryId)}/send`,
{
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
data: {},
},
);
expect(response.ok()).toBeTruthy();
expect(response.status()).toBe(200);
});
});
@@ -171,6 +171,24 @@ test.describe('Template Field Prefill API v1', () => {
},
});
// Add SIGNATURE field (required for distribution)
await prisma.field.create({
data: {
envelopeId: template.id,
envelopeItemId: firstEnvelopeItem.id,
recipientId: recipient.id,
type: FieldType.SIGNATURE,
page: 1,
positionX: 1,
positionY: 1,
width: 1,
height: 1,
customText: '',
inserted: false,
fieldMeta: { type: 'signature', fontSize: 14 },
},
});
// 6. Sign in as the user
await apiSignin({
page,
@@ -444,6 +462,24 @@ test.describe('Template Field Prefill API v1', () => {
},
});
// Add SIGNATURE field (required for distribution)
await prisma.field.create({
data: {
envelopeId: template.id,
envelopeItemId: firstEnvelopeItem.id,
recipientId: recipient.id,
type: FieldType.SIGNATURE,
page: 1,
positionX: 1,
positionY: 1,
width: 1,
height: 1,
customText: '',
inserted: false,
fieldMeta: { type: 'signature', fontSize: 14 },
},
});
// 6. Sign in as the user
await apiSignin({
page,
@@ -221,7 +221,7 @@ test.describe('Document Access API V1', () => {
);
expect(resB.ok()).toBeFalsy();
expect(resB.status()).toBe(500);
expect(resB.status()).toBe(404);
});
test('should allow authorized access to document send endpoint', async ({ request }) => {
@@ -0,0 +1,403 @@
import { type APIRequestContext, expect, test } from '@playwright/test';
import type { Team, User } from '@prisma/client';
import fs from 'node:fs';
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 { EnvelopeType, FieldType, RecipientRole } from '@documenso/prisma/client';
import { seedUser } from '@documenso/prisma/seed/users';
import type {
TCreateEnvelopePayload,
TCreateEnvelopeResponse,
} from '@documenso/trpc/server/envelope-router/create-envelope.types';
import type { TCreateEnvelopeRecipientsRequest } from '@documenso/trpc/server/envelope-router/envelope-recipients/create-envelope-recipients.types';
import type { TGetEnvelopeResponse } from '@documenso/trpc/server/envelope-router/get-envelope.types';
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
const baseUrl = `${WEBAPP_BASE_URL}/api/v2-beta`;
test.describe.configure({
mode: 'parallel',
});
test.describe('Envelope distribute validation', () => {
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,
}));
});
const createEnvelope = async (request: APIRequestContext, authToken: string) => {
const payload: TCreateEnvelopePayload = {
type: EnvelopeType.DOCUMENT,
title: 'Test Document',
};
const formData = new FormData();
formData.append('payload', JSON.stringify(payload));
const pdfData = fs.readFileSync(path.join(__dirname, '../../../../../assets/example.pdf'));
formData.append('files', new File([pdfData], 'test.pdf', { type: 'application/pdf' }));
const res = await request.post(`${baseUrl}/envelope/create`, {
headers: { Authorization: `Bearer ${authToken}` },
multipart: formData,
});
expect(res.ok()).toBeTruthy();
return (await res.json()) as TCreateEnvelopeResponse;
};
const getEnvelope = async (request: APIRequestContext, authToken: string, envelopeId: string) => {
const res = await request.get(`${baseUrl}/envelope/${envelopeId}`, {
headers: { Authorization: `Bearer ${authToken}` },
});
expect(res.ok()).toBeTruthy();
return (await res.json()) as TGetEnvelopeResponse;
};
const createRecipients = async (
request: APIRequestContext,
authToken: string,
envelopeId: string,
recipients: TCreateEnvelopeRecipientsRequest['data'],
) => {
const res = await request.post(`${baseUrl}/envelope/recipient/create-many`, {
headers: { Authorization: `Bearer ${authToken}` },
data: {
envelopeId,
data: recipients,
} satisfies TCreateEnvelopeRecipientsRequest,
});
expect(res.ok()).toBeTruthy();
return (await res.json()).data;
};
const createFields = async (
request: APIRequestContext,
authToken: string,
envelopeId: string,
envelopeItemId: string,
fields: Array<{ recipientId: number; type: FieldType }>,
) => {
const res = await request.post(`${baseUrl}/envelope/field/create-many`, {
headers: { Authorization: `Bearer ${authToken}` },
data: {
envelopeId,
data: fields.map((field, index) => ({
recipientId: field.recipientId,
envelopeItemId,
type: field.type,
page: 1,
positionX: 10,
positionY: 10 + index * 10,
width: 10,
height: 10,
})),
},
});
expect(res.ok()).toBeTruthy();
return (await res.json()).data;
};
test('should fail to distribute when signer has no fields', async ({ request }) => {
const envelope = await createEnvelope(request, token);
// Create a signer without any fields
await createRecipients(request, token, envelope.id, [
{
email: 'signer@example.com',
name: 'Test Signer',
role: RecipientRole.SIGNER,
accessAuth: [],
actionAuth: [],
},
]);
// Try to distribute without adding any fields
const distributeRes = await request.post(`${baseUrl}/envelope/distribute`, {
headers: { Authorization: `Bearer ${token}` },
data: { envelopeId: envelope.id },
});
expect(distributeRes.ok()).toBeFalsy();
expect(distributeRes.status()).toBe(400);
const errorResponse = await distributeRes.json();
expect(errorResponse.message).toContain('missing required fields');
expect(errorResponse.message).toContain('Signers must have at least one signature field');
});
test('should fail to distribute when signer has non-signature fields only', async ({
request,
}) => {
const envelope = await createEnvelope(request, token);
const envelopeData = await getEnvelope(request, token, envelope.id);
// Create a signer
const recipients = await createRecipients(request, token, envelope.id, [
{
email: 'signer@example.com',
name: 'Test Signer',
role: RecipientRole.SIGNER,
accessAuth: [],
actionAuth: [],
},
]);
// Add only a TEXT field (not a signature field)
await createFields(request, token, envelope.id, envelopeData.envelopeItems[0].id, [
{ recipientId: recipients[0].id, type: FieldType.TEXT },
]);
// Try to distribute
const distributeRes = await request.post(`${baseUrl}/envelope/distribute`, {
headers: { Authorization: `Bearer ${token}` },
data: { envelopeId: envelope.id },
});
expect(distributeRes.ok()).toBeFalsy();
expect(distributeRes.status()).toBe(400);
const errorResponse = await distributeRes.json();
expect(errorResponse.message).toContain('missing required fields');
});
test('should succeed when signer has SIGNATURE field', async ({ request }) => {
const envelope = await createEnvelope(request, token);
const envelopeData = await getEnvelope(request, token, envelope.id);
// Create a signer
const recipients = await createRecipients(request, token, envelope.id, [
{
email: 'signer@example.com',
name: 'Test Signer',
role: RecipientRole.SIGNER,
accessAuth: [],
actionAuth: [],
},
]);
// Add a SIGNATURE field
await createFields(request, token, envelope.id, envelopeData.envelopeItems[0].id, [
{ recipientId: recipients[0].id, type: FieldType.SIGNATURE },
]);
// Distribute should succeed
const distributeRes = await request.post(`${baseUrl}/envelope/distribute`, {
headers: { Authorization: `Bearer ${token}` },
data: { envelopeId: envelope.id },
});
expect(distributeRes.ok()).toBeTruthy();
expect(distributeRes.status()).toBe(200);
const response = await distributeRes.json();
expect(response.success).toBe(true);
});
// Note: FREE_SIGNATURE field type is not supported via the v2 API for field creation,
// so we only test with SIGNATURE fields here. The v1 tests cover FREE_SIGNATURE
// using direct Prisma creation.
test('should succeed when VIEWER has no fields', async ({ request }) => {
const envelope = await createEnvelope(request, token);
const envelopeData = await getEnvelope(request, token, envelope.id);
// Create a signer and a viewer
const recipients = await createRecipients(request, token, envelope.id, [
{
email: 'signer@example.com',
name: 'Test Signer',
role: RecipientRole.SIGNER,
accessAuth: [],
actionAuth: [],
},
{
email: 'viewer@example.com',
name: 'Test Viewer',
role: RecipientRole.VIEWER,
accessAuth: [],
actionAuth: [],
},
]);
// Add signature field only for the signer (viewer has no fields)
await createFields(request, token, envelope.id, envelopeData.envelopeItems[0].id, [
{ recipientId: recipients[0].id, type: FieldType.SIGNATURE },
]);
// Distribute should succeed
const distributeRes = await request.post(`${baseUrl}/envelope/distribute`, {
headers: { Authorization: `Bearer ${token}` },
data: { envelopeId: envelope.id },
});
expect(distributeRes.ok()).toBeTruthy();
expect(distributeRes.status()).toBe(200);
});
test('should succeed when CC has no fields', async ({ request }) => {
const envelope = await createEnvelope(request, token);
const envelopeData = await getEnvelope(request, token, envelope.id);
// Create a signer and a CC recipient
const recipients = await createRecipients(request, token, envelope.id, [
{
email: 'signer@example.com',
name: 'Test Signer',
role: RecipientRole.SIGNER,
accessAuth: [],
actionAuth: [],
},
{
email: 'cc@example.com',
name: 'Test CC',
role: RecipientRole.CC,
accessAuth: [],
actionAuth: [],
},
]);
// Add signature field only for the signer (CC has no fields)
await createFields(request, token, envelope.id, envelopeData.envelopeItems[0].id, [
{ recipientId: recipients[0].id, type: FieldType.SIGNATURE },
]);
// Distribute should succeed
const distributeRes = await request.post(`${baseUrl}/envelope/distribute`, {
headers: { Authorization: `Bearer ${token}` },
data: { envelopeId: envelope.id },
});
expect(distributeRes.ok()).toBeTruthy();
expect(distributeRes.status()).toBe(200);
});
test('should succeed when APPROVER has no fields', async ({ request }) => {
const envelope = await createEnvelope(request, token);
const envelopeData = await getEnvelope(request, token, envelope.id);
// Create a signer and an approver
const recipients = await createRecipients(request, token, envelope.id, [
{
email: 'signer@example.com',
name: 'Test Signer',
role: RecipientRole.SIGNER,
accessAuth: [],
actionAuth: [],
},
{
email: 'approver@example.com',
name: 'Test Approver',
role: RecipientRole.APPROVER,
accessAuth: [],
actionAuth: [],
},
]);
// Add signature field only for the signer (approver has no fields)
await createFields(request, token, envelope.id, envelopeData.envelopeItems[0].id, [
{ recipientId: recipients[0].id, type: FieldType.SIGNATURE },
]);
// Distribute should succeed
const distributeRes = await request.post(`${baseUrl}/envelope/distribute`, {
headers: { Authorization: `Bearer ${token}` },
data: { envelopeId: envelope.id },
});
expect(distributeRes.ok()).toBeTruthy();
expect(distributeRes.status()).toBe(200);
});
test('should fail when one of multiple signers is missing signature field', async ({
request,
}) => {
const envelope = await createEnvelope(request, token);
const envelopeData = await getEnvelope(request, token, envelope.id);
// Create two signers
const recipients = await createRecipients(request, token, envelope.id, [
{
email: 'signer1@example.com',
name: 'Test Signer 1',
role: RecipientRole.SIGNER,
accessAuth: [],
actionAuth: [],
},
{
email: 'signer2@example.com',
name: 'Test Signer 2',
role: RecipientRole.SIGNER,
accessAuth: [],
actionAuth: [],
},
]);
// Add signature field only for the first signer
await createFields(request, token, envelope.id, envelopeData.envelopeItems[0].id, [
{ recipientId: recipients[0].id, type: FieldType.SIGNATURE },
]);
// Distribute should fail because second signer has no signature field
const distributeRes = await request.post(`${baseUrl}/envelope/distribute`, {
headers: { Authorization: `Bearer ${token}` },
data: { envelopeId: envelope.id },
});
expect(distributeRes.ok()).toBeFalsy();
expect(distributeRes.status()).toBe(400);
const errorResponse = await distributeRes.json();
expect(errorResponse.message).toContain('missing required fields');
});
test('should succeed when all signers have signature fields', async ({ request }) => {
const envelope = await createEnvelope(request, token);
const envelopeData = await getEnvelope(request, token, envelope.id);
// Create two signers
const recipients = await createRecipients(request, token, envelope.id, [
{
email: 'signer1@example.com',
name: 'Test Signer 1',
role: RecipientRole.SIGNER,
accessAuth: [],
actionAuth: [],
},
{
email: 'signer2@example.com',
name: 'Test Signer 2',
role: RecipientRole.SIGNER,
accessAuth: [],
actionAuth: [],
},
]);
// Add signature fields for both signers
await createFields(request, token, envelope.id, envelopeData.envelopeItems[0].id, [
{ recipientId: recipients[0].id, type: FieldType.SIGNATURE },
{ recipientId: recipients[1].id, type: FieldType.SIGNATURE },
]);
// Distribute should succeed
const distributeRes = await request.post(`${baseUrl}/envelope/distribute`, {
headers: { Authorization: `Bearer ${token}` },
data: { envelopeId: envelope.id },
});
expect(distributeRes.ok()).toBeTruthy();
expect(distributeRes.status()).toBe(200);
});
});
@@ -171,6 +171,24 @@ test.describe('Template Field Prefill API v2', () => {
},
});
// Add SIGNATURE field (required for distribution)
await prisma.field.create({
data: {
envelopeId: template.id,
envelopeItemId: firstEnvelopeItem.id,
recipientId: recipient.id,
type: FieldType.SIGNATURE,
page: 1,
positionX: 1,
positionY: 1,
width: 1,
height: 1,
customText: '',
inserted: false,
fieldMeta: { type: 'signature', fontSize: 14 },
},
});
// 6. Sign in as the user
await apiSignin({
page,
@@ -441,6 +459,24 @@ test.describe('Template Field Prefill API v2', () => {
},
});
// Add SIGNATURE field (required for distribution)
await prisma.field.create({
data: {
envelopeId: template.id,
envelopeItemId: firstEnvelopeItem.id,
recipientId: recipient.id,
type: FieldType.SIGNATURE,
page: 1,
positionX: 1,
positionY: 1,
width: 1,
height: 1,
customText: '',
inserted: false,
fieldMeta: { type: 'signature', fontSize: 14 },
},
});
// 6. Sign in as the user
await apiSignin({
page,
@@ -195,6 +195,31 @@ test.describe('Document API V2', () => {
}) => {
const doc = await seedDraftDocument(userA, teamA.id, ['test@example.com']);
// Get the recipient created during seeding.
const recipient = await prisma.recipient.findFirstOrThrow({
where: {
envelopeId: doc.id,
},
});
// Create a signature field for the recipient so distribution validation can run.
await prisma.field.create({
data: {
envelopeId: doc.id,
envelopeItemId: doc.envelopeItems[0].id,
recipientId: recipient.id,
type: FieldType.SIGNATURE,
page: 1,
positionX: 1,
positionY: 1,
width: 1,
height: 1,
customText: '',
inserted: false,
fieldMeta: { type: 'signature', fontSize: 14 },
},
});
const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/document/distribute`, {
headers: { Authorization: `Bearer ${tokenB}` },
data: { documentId: mapSecondaryIdToDocumentId(doc.secondaryId) },
@@ -207,6 +232,31 @@ test.describe('Document API V2', () => {
test('should allow authorized access to document distribute endpoint', async ({ request }) => {
const doc = await seedDraftDocument(userA, teamA.id, ['test@example.com']);
// Get the recipient created during seeding.
const recipient = await prisma.recipient.findFirstOrThrow({
where: {
envelopeId: doc.id,
},
});
// Create a signature field for the recipient so distribution validation can run.
await prisma.field.create({
data: {
envelopeId: doc.id,
envelopeItemId: doc.envelopeItems[0].id,
recipientId: recipient.id,
type: FieldType.SIGNATURE,
page: 1,
positionX: 1,
positionY: 1,
width: 1,
height: 1,
customText: '',
inserted: false,
fieldMeta: { type: 'signature', fontSize: 14 },
},
});
const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/document/distribute`, {
headers: { Authorization: `Bearer ${tokenA}` },
data: { documentId: mapSecondaryIdToDocumentId(doc.secondaryId) },
@@ -3678,6 +3728,26 @@ test.describe('Document API V2', () => {
internalVersion: 2,
});
const [recipient] = doc.recipients;
// add signing field for recipient (fieldMeta required for v2 envelopes)
await prisma.field.create({
data: {
page: 1,
type: FieldType.SIGNATURE,
inserted: false,
customText: '',
positionX: 1,
positionY: 1,
width: 1,
height: 1,
envelopeId: doc.id,
envelopeItemId: doc.envelopeItems[0].id,
recipientId: recipient.id,
fieldMeta: { type: 'signature', fontSize: 14 },
},
});
const payload: TUseEnvelopePayload = {
envelopeId: doc.id,
distributeDocument: true,
@@ -3741,6 +3811,31 @@ test.describe('Document API V2', () => {
}) => {
const doc = await seedDraftDocument(userA, teamA.id, ['test@example.com']);
// Get the recipient created during seeding.
const recipient = await prisma.recipient.findFirstOrThrow({
where: {
envelopeId: doc.id,
},
});
// Create a signature field for the recipient so distribution validation can pass.
await prisma.field.create({
data: {
envelopeId: doc.id,
envelopeItemId: doc.envelopeItems[0].id,
recipientId: recipient.id,
type: FieldType.SIGNATURE,
page: 1,
positionX: 1,
positionY: 1,
width: 1,
height: 1,
customText: '',
inserted: false,
fieldMeta: { type: 'signature', fontSize: 14 },
},
});
const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/envelope/distribute`, {
headers: { Authorization: `Bearer ${tokenA}` },
data: { envelopeId: doc.id },
@@ -257,10 +257,12 @@ test.describe('[DOCUMENT_FLOW]: Duplicate Recipients', () => {
// Change second recipient role if role selector is available
const roleDropdown = page.getByLabel('Role').nth(1);
let secondRecipientIsApprover = false;
if (await roleDropdown.isVisible()) {
await roleDropdown.click();
await page.getByText('Approver').click();
secondRecipientIsApprover = true;
}
// Step 3: Add different field types for each duplicate
@@ -281,6 +283,13 @@ test.describe('[DOCUMENT_FLOW]: Duplicate Recipients', () => {
await page.getByRole('button', { name: 'Date' }).click();
await page.locator('canvas').click({ position: { x: 200, y: 150 } });
// If second recipient is still a SIGNER (role change wasn't available),
// add a signature field for them to pass validation
if (!secondRecipientIsApprover) {
await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({ position: { x: 200, y: 200 } });
}
// Complete the document
await page.getByRole('button', { name: 'Continue' }).click();
@@ -1,4 +1,4 @@
import { PDFDocument } from '@cantoo/pdf-lib';
import { PDF } from '@libpdf/core';
import { expect, test } from '@playwright/test';
import { DocumentStatus, FieldType } from '@prisma/client';
@@ -43,7 +43,7 @@ test.describe('Signing Certificate Tests', () => {
return fetch(documentUrl).then(async (res) => await res.arrayBuffer());
});
const originalPdf = await PDFDocument.load(documentData);
const originalPdf = await PDF.load(new Uint8Array(documentData));
// Sign the document
await page.goto(`/sign/${recipient.token}`);
@@ -101,7 +101,7 @@ test.describe('Signing Certificate Tests', () => {
const completedDocumentData = new Uint8Array(pdfData);
// Load the PDF and check number of pages
const pdfDoc = await PDFDocument.load(completedDocumentData);
const pdfDoc = await PDF.load(new Uint8Array(completedDocumentData));
expect(pdfDoc.getPageCount()).toBe(originalPdf.getPageCount() + 1); // Original + Certificate
});
@@ -153,7 +153,7 @@ test.describe('Signing Certificate Tests', () => {
return fetch(documentUrl).then(async (res) => await res.arrayBuffer());
});
const originalPdf = await PDFDocument.load(documentData);
const originalPdf = await PDF.load(new Uint8Array(documentData));
// Sign the document
await page.goto(`/sign/${recipient.token}`);
@@ -206,7 +206,7 @@ test.describe('Signing Certificate Tests', () => {
const completedDocumentData = new Uint8Array(pdfData);
// Load the PDF and check number of pages
const completedPdf = await PDFDocument.load(completedDocumentData);
const completedPdf = await PDF.load(new Uint8Array(completedDocumentData));
expect(completedPdf.getPageCount()).toBe(originalPdf.getPageCount() + 1); // Original + Certificate
});
@@ -258,7 +258,7 @@ test.describe('Signing Certificate Tests', () => {
return fetch(documentUrl).then(async (res) => await res.arrayBuffer());
});
const originalPdf = await PDFDocument.load(new Uint8Array(documentData));
const originalPdf = await PDF.load(new Uint8Array(documentData));
// Sign the document
await page.goto(`/sign/${recipient.token}`);
@@ -309,7 +309,7 @@ test.describe('Signing Certificate Tests', () => {
);
// Load the PDF and check number of pages
const completedPdf = await PDFDocument.load(completedDocumentData);
const completedPdf = await PDF.load(new Uint8Array(completedDocumentData));
expect(completedPdf.getPageCount()).toBe(originalPdf.getPageCount());
});
@@ -0,0 +1,858 @@
import { PDF } from '@libpdf/core';
import { expect, test } from '@playwright/test';
import fs from 'node:fs';
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 { getFileServerSide } from '@documenso/lib/universal/upload/get-file.server';
import { mapSecondaryIdToTemplateId } from '@documenso/lib/utils/envelope';
import { prisma } from '@documenso/prisma';
import { EnvelopeType, RecipientRole } from '@documenso/prisma/client';
import { seedUser } from '@documenso/prisma/seed/users';
import type {
TCreateEnvelopePayload,
TCreateEnvelopeResponse,
} from '@documenso/trpc/server/envelope-router/create-envelope.types';
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
const baseUrl = `${WEBAPP_BASE_URL}/api/v2-beta`;
// Form field names in the test PDF
const FORM_FIELDS = {
TEXT_FIELD: 'test_text_field',
COMPANY_NAME: 'company_name',
CHECKBOX: 'accept_terms',
DROPDOWN: 'country',
} as const;
// Test values to insert into form fields
const TEST_FORM_VALUES = {
[FORM_FIELDS.TEXT_FIELD]: 'Hello World',
[FORM_FIELDS.COMPANY_NAME]: 'Documenso Inc.',
[FORM_FIELDS.CHECKBOX]: true,
[FORM_FIELDS.DROPDOWN]: 'Germany',
};
/**
* Helper to check if a PDF has interactive form fields.
* Returns true if the PDF has form fields, false if they've been flattened.
*/
async function pdfHasFormFields(pdfBuffer: Uint8Array): Promise<boolean> {
const pdfDoc = await PDF.load(new Uint8Array(pdfBuffer));
const form = await pdfDoc.getForm();
if (!form) {
return false;
}
return form.fieldCount > 0;
}
/**
* Helper to get form field names from a PDF.
*/
async function getPdfFormFieldNames(pdfBuffer: Uint8Array): Promise<string[]> {
const pdfDoc = await PDF.load(new Uint8Array(pdfBuffer));
const form = await pdfDoc.getForm();
if (!form) {
return [];
}
return form.getFieldNames();
}
/**
* Helper to get the value of a text field in a PDF.
*/
async function getPdfTextFieldValue(
pdfBuffer: Uint8Array,
fieldName: string,
): Promise<string | undefined> {
const pdfDoc = await PDF.load(new Uint8Array(pdfBuffer));
const form = await pdfDoc.getForm();
if (!form) {
return undefined;
}
const textField = form.getTextField(fieldName);
if (!textField) {
return undefined;
}
return textField.getValue();
}
test.describe.configure({
mode: 'parallel',
});
test.describe('Form Flattening', () => {
const formFieldsPdf = fs.readFileSync(
path.join(__dirname, '../../../../assets/form-fields-test.pdf'),
);
test.describe('Envelope Creation (DOCUMENT type)', () => {
test('should flatten form fields when creating a DOCUMENT envelope with formValues', async ({
request,
}) => {
const { user, team } = await seedUser();
const { token } = await createApiToken({
userId: user.id,
teamId: team.id,
tokenName: 'test',
expiresIn: null,
});
const payload: TCreateEnvelopePayload = {
type: EnvelopeType.DOCUMENT,
title: 'Document with Form Values',
formValues: TEST_FORM_VALUES,
};
const formData = new FormData();
formData.append('payload', JSON.stringify(payload));
formData.append(
'files',
new File([formFieldsPdf], 'form-fields-test.pdf', { type: 'application/pdf' }),
);
const res = await request.post(`${baseUrl}/envelope/create`, {
headers: { Authorization: `Bearer ${token}` },
multipart: formData,
});
expect(res.ok()).toBeTruthy();
expect(res.status()).toBe(200);
const response = (await res.json()) as TCreateEnvelopeResponse;
// Verify the envelope was created with the correct formValues
const envelope = await prisma.envelope.findUniqueOrThrow({
where: { id: response.id },
include: {
envelopeItems: {
include: { documentData: true },
},
},
});
expect(envelope.formValues).toEqual(TEST_FORM_VALUES);
expect(envelope.type).toBe(EnvelopeType.DOCUMENT);
// Get the PDF and verify form fields are flattened
const documentData = envelope.envelopeItems[0].documentData;
const pdfBuffer = await getFileServerSide(documentData);
const hasFormFields = await pdfHasFormFields(pdfBuffer);
expect(hasFormFields).toBe(false);
});
test('should flatten form fields when creating a DOCUMENT envelope without formValues', async ({
request,
}) => {
const { user, team } = await seedUser();
const { token } = await createApiToken({
userId: user.id,
teamId: team.id,
tokenName: 'test',
expiresIn: null,
});
const payload: TCreateEnvelopePayload = {
type: EnvelopeType.DOCUMENT,
title: 'Document without Form Values',
// No formValues - but form should still be flattened for DOCUMENT type
};
const formData = new FormData();
formData.append('payload', JSON.stringify(payload));
formData.append(
'files',
new File([formFieldsPdf], 'form-fields-test.pdf', { type: 'application/pdf' }),
);
const res = await request.post(`${baseUrl}/envelope/create`, {
headers: { Authorization: `Bearer ${token}` },
multipart: formData,
});
expect(res.ok()).toBeTruthy();
expect(res.status()).toBe(200);
const response = (await res.json()) as TCreateEnvelopeResponse;
const envelope = await prisma.envelope.findUniqueOrThrow({
where: { id: response.id },
include: {
envelopeItems: {
include: { documentData: true },
},
},
});
// Get the PDF and verify form fields are flattened
const documentData = envelope.envelopeItems[0].documentData;
const pdfBuffer = await getFileServerSide(documentData);
const hasFormFields = await pdfHasFormFields(pdfBuffer);
expect(hasFormFields).toBe(false);
});
});
test.describe('Template Creation (TEMPLATE type)', () => {
test('should NOT flatten form fields when creating a TEMPLATE envelope', async ({
request,
}) => {
const { user, team } = await seedUser();
const { token } = await createApiToken({
userId: user.id,
teamId: team.id,
tokenName: 'test',
expiresIn: null,
});
const payload: TCreateEnvelopePayload = {
type: EnvelopeType.TEMPLATE,
title: 'Template with Form Fields',
// Note: formValues can be set but form should NOT be flattened for templates
};
const formData = new FormData();
formData.append('payload', JSON.stringify(payload));
formData.append(
'files',
new File([formFieldsPdf], 'form-fields-test.pdf', { type: 'application/pdf' }),
);
const res = await request.post(`${baseUrl}/envelope/create`, {
headers: { Authorization: `Bearer ${token}` },
multipart: formData,
});
expect(res.ok()).toBeTruthy();
expect(res.status()).toBe(200);
const response = (await res.json()) as TCreateEnvelopeResponse;
const envelope = await prisma.envelope.findUniqueOrThrow({
where: { id: response.id },
include: {
envelopeItems: {
include: { documentData: true },
},
},
});
expect(envelope.type).toBe(EnvelopeType.TEMPLATE);
// Get the PDF and verify form fields are NOT flattened
const documentData = envelope.envelopeItems[0].documentData;
const pdfBuffer = await getFileServerSide(documentData);
const hasFormFields = await pdfHasFormFields(pdfBuffer);
expect(hasFormFields).toBe(true);
// Verify the specific form fields still exist
const fieldNames = await getPdfFormFieldNames(pdfBuffer);
expect(fieldNames).toContain(FORM_FIELDS.TEXT_FIELD);
expect(fieldNames).toContain(FORM_FIELDS.COMPANY_NAME);
expect(fieldNames).toContain(FORM_FIELDS.CHECKBOX);
expect(fieldNames).toContain(FORM_FIELDS.DROPDOWN);
});
test('should preserve form fields in template even when formValues are provided', async ({
request,
}) => {
const { user, team } = await seedUser();
const { token } = await createApiToken({
userId: user.id,
teamId: team.id,
tokenName: 'test',
expiresIn: null,
});
const payload: TCreateEnvelopePayload = {
type: EnvelopeType.TEMPLATE,
title: 'Template with Form Values',
formValues: TEST_FORM_VALUES,
};
const formData = new FormData();
formData.append('payload', JSON.stringify(payload));
formData.append(
'files',
new File([formFieldsPdf], 'form-fields-test.pdf', { type: 'application/pdf' }),
);
const res = await request.post(`${baseUrl}/envelope/create`, {
headers: { Authorization: `Bearer ${token}` },
multipart: formData,
});
expect(res.ok()).toBeTruthy();
expect(res.status()).toBe(200);
const response = (await res.json()) as TCreateEnvelopeResponse;
const envelope = await prisma.envelope.findUniqueOrThrow({
where: { id: response.id },
include: {
envelopeItems: {
include: { documentData: true },
},
},
});
// formValues should be stored in the database
expect(envelope.formValues).toEqual(TEST_FORM_VALUES);
expect(envelope.type).toBe(EnvelopeType.TEMPLATE);
// But the PDF should still have interactive form fields
const documentData = envelope.envelopeItems[0].documentData;
const pdfBuffer = await getFileServerSide(documentData);
const hasFormFields = await pdfHasFormFields(pdfBuffer);
expect(hasFormFields).toBe(true);
expect(await getPdfTextFieldValue(pdfBuffer, FORM_FIELDS.TEXT_FIELD)).toBe(
TEST_FORM_VALUES[FORM_FIELDS.TEXT_FIELD],
);
});
});
test.describe('Document from Template', () => {
test('should flatten form fields when creating document from template with formValues', async ({
request,
}) => {
const { user, team } = await seedUser();
const { token } = await createApiToken({
userId: user.id,
teamId: team.id,
tokenName: 'test',
expiresIn: null,
});
// First, create a template via API
const templatePayload: TCreateEnvelopePayload = {
type: EnvelopeType.TEMPLATE,
title: 'Template for Document Creation',
recipients: [
{
email: 'recipient@example.com',
name: 'Test Recipient',
role: RecipientRole.SIGNER,
},
],
};
const templateFormData = new FormData();
templateFormData.append('payload', JSON.stringify(templatePayload));
templateFormData.append(
'files',
new File([formFieldsPdf], 'form-fields-test.pdf', { type: 'application/pdf' }),
);
const templateRes = await request.post(`${baseUrl}/envelope/create`, {
headers: { Authorization: `Bearer ${token}` },
multipart: templateFormData,
});
expect(templateRes.ok()).toBeTruthy();
const templateResponse = (await templateRes.json()) as TCreateEnvelopeResponse;
// Verify template has form fields
const template = await prisma.envelope.findUniqueOrThrow({
where: { id: templateResponse.id },
include: {
envelopeItems: { include: { documentData: true } },
recipients: true,
},
});
const templatePdfBuffer = await getFileServerSide(template.envelopeItems[0].documentData);
expect(await pdfHasFormFields(templatePdfBuffer)).toBe(true);
// Now create a document from the template with formValues
const useTemplateRes = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/template/use`, {
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
data: {
templateId: mapSecondaryIdToTemplateId(template.secondaryId),
recipients: [
{
id: template.recipients[0].id,
email: 'recipient@example.com',
name: 'Test Recipient',
},
],
formValues: TEST_FORM_VALUES,
},
});
expect(useTemplateRes.ok()).toBeTruthy();
expect(useTemplateRes.status()).toBe(200);
const documentResponse = await useTemplateRes.json();
// Get the created document
const document = await prisma.envelope.findFirstOrThrow({
where: {
id: documentResponse.envelopeId,
},
include: {
envelopeItems: { include: { documentData: true } },
},
});
expect(document.type).toBe(EnvelopeType.DOCUMENT);
// Verify form fields are flattened in the created document
const documentPdfBuffer = await getFileServerSide(document.envelopeItems[0].documentData);
const hasFormFields = await pdfHasFormFields(documentPdfBuffer);
expect(hasFormFields).toBe(false);
});
test('should flatten form fields when creating document from template without formValues', async ({
request,
}) => {
const { user, team } = await seedUser();
const { token } = await createApiToken({
userId: user.id,
teamId: team.id,
tokenName: 'test',
expiresIn: null,
});
// Create a template
const templatePayload: TCreateEnvelopePayload = {
type: EnvelopeType.TEMPLATE,
title: 'Template without Form Values',
recipients: [
{
email: 'recipient@example.com',
name: 'Test Recipient',
role: RecipientRole.SIGNER,
},
],
};
const templateFormData = new FormData();
templateFormData.append('payload', JSON.stringify(templatePayload));
templateFormData.append(
'files',
new File([formFieldsPdf], 'form-fields-test.pdf', { type: 'application/pdf' }),
);
const templateRes = await request.post(`${baseUrl}/envelope/create`, {
headers: { Authorization: `Bearer ${token}` },
multipart: templateFormData,
});
expect(templateRes.ok()).toBeTruthy();
const templateResponse = (await templateRes.json()) as TCreateEnvelopeResponse;
const template = await prisma.envelope.findUniqueOrThrow({
where: { id: templateResponse.id },
include: {
envelopeItems: { include: { documentData: true } },
recipients: true,
},
});
// Create document from template WITHOUT formValues
const useTemplateRes = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/template/use`, {
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
data: {
templateId: mapSecondaryIdToTemplateId(template.secondaryId),
recipients: [
{
id: template.recipients[0].id,
email: 'recipient@example.com',
name: 'Test Recipient',
},
],
// No formValues provided
},
});
expect(useTemplateRes.ok()).toBeTruthy();
const documentResponse = await useTemplateRes.json();
const document = await prisma.envelope.findFirstOrThrow({
where: { id: documentResponse.envelopeId },
include: {
envelopeItems: { include: { documentData: true } },
},
});
expect(document.type).toBe(EnvelopeType.DOCUMENT);
// Form fields should still be flattened even without formValues
const documentPdfBuffer = await getFileServerSide(document.envelopeItems[0].documentData);
const hasFormFields = await pdfHasFormFields(documentPdfBuffer);
expect(hasFormFields).toBe(false);
});
test('should use template formValues when creating document without override', async ({
request,
}) => {
const { user, team } = await seedUser();
const { token } = await createApiToken({
userId: user.id,
teamId: team.id,
tokenName: 'test',
expiresIn: null,
});
// Create a template with formValues
const templatePayload: TCreateEnvelopePayload = {
type: EnvelopeType.TEMPLATE,
title: 'Template with Default Form Values',
formValues: {
[FORM_FIELDS.TEXT_FIELD]: 'Default Value',
[FORM_FIELDS.COMPANY_NAME]: 'Default Company',
},
recipients: [
{
email: 'recipient@example.com',
name: 'Test Recipient',
role: RecipientRole.SIGNER,
},
],
};
const templateFormData = new FormData();
templateFormData.append('payload', JSON.stringify(templatePayload));
templateFormData.append(
'files',
new File([formFieldsPdf], 'form-fields-test.pdf', { type: 'application/pdf' }),
);
const templateRes = await request.post(`${baseUrl}/envelope/create`, {
headers: { Authorization: `Bearer ${token}` },
multipart: templateFormData,
});
expect(templateRes.ok()).toBeTruthy();
const templateResponse = (await templateRes.json()) as TCreateEnvelopeResponse;
const template = await prisma.envelope.findUniqueOrThrow({
where: { id: templateResponse.id },
include: {
envelopeItems: { include: { documentData: true } },
recipients: true,
},
});
// Verify template stored the formValues
expect(template.formValues).toEqual({
[FORM_FIELDS.TEXT_FIELD]: 'Default Value',
[FORM_FIELDS.COMPANY_NAME]: 'Default Company',
});
// Create document from template without providing new formValues
// The template's formValues should be used
const useTemplateRes = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/template/use`, {
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
data: {
templateId: mapSecondaryIdToTemplateId(template.secondaryId),
recipients: [
{
id: template.recipients[0].id,
email: 'recipient@example.com',
name: 'Test Recipient',
},
],
// No formValues - should inherit from template
},
});
expect(useTemplateRes.ok()).toBeTruthy();
const documentResponse = await useTemplateRes.json();
const document = await prisma.envelope.findFirstOrThrow({
where: { id: documentResponse.envelopeId },
include: {
envelopeItems: { include: { documentData: true } },
},
});
// Form fields should be flattened
const documentPdfBuffer = await getFileServerSide(document.envelopeItems[0].documentData);
expect(await pdfHasFormFields(documentPdfBuffer)).toBe(false);
});
});
test.describe('Form Values Verification', () => {
test('should correctly insert form values into PDF before flattening', async ({ request }) => {
const { user, team } = await seedUser();
const { token } = await createApiToken({
userId: user.id,
teamId: team.id,
tokenName: 'test',
expiresIn: null,
});
// Create a template first (form fields preserved)
const templatePayload: TCreateEnvelopePayload = {
type: EnvelopeType.TEMPLATE,
title: 'Template for Value Verification',
recipients: [
{
email: 'recipient@example.com',
name: 'Test Recipient',
role: RecipientRole.SIGNER,
},
],
};
const templateFormData = new FormData();
templateFormData.append('payload', JSON.stringify(templatePayload));
templateFormData.append(
'files',
new File([formFieldsPdf], 'form-fields-test.pdf', { type: 'application/pdf' }),
);
const templateRes = await request.post(`${baseUrl}/envelope/create`, {
headers: { Authorization: `Bearer ${token}` },
multipart: templateFormData,
});
const templateResponse = (await templateRes.json()) as TCreateEnvelopeResponse;
const template = await prisma.envelope.findUniqueOrThrow({
where: { id: templateResponse.id },
include: {
envelopeItems: { include: { documentData: true } },
recipients: true,
},
});
// Verify template PDF still has form fields
const templatePdfBuffer = await getFileServerSide(template.envelopeItems[0].documentData);
expect(await pdfHasFormFields(templatePdfBuffer)).toBe(true);
// Verify we can read a text field value (should be empty initially)
const initialValue = await getPdfTextFieldValue(templatePdfBuffer, FORM_FIELDS.TEXT_FIELD);
expect(initialValue).toBe('');
// Now create a document with form values
const testValues = {
[FORM_FIELDS.TEXT_FIELD]: 'Inserted Text Value',
[FORM_FIELDS.COMPANY_NAME]: 'Test Company Name',
};
const useTemplateRes = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/template/use`, {
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
data: {
templateId: mapSecondaryIdToTemplateId(template.secondaryId),
recipients: [
{
id: template.recipients[0].id,
email: 'recipient@example.com',
name: 'Test Recipient',
},
],
formValues: testValues,
},
});
expect(useTemplateRes.ok()).toBeTruthy();
const documentResponse = await useTemplateRes.json();
const document = await prisma.envelope.findFirstOrThrow({
where: { id: documentResponse.envelopeId },
include: {
envelopeItems: { include: { documentData: true } },
},
});
// The form should be flattened, so we can't read form fields
const documentPdfBuffer = await getFileServerSide(document.envelopeItems[0].documentData);
expect(await pdfHasFormFields(documentPdfBuffer)).toBe(false);
// The values should have been inserted before flattening
// We can't verify the actual text content easily without visual inspection,
// but we can verify the form fields are gone (flattened)
const fieldNames = await getPdfFormFieldNames(documentPdfBuffer);
expect(fieldNames.length).toBe(0);
});
});
test.describe('Edge Cases', () => {
test('should handle PDF without form fields gracefully', async ({ request }) => {
const { user, team } = await seedUser();
const { token } = await createApiToken({
userId: user.id,
teamId: team.id,
tokenName: 'test',
expiresIn: null,
});
// Use a PDF without form fields
const examplePdf = fs.readFileSync(path.join(__dirname, '../../../../assets/example.pdf'));
const payload: TCreateEnvelopePayload = {
type: EnvelopeType.DOCUMENT,
title: 'Document with No Form Fields',
formValues: {
nonexistent_field: 'Some Value',
},
};
const formData = new FormData();
formData.append('payload', JSON.stringify(payload));
formData.append('files', new File([examplePdf], 'example.pdf', { type: 'application/pdf' }));
const res = await request.post(`${baseUrl}/envelope/create`, {
headers: { Authorization: `Bearer ${token}` },
multipart: formData,
});
// Should succeed even with formValues for non-existent fields
expect(res.ok()).toBeTruthy();
expect(res.status()).toBe(200);
const response = (await res.json()) as TCreateEnvelopeResponse;
const envelope = await prisma.envelope.findUniqueOrThrow({
where: { id: response.id },
include: {
envelopeItems: { include: { documentData: true } },
},
});
expect(envelope.formValues).toEqual({ nonexistent_field: 'Some Value' });
});
test('should handle empty formValues object', async ({ request }) => {
const { user, team } = await seedUser();
const { token } = await createApiToken({
userId: user.id,
teamId: team.id,
tokenName: 'test',
expiresIn: null,
});
const payload: TCreateEnvelopePayload = {
type: EnvelopeType.DOCUMENT,
title: 'Document with Empty Form Values',
formValues: {},
};
const formData = new FormData();
formData.append('payload', JSON.stringify(payload));
formData.append(
'files',
new File([formFieldsPdf], 'form-fields-test.pdf', { type: 'application/pdf' }),
);
const res = await request.post(`${baseUrl}/envelope/create`, {
headers: { Authorization: `Bearer ${token}` },
multipart: formData,
});
expect(res.ok()).toBeTruthy();
expect(res.status()).toBe(200);
const response = (await res.json()) as TCreateEnvelopeResponse;
const envelope = await prisma.envelope.findUniqueOrThrow({
where: { id: response.id },
include: {
envelopeItems: { include: { documentData: true } },
},
});
// Form should still be flattened for DOCUMENT type
const documentData = envelope.envelopeItems[0].documentData;
const pdfBuffer = await getFileServerSide(documentData);
expect(await pdfHasFormFields(pdfBuffer)).toBe(false);
});
test('should handle partial formValues (only some fields)', async ({ request }) => {
const { user, team } = await seedUser();
const { token } = await createApiToken({
userId: user.id,
teamId: team.id,
tokenName: 'test',
expiresIn: null,
});
const payload: TCreateEnvelopePayload = {
type: EnvelopeType.DOCUMENT,
title: 'Document with Partial Form Values',
formValues: {
[FORM_FIELDS.TEXT_FIELD]: 'Only this field',
// Other fields not set
},
};
const formData = new FormData();
formData.append('payload', JSON.stringify(payload));
formData.append(
'files',
new File([formFieldsPdf], 'form-fields-test.pdf', { type: 'application/pdf' }),
);
const res = await request.post(`${baseUrl}/envelope/create`, {
headers: { Authorization: `Bearer ${token}` },
multipart: formData,
});
expect(res.ok()).toBeTruthy();
expect(res.status()).toBe(200);
const response = (await res.json()) as TCreateEnvelopeResponse;
const envelope = await prisma.envelope.findUniqueOrThrow({
where: { id: response.id },
include: {
envelopeItems: { include: { documentData: true } },
},
});
// Should store the partial formValues
expect(envelope.formValues).toEqual({
[FORM_FIELDS.TEXT_FIELD]: 'Only this field',
});
// Form should still be flattened
const documentData = envelope.envelopeItems[0].documentData;
const pdfBuffer = await getFileServerSide(documentData);
expect(await pdfHasFormFields(pdfBuffer)).toBe(false);
});
});
});
@@ -0,0 +1,427 @@
import { expect, test } from '@playwright/test';
import * as fs from 'node:fs';
import * as 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 { EnvelopeType, RecipientRole } from '@documenso/prisma/client';
import { seedTeam } from '@documenso/prisma/seed/teams';
import { seedBlankTemplate } from '@documenso/prisma/seed/templates';
import type {
TCreateEnvelopePayload,
TCreateEnvelopeResponse,
} from '@documenso/trpc/server/envelope-router/create-envelope.types';
import { apiSignin } from '../fixtures/authentication';
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
const baseUrl = `${WEBAPP_BASE_URL}/api/v2-beta`;
test.describe.configure({ mode: 'parallel' });
/**
* Helper function to set default recipients for a team
*/
const setTeamDefaultRecipients = async (
teamId: number,
defaultRecipients: Array<{ email: string; name: string; role: RecipientRole }>,
) => {
const teamSettings = await prisma.teamGlobalSettings.findFirstOrThrow({
where: {
team: {
id: teamId,
},
},
});
await prisma.teamGlobalSettings.update({
where: {
id: teamSettings.id,
},
data: {
defaultRecipients,
},
});
};
test.describe('Default Recipients', () => {
test('[DEFAULT_RECIPIENTS]: default recipients are added to documents created via UI', async ({
page,
}) => {
const { team, owner } = await seedTeam({
createTeamMembers: 2,
});
// Get a team member to set as default recipient
const teamMembers = await prisma.organisationMember.findMany({
where: {
organisationId: team.organisationId,
userId: {
not: owner.id,
},
},
include: {
user: true,
},
});
const defaultRecipientUser = teamMembers[0].user;
// Set up default recipients for the team
await setTeamDefaultRecipients(team.id, [
{
email: defaultRecipientUser.email,
name: defaultRecipientUser.name || defaultRecipientUser.email,
role: RecipientRole.CC,
},
]);
await apiSignin({
page,
email: owner.email,
redirectPath: `/t/${team.url}/documents`,
});
// Upload document via UI - this triggers document creation with default recipients
const [fileChooser] = await Promise.all([
page.waitForEvent('filechooser'),
page
.locator('input[type=file]')
.nth(1)
.evaluate((e) => {
if (e instanceof HTMLInputElement) {
e.click();
}
}),
]);
await fileChooser.setFiles(path.join(__dirname, '../../../../assets/example.pdf'));
// Wait to be redirected to the edit page (v2 envelope editor)
await page.waitForURL(new RegExp(`/t/${team.url}/documents/envelope_.*`));
// Extract document ID from URL
const urlParts = page.url().split('/');
const documentId = urlParts.find((part) => part.startsWith('envelope_'));
// Wait for the Recipients card to be visible (v2 envelope editor)
await expect(page.getByRole('heading', { name: 'Recipients' })).toBeVisible();
await expect(page.getByTestId('signer-email-input').first()).not.toBeEmpty();
await page.getByRole('button', { name: 'Add Signer' }).click();
// Add a regular signer using the v2 editor
await page.getByTestId('signer-email-input').last().fill('regular-signer@documenso.com');
await page
.getByPlaceholder(/Recipient/)
.first()
.fill('Regular Signer');
// Wait for autosave to complete
await page.waitForTimeout(3000);
// Verify that default recipient is present in the database
await expect(async () => {
const envelope = await prisma.envelope.findFirstOrThrow({
where: {
id: documentId,
},
include: {
recipients: true,
},
});
// Should have 2 recipients: the regular signer + the default recipient
expect(envelope.recipients.length).toBe(2);
const defaultRecipient = envelope.recipients.find(
(r) => r.email.toLowerCase() === defaultRecipientUser.email.toLowerCase(),
);
expect(defaultRecipient).toBeDefined();
expect(defaultRecipient?.role).toBe(RecipientRole.CC);
const regularSigner = envelope.recipients.find(
(r) => r.email === 'regular-signer@documenso.com',
);
expect(regularSigner).toBeDefined();
}).toPass();
});
// TODO: Are we intending to allow default recipients to be removed from a document?
test.skip('[DEFAULT_RECIPIENTS]: default recipients cannot be removed from a document', async ({
page,
}) => {
const { team, owner } = await seedTeam({
createTeamMembers: 2,
});
// Get a team member to set as default recipient
const teamMembers = await prisma.organisationMember.findMany({
where: {
organisationId: team.organisationId,
userId: {
not: owner.id,
},
},
include: {
user: true,
},
});
const defaultRecipientUser = teamMembers[0].user;
// Set up default recipients for the team
await setTeamDefaultRecipients(team.id, [
{
email: defaultRecipientUser.email,
name: defaultRecipientUser.name || defaultRecipientUser.email,
role: RecipientRole.CC,
},
]);
await apiSignin({
page,
email: owner.email,
redirectPath: `/t/${team.url}/documents`,
});
// Upload document via UI - this triggers document creation with default recipients
const [fileChooser] = await Promise.all([
page.waitForEvent('filechooser'),
page
.locator('input[type=file]')
.nth(1)
.evaluate((e) => {
if (e instanceof HTMLInputElement) {
e.click();
}
}),
]);
await fileChooser.setFiles(path.join(__dirname, '../../../../assets/example.pdf'));
// Wait to be redirected to the edit page (v2 envelope editor)
await page.waitForURL(new RegExp(`/t/${team.url}/documents/envelope_.*`));
// Extract document ID from URL
const urlParts = page.url().split('/');
const documentId = urlParts.find((part) => part.startsWith('envelope_'));
// Replace the default recipient with a regular signer
await page.getByTestId('signer-email-input').first().fill('regular-signer@documenso.com');
await page
.getByPlaceholder(/Recipient/)
.first()
.fill('Regular Signer');
// Wait for autosave to complete
await page.waitForTimeout(3000);
// Wait for recipients to be saved
await expect(async () => {
const envelope = await prisma.envelope.findFirstOrThrow({
where: {
id: documentId,
},
include: {
recipients: true,
},
});
expect(envelope.recipients.length).toBe(2);
}).toPass();
// Verify that the default recipient's remove button is disabled
// In the v2 editor, default recipients should have a disabled remove button
// Find the fieldset containing the default recipient's email and check if its remove button is disabled
const defaultRecipientRow = page.locator('fieldset').filter({
hasText: defaultRecipientUser.email,
});
// The default recipient row should exist and have a disabled remove button
await expect(defaultRecipientRow).toHaveCount(1);
const removeButton = defaultRecipientRow.getByTestId('remove-signer-button');
await expect(removeButton).toBeDisabled();
});
test('[DEFAULT_RECIPIENTS]: documents created via API have default recipients', async ({
request,
}) => {
const { team, owner } = await seedTeam({
createTeamMembers: 2,
});
// Get a team member to set as default recipient
const teamMembers = await prisma.organisationMember.findMany({
where: {
organisationId: team.organisationId,
userId: {
not: owner.id,
},
},
include: {
user: true,
},
});
const defaultRecipientUser = teamMembers[0].user;
// Set up default recipients for the team
await setTeamDefaultRecipients(team.id, [
{
email: defaultRecipientUser.email,
name: defaultRecipientUser.name || defaultRecipientUser.email,
role: RecipientRole.CC,
},
]);
// Create API token
const { token } = await createApiToken({
userId: owner.id,
teamId: team.id,
tokenName: 'test-token',
expiresIn: null,
});
// Create envelope via API
const payload: TCreateEnvelopePayload = {
type: EnvelopeType.DOCUMENT,
title: 'Test Document with Default Recipients',
recipients: [
{
email: 'api-recipient@documenso.com',
name: 'API Recipient',
role: RecipientRole.SIGNER,
},
],
};
const formData = new FormData();
formData.append('payload', JSON.stringify(payload));
const pdfData = fs.readFileSync(path.join(__dirname, '../../../../assets/example.pdf'));
formData.append('files', new File([pdfData], 'test.pdf', { type: 'application/pdf' }));
const res = await request.post(`${baseUrl}/envelope/create`, {
headers: { Authorization: `Bearer ${token}` },
multipart: formData,
});
expect(res.ok()).toBeTruthy();
expect(res.status()).toBe(200);
const response = (await res.json()) as TCreateEnvelopeResponse;
// Verify the envelope has both the API recipient and the default recipient
const envelope = await prisma.envelope.findUniqueOrThrow({
where: {
id: response.id,
},
include: {
recipients: true,
},
});
expect(envelope.recipients.length).toBe(2);
const apiRecipient = envelope.recipients.find((r) => r.email === 'api-recipient@documenso.com');
expect(apiRecipient).toBeDefined();
expect(apiRecipient?.role).toBe(RecipientRole.SIGNER);
const defaultRecipient = envelope.recipients.find(
(r) => r.email.toLowerCase() === defaultRecipientUser.email.toLowerCase(),
);
expect(defaultRecipient).toBeDefined();
expect(defaultRecipient?.role).toBe(RecipientRole.CC);
});
test('[DEFAULT_RECIPIENTS]: documents created from template have default recipients', async ({
page,
}) => {
const { team, owner } = await seedTeam({
createTeamMembers: 2,
});
// Get a team member to set as default recipient
const teamMembers = await prisma.organisationMember.findMany({
where: {
organisationId: team.organisationId,
userId: {
not: owner.id,
},
},
include: {
user: true,
},
});
const defaultRecipientUser = teamMembers[0].user;
// Set up default recipients for the team
await setTeamDefaultRecipients(team.id, [
{
email: defaultRecipientUser.email,
name: defaultRecipientUser.name || defaultRecipientUser.email,
role: RecipientRole.CC,
},
]);
// Create a template
const template = await seedBlankTemplate(owner, team.id);
await apiSignin({
page,
email: owner.email,
redirectPath: `/t/${team.url}/templates/${template.id}/edit`,
});
// Set template title
await page.getByLabel('Title').fill('Template with Default Recipients');
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('heading', { name: 'Add Placeholder' })).toBeVisible();
// Add a template recipient
await page.getByPlaceholder('Email').fill('template-recipient@documenso.com');
await page.getByPlaceholder('Name').fill('Template Recipient');
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
await page.getByRole('button', { name: 'Save template' }).click();
// Use template to create document
await page.waitForURL(`/t/${team.url}/templates`);
await page.getByRole('button', { name: 'Use Template' }).click();
await page.getByRole('button', { name: 'Create as draft' }).click();
// Wait for document to be created
await page.waitForURL(new RegExp(`/t/${team.url}/documents/envelope_.*`));
const documentId = page.url().split('/').pop();
// Verify the document has both the template recipient and the default recipient
const document = await prisma.envelope.findFirstOrThrow({
where: {
id: documentId,
},
include: {
recipients: true,
},
});
expect(document.recipients.length).toBe(2);
const templateRecipient = document.recipients.find(
(r) => r.email === 'template-recipient@documenso.com',
);
expect(templateRecipient).toBeDefined();
const defaultRecipient = document.recipients.find(
(r) => r.email.toLowerCase() === defaultRecipientUser.email.toLowerCase(),
);
expect(defaultRecipient).toBeDefined();
expect(defaultRecipient?.role).toBe(RecipientRole.CC);
});
});
@@ -50,11 +50,13 @@ const completeTemplateFlowWithDuplicateRecipients = async (options: {
await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({ position: { x: 200, y: 100 } });
// Switch to different recipient and add their field
// Switch to different recipient and add their fields
await page.getByRole('combobox').first().click();
await page.getByText('Different Recipient').first().click();
await page.getByRole('button', { name: 'Name' }).click();
await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({ position: { x: 300, y: 100 } });
await page.getByRole('button', { name: 'Name' }).click();
await page.locator('canvas').click({ position: { x: 300, y: 150 } });
// Save template
await page.getByRole('button', { name: 'Save Template' }).click();
@@ -505,7 +505,9 @@ test('[TEMPLATE]: should create a document from a template using template docume
});
expect(document.title).toEqual('TEMPLATE_WITH_ORIGINAL_DOC');
expect(firstDocumentData.data).toEqual(templateWithData.envelopeItems[0].documentData.data);
expect(firstDocumentData.initialData).toEqual(
templateWithData.envelopeItems[0].documentData.data,
);
expect(firstDocumentData.initialData).toEqual(
templateWithData.envelopeItems[0].documentData.initialData,
);
+3 -2
View File
@@ -15,11 +15,12 @@
"@hono/standard-validator": "^0.2.0",
"@oslojs/crypto": "^1.0.1",
"@oslojs/encoding": "^1.1.0",
"@simplewebauthn/server": "^13.2.2",
"arctic": "^3.7.0",
"hono": "4.10.6",
"hono": "4.11.4",
"luxon": "^3.7.2",
"nanoid": "^5.1.6",
"ts-pattern": "^5.9.0",
"zod": "^3.25.76"
}
}
}
@@ -30,11 +30,17 @@ type HandleOAuthAuthorizeUrlOptions = {
prompt?: 'none' | 'login' | 'consent' | 'select_account';
};
const isOidcPrompt = (value: unknown): value is HandleOAuthAuthorizeUrlOptions['prompt'] => {
return value === 'none' || value === 'login' || value === 'consent' || value === 'select_account';
};
const oauthCookieMaxAge = 60 * 10; // 10 minutes.
export const handleOAuthAuthorizeUrl = async (options: HandleOAuthAuthorizeUrlOptions) => {
const { c, clientOptions, redirectPath } = options;
let prompt = options.prompt ?? 'login';
if (!clientOptions.clientId || !clientOptions.clientSecret) {
throw new AppError(AppErrorCode.NOT_SETUP);
}
@@ -63,12 +69,12 @@ export const handleOAuthAuthorizeUrl = async (options: HandleOAuthAuthorizeUrlOp
);
// Pass the prompt to the authorization endpoint.
if (process.env.NEXT_PRIVATE_OIDC_PROMPT !== '') {
const prompt = process.env.NEXT_PRIVATE_OIDC_PROMPT ?? 'login';
url.searchParams.append('prompt', prompt);
if (process.env.NEXT_PRIVATE_OIDC_PROMPT && isOidcPrompt(process.env.NEXT_PRIVATE_OIDC_PROMPT)) {
prompt = process.env.NEXT_PRIVATE_OIDC_PROMPT;
}
url.searchParams.set('prompt', prompt);
setCookie(c, `${clientOptions.id}_oauth_state`, state, {
...sessionCookieOptions,
sameSite: 'lax',
+4 -3
View File
@@ -1,6 +1,7 @@
import { sValidator } from '@hono/standard-validator';
import { UserSecurityAuditLogType } from '@prisma/client';
import { verifyAuthenticationResponse } from '@simplewebauthn/server';
import { isoBase64URL } from '@simplewebauthn/server/helpers';
import { Hono } from 'hono';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
@@ -80,9 +81,9 @@ export const passkeyRoute = new Hono<HonoAuthContext>()
expectedChallenge: challengeToken.token,
expectedOrigin: origin,
expectedRPID: rpId,
authenticator: {
credentialID: new Uint8Array(Array.from(passkey.credentialId)),
credentialPublicKey: new Uint8Array(passkey.credentialPublicKey),
credential: {
id: isoBase64URL.fromBuffer(passkey.credentialId),
publicKey: new Uint8Array(passkey.credentialPublicKey),
counter: Number(passkey.counter),
},
}).catch(() => null);
+1 -1
View File
@@ -70,7 +70,7 @@ export const getServerLimits = async ({
}
// Early return for users with an expired subscription.
if (subscription && subscription.status !== SubscriptionStatus.ACTIVE) {
if (subscription && subscription.status === SubscriptionStatus.INACTIVE) {
return {
quota: INACTIVE_PLAN_LIMITS,
remaining: INACTIVE_PLAN_LIMITS,
@@ -0,0 +1,106 @@
import { useId } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { DocumentSigningOrder, type Recipient, RecipientRole } from '@prisma/client';
import type { UseFormReturn } from 'react-hook-form';
import { useForm } from 'react-hook-form';
import { prop, sortBy } from 'remeda';
import { z } from 'zod';
import {
ZRecipientActionAuthTypesSchema,
ZRecipientAuthOptionsSchema,
} from '@documenso/lib/types/document-auth';
import { ZRecipientEmailSchema } from '@documenso/lib/types/recipient';
import type { TEnvelope } from '../../types/envelope';
const LocalRecipientSchema = z.object({
formId: z.string().min(1),
id: z.number().optional(),
email: ZRecipientEmailSchema,
name: z.string(),
role: z.nativeEnum(RecipientRole),
signingOrder: z.number().optional(),
actionAuth: z.array(ZRecipientActionAuthTypesSchema).optional().default([]),
});
type TLocalRecipient = z.infer<typeof LocalRecipientSchema>;
export const ZEditorRecipientsFormSchema = z.object({
signers: z.array(LocalRecipientSchema),
signingOrder: z.nativeEnum(DocumentSigningOrder),
allowDictateNextSigner: z.boolean().default(false),
});
export type TEditorRecipientsFormSchema = z.infer<typeof ZEditorRecipientsFormSchema>;
type EditorRecipientsProps = {
envelope: TEnvelope;
};
type ResetFormOptions = {
recipients?: Recipient[];
documentMeta?: TEnvelope['documentMeta'];
};
type UseEditorRecipientsResponse = {
form: UseFormReturn<TEditorRecipientsFormSchema>;
resetForm: (options?: ResetFormOptions) => void;
};
export const useEditorRecipients = ({
envelope,
}: EditorRecipientsProps): UseEditorRecipientsResponse => {
const initialId = useId();
const generateDefaultValues = (options?: ResetFormOptions) => {
const { recipients, documentMeta } = options ?? {};
const formRecipients = (recipients || envelope.recipients).map((recipient, index) => ({
id: recipient.id,
formId: String(recipient.id),
name: recipient.name,
email: recipient.email,
role: recipient.role,
signingOrder: recipient.signingOrder ?? index + 1,
actionAuth: ZRecipientAuthOptionsSchema.parse(recipient.authOptions)?.actionAuth ?? undefined,
}));
const signers: TLocalRecipient[] =
formRecipients.length > 0
? sortBy(formRecipients, [prop('signingOrder'), 'asc'], [prop('id'), 'asc'])
: [
{
formId: initialId,
name: '',
email: '',
role: RecipientRole.SIGNER,
signingOrder: 1,
actionAuth: [],
},
];
return {
signers,
signingOrder: documentMeta?.signingOrder ?? envelope.documentMeta.signingOrder,
allowDictateNextSigner:
documentMeta?.allowDictateNextSigner ?? envelope.documentMeta.allowDictateNextSigner,
};
};
const form = useForm<TEditorRecipientsFormSchema>({
defaultValues: generateDefaultValues(),
resolver: zodResolver(ZEditorRecipientsFormSchema),
mode: 'onChange', // Used for autosave purposes, maybe can try onBlur instead?
});
const resetForm = (options?: ResetFormOptions) => {
form.reset(generateDefaultValues(options));
};
return {
form,
resetForm,
};
};
@@ -15,6 +15,7 @@ import type { TEnvelope } from '../../types/envelope';
import { formatDocumentsPath, formatTemplatesPath } from '../../utils/teams';
import { useEditorFields } from '../hooks/use-editor-fields';
import type { TLocalField } from '../hooks/use-editor-fields';
import { useEditorRecipients } from '../hooks/use-editor-recipients';
import { useEnvelopeAutosave } from '../hooks/use-envelope-autosave';
export const useDebounceFunction = <Args extends unknown[]>(
@@ -53,6 +54,7 @@ type EnvelopeEditorProviderValue = {
getRecipientColorKey: (recipientId: number) => TRecipientColor;
editorFields: ReturnType<typeof useEditorFields>;
editorRecipients: ReturnType<typeof useEditorRecipients>;
isAutosaving: boolean;
flushAutosave: () => Promise<void>;
@@ -101,6 +103,10 @@ export const EnvelopeEditorProvider = ({
handleFieldsUpdate: (fields) => setFieldsDebounced(fields),
});
const editorRecipients = useEditorRecipients({
envelope,
});
const envelopeUpdateMutationQuery = trpc.envelope.update.useMutation({
onSuccess: (response, input) => {
setEnvelope({
@@ -291,6 +297,12 @@ export const EnvelopeEditorProvider = ({
if (fetchedEnvelopeData.data) {
setEnvelope(fetchedEnvelopeData.data);
editorRecipients.resetForm({
recipients: fetchedEnvelopeData.data.recipients,
documentMeta: fetchedEnvelopeData.data.documentMeta,
});
editorFields.resetForm(fetchedEnvelopeData.data.fields);
}
};
@@ -348,6 +360,7 @@ export const EnvelopeEditorProvider = ({
setRecipientsDebounced,
setRecipientsAsync,
editorFields,
editorRecipients,
autosaveError,
flushAutosave,
isAutosaving,
+9
View File
@@ -6,6 +6,12 @@ export const APP_DOCUMENT_UPLOAD_SIZE_LIMIT =
export const NEXT_PUBLIC_WEBAPP_URL = () =>
env('NEXT_PUBLIC_WEBAPP_URL') ?? 'http://localhost:3000';
export const NEXT_PUBLIC_SIGNING_CONTACT_INFO = () =>
env('NEXT_PUBLIC_SIGNING_CONTACT_INFO') ?? NEXT_PUBLIC_WEBAPP_URL();
export const NEXT_PRIVATE_USE_LEGACY_SIGNING_SUBFILTER = () =>
env('NEXT_PRIVATE_USE_LEGACY_SIGNING_SUBFILTER') === 'true';
export const NEXT_PRIVATE_INTERNAL_WEBAPP_URL = () =>
env('NEXT_PRIVATE_INTERNAL_WEBAPP_URL') ?? NEXT_PUBLIC_WEBAPP_URL();
@@ -30,3 +36,6 @@ export const IS_AI_FEATURES_CONFIGURED = () =>
*/
export const NEXT_PRIVATE_USE_PLAYWRIGHT_PDF = () =>
env('NEXT_PRIVATE_USE_PLAYWRIGHT_PDF') === 'true';
export const NEXT_PRIVATE_SIGNING_TIMESTAMP_AUTHORITY = () =>
env('NEXT_PRIVATE_SIGNING_TIMESTAMP_AUTHORITY');
+9 -6
View File
@@ -1,30 +1,33 @@
import type { MessageDescriptor } from '@lingui/core';
import { msg } from '@lingui/core/macro';
import type { TDocumentAuth } from '../types/document-auth';
import { DocumentAuth } from '../types/document-auth';
type DocumentAuthTypeData = {
key: TDocumentAuth;
value: string;
value: MessageDescriptor;
};
export const DOCUMENT_AUTH_TYPES: Record<string, DocumentAuthTypeData> = {
[DocumentAuth.ACCOUNT]: {
key: DocumentAuth.ACCOUNT,
value: 'Require account',
value: msg`Require account`,
},
[DocumentAuth.PASSKEY]: {
key: DocumentAuth.PASSKEY,
value: 'Require passkey',
value: msg`Require passkey`,
},
[DocumentAuth.TWO_FACTOR_AUTH]: {
key: DocumentAuth.TWO_FACTOR_AUTH,
value: 'Require 2FA',
value: msg`Require 2FA`,
},
[DocumentAuth.PASSWORD]: {
key: DocumentAuth.PASSWORD,
value: 'Require password',
value: msg`Require password`,
},
[DocumentAuth.EXPLICIT_NONE]: {
key: DocumentAuth.EXPLICIT_NONE,
value: 'None (Overrides global settings)',
value: msg`None (Overrides global settings)`,
},
} satisfies Record<TDocumentAuth, DocumentAuthTypeData>;
@@ -1,23 +1,26 @@
import type { MessageDescriptor } from '@lingui/core';
import { msg } from '@lingui/core/macro';
import { DocumentVisibility } from '@documenso/lib/types/document-visibility';
import type { TDocumentVisibility } from '../types/document-visibility';
type DocumentVisibilityTypeData = {
key: TDocumentVisibility;
value: string;
value: MessageDescriptor;
};
export const DOCUMENT_VISIBILITY: Record<string, DocumentVisibilityTypeData> = {
[DocumentVisibility.ADMIN]: {
key: DocumentVisibility.ADMIN,
value: 'Admins only',
value: msg`Admins only`,
},
[DocumentVisibility.EVERYONE]: {
key: DocumentVisibility.EVERYONE,
value: 'Everyone',
value: msg`Everyone`,
},
[DocumentVisibility.MANAGER_AND_ABOVE]: {
key: DocumentVisibility.MANAGER_AND_ABOVE,
value: 'Managers and above',
value: msg`Managers and above`,
},
} satisfies Record<TDocumentVisibility, DocumentVisibilityTypeData>;
@@ -1,12 +1,5 @@
import {
PDFDocument,
RotationTypes,
popGraphicsState,
pushGraphicsState,
radiansToDegrees,
rotateDegrees,
translate,
} from '@cantoo/pdf-lib';
import { PDFDocument } from '@cantoo/pdf-lib';
import { PDF } from '@libpdf/core';
import type { DocumentData, Envelope, EnvelopeItem, Field } from '@prisma/client';
import {
DocumentStatus,
@@ -18,8 +11,8 @@ import {
import { nanoid } from 'nanoid';
import path from 'node:path';
import { groupBy } from 'remeda';
import { match } from 'ts-pattern';
import { addRejectionStampToPdf } from '@documenso/lib/server-only/pdf/add-rejection-stamp-to-pdf';
import { generateAuditLogPdf } from '@documenso/lib/server-only/pdf/generate-audit-log-pdf';
import { generateCertificatePdf } from '@documenso/lib/server-only/pdf/generate-certificate-pdf';
import { prisma } from '@documenso/prisma';
@@ -31,14 +24,9 @@ import { AppError, AppErrorCode } from '../../../errors/app-error';
import { sendCompletedEmail } from '../../../server-only/document/send-completed-email';
import { getAuditLogsPdf } from '../../../server-only/htmltopdf/get-audit-logs-pdf';
import { getCertificatePdf } from '../../../server-only/htmltopdf/get-certificate-pdf';
import { addRejectionStampToPdf } from '../../../server-only/pdf/add-rejection-stamp-to-pdf';
import { flattenAnnotations } from '../../../server-only/pdf/flatten-annotations';
import { flattenForm } from '../../../server-only/pdf/flatten-form';
import { getPageSize } from '../../../server-only/pdf/get-page-size';
import { insertFieldInPDFV1 } from '../../../server-only/pdf/insert-field-in-pdf-v1';
import { insertFieldInPDFV2 } from '../../../server-only/pdf/insert-field-in-pdf-v2';
import { legacy_insertFieldInPDF } from '../../../server-only/pdf/legacy-insert-field-in-pdf';
import { normalizeSignatureAppearances } from '../../../server-only/pdf/normalize-signature-appearances';
import { getTeamSettings } from '../../../server-only/team/get-team-settings';
import { triggerWebhook } from '../../../server-only/webhooks/trigger/trigger-webhook';
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../../types/document-audit-logs';
@@ -181,8 +169,8 @@ export const run = async ({
});
}
let certificateDoc: PDFDocument | null = null;
let auditLogDoc: PDFDocument | null = null;
let certificateDoc: PDF | null = null;
let auditLogDoc: PDF | null = null;
if (settings.includeSigningCertificate || settings.includeAuditLog) {
const certificatePayload = {
@@ -208,7 +196,7 @@ export const run = async ({
? getCertificatePdf({
documentId,
language: envelope.documentMeta.language,
}).then(async (buffer) => PDFDocument.load(buffer))
}).then(async (buffer) => PDF.load(buffer))
: generateCertificatePdf(certificatePayload);
const makeAuditLogPdf = async () =>
@@ -216,7 +204,7 @@ export const run = async ({
? getAuditLogsPdf({
documentId,
language: envelope.documentMeta.language,
}).then(async (buffer) => PDFDocument.load(buffer))
}).then(async (buffer) => PDF.load(buffer))
: generateAuditLogPdf(certificatePayload);
const [createdCertificatePdf, createdAuditLogPdf] = await Promise.all([
@@ -342,8 +330,8 @@ type DecorateAndSignPdfOptions = {
envelopeItemFields: Field[];
isRejected: boolean;
rejectionReason: string;
certificateDoc: PDFDocument | null;
auditLogDoc: PDFDocument | null;
certificateDoc: PDF | null;
auditLogDoc: PDF | null;
};
/**
@@ -360,48 +348,47 @@ const decorateAndSignPdf = async ({
}: DecorateAndSignPdfOptions) => {
const pdfData = await getFileServerSide(envelopeItem.documentData);
const pdfDoc = await PDFDocument.load(pdfData);
let pdfDoc = await PDF.load(pdfData);
// Normalize and flatten layers that could cause issues with the signature
normalizeSignatureAppearances(pdfDoc);
await flattenForm(pdfDoc);
flattenAnnotations(pdfDoc);
pdfDoc.flattenAll();
// Upgrade to PDF 1.7 for better compatibility with signing
pdfDoc.upgradeVersion('1.7');
// Add rejection stamp if the document is rejected
if (isRejected && rejectionReason) {
if (isRejected) {
await addRejectionStampToPdf(pdfDoc, rejectionReason);
}
if (certificateDoc) {
const certificatePages = await pdfDoc.copyPages(
await pdfDoc.copyPagesFrom(
certificateDoc,
certificateDoc.getPageIndices(),
Array.from({ length: certificateDoc.getPageCount() }, (_, index) => index),
);
certificatePages.forEach((page) => {
pdfDoc.addPage(page);
});
}
if (auditLogDoc) {
const auditLogPages = await pdfDoc.copyPages(auditLogDoc, auditLogDoc.getPageIndices());
auditLogPages.forEach((page) => {
pdfDoc.addPage(page);
});
await pdfDoc.copyPagesFrom(
auditLogDoc,
Array.from({ length: auditLogDoc.getPageCount() }, (_, index) => index),
);
}
// Handle V1 and legacy insertions.
if (envelope.internalVersion === 1) {
const legacy_pdfLibDoc = await PDFDocument.load(await pdfDoc.save({ useXRefStream: true }));
for (const field of envelopeItemFields) {
if (field.inserted) {
if (envelope.useLegacyFieldInsertion) {
await legacy_insertFieldInPDF(pdfDoc, field);
await legacy_insertFieldInPDF(legacy_pdfLibDoc, field);
} else {
await insertFieldInPDFV1(pdfDoc, field);
await insertFieldInPDFV1(legacy_pdfLibDoc, field);
}
}
}
await pdfDoc.reload(await legacy_pdfLibDoc.save());
}
// Handle V2 envelope insertions.
@@ -410,87 +397,61 @@ const decorateAndSignPdf = async ({
for (const [pageNumber, fields] of Object.entries(fieldsGroupedByPage)) {
const page = pdfDoc.getPage(Number(pageNumber) - 1);
const pageRotation = page.getRotation();
let { width: pageWidth, height: pageHeight } = getPageSize(page);
let pageRotationInDegrees = match(pageRotation.type)
.with(RotationTypes.Degrees, () => pageRotation.angle)
.with(RotationTypes.Radians, () => radiansToDegrees(pageRotation.angle))
.exhaustive();
// Round to the closest multiple of 90 degrees.
pageRotationInDegrees = Math.round(pageRotationInDegrees / 90) * 90;
// PDFs can have pages that are rotated, which are correctly rendered in the frontend.
// However when we load the PDF in the backend, the rotation is applied.
// To account for this, we swap the width and height for pages that are rotated by 90/270
// degrees. This is so we can calculate the virtual position the field was placed if it
// was correctly oriented in the frontend.
if (pageRotationInDegrees === 90 || pageRotationInDegrees === 270) {
[pageWidth, pageHeight] = [pageHeight, pageWidth];
if (!page) {
throw new Error(`Page ${pageNumber} does not exist`);
}
// Rotate the page to the orientation that the react-pdf renders on the frontend.
// Note: These transformations are undone at the end of the function.
// If you change this if statement, update the if statement at the end as well
if (pageRotationInDegrees !== 0) {
let translateX = 0;
let translateY = 0;
const pageWidth = page.width;
const pageHeight = page.height;
switch (pageRotationInDegrees) {
case 90:
translateX = pageHeight;
translateY = 0;
break;
case 180:
translateX = pageWidth;
translateY = pageHeight;
break;
case 270:
translateX = 0;
translateY = pageWidth;
break;
case 0:
default:
translateX = 0;
translateY = 0;
}
page.pushOperators(pushGraphicsState());
page.pushOperators(translate(translateX, translateY), rotateDegrees(pageRotationInDegrees));
}
const renderedPdfOverlay = await insertFieldInPDFV2({
const overlayBytes = await insertFieldInPDFV2({
pageWidth,
pageHeight,
fields,
});
const [embeddedPage] = await pdfDoc.embedPdf(renderedPdfOverlay);
const overlayPdf = await PDF.load(overlayBytes);
// Draw the SVG on the page
page.drawPage(embeddedPage, {
x: 0,
y: 0,
width: pageWidth,
height: pageHeight,
});
const embeddedPage = await pdfDoc.embedPage(overlayPdf, 0);
// Remove the transformations applied to the page if any were applied.
if (pageRotationInDegrees !== 0) {
page.pushOperators(popGraphicsState());
// Rotate the page to the orientation that the react-pdf renders on the frontend.
let translateX = 0;
let translateY = 0;
switch (page.rotation) {
case 90:
translateX = pageHeight;
translateY = 0;
break;
case 180:
translateX = pageWidth;
translateY = pageHeight;
break;
case 270:
translateX = 0;
translateY = pageWidth;
break;
}
// Draw the overlay on the page
page.drawPage(embeddedPage, {
x: translateX,
y: translateY,
rotate: {
angle: page.rotation,
},
});
}
}
// Re-flatten the form to handle our checkbox and radio fields that
// create native arcoFields
await flattenForm(pdfDoc);
pdfDoc.flattenAll();
const pdfBytes = await pdfDoc.save();
pdfDoc = await PDF.load(await pdfDoc.save({ useXRefStream: true }));
const pdfBuffer = await signPdf({ pdf: Buffer.from(pdfBytes) });
const pdfBytes = await signPdf({ pdf: pdfDoc });
const { name } = path.parse(envelopeItem.title);
@@ -500,7 +461,7 @@ const decorateAndSignPdf = async ({
const newDocumentData = await putPdfFileServerSide({
name: `${name}${suffix}`,
type: 'application/pdf',
arrayBuffer: async () => Promise.resolve(pdfBuffer),
arrayBuffer: async () => Promise.resolve(pdfBytes),
});
return {
+3 -2
View File
@@ -34,6 +34,7 @@
"@node-rs/bcrypt": "^1.10.7",
"@pdf-lib/fontkit": "^1.1.1",
"@scure/base": "^1.2.6",
"@simplewebauthn/server": "^13.2.2",
"@sindresorhus/slugify": "^3.0.0",
"@team-plain/typescript-sdk": "^5.11.0",
"@vvo/tzdb": "^6.196.0",
@@ -54,7 +55,7 @@
"posthog-js": "^1.297.2",
"posthog-node": "4.18.0",
"react": "^18",
"react-pdf": "^10.2.0",
"react-pdf": "^10.3.0",
"remeda": "^2.32.0",
"sharp": "0.34.5",
"skia-canvas": "^3.0.8",
@@ -67,4 +68,4 @@
"@types/luxon": "^3.7.1",
"@types/pg": "^8.15.6"
}
}
}
@@ -1,6 +1,7 @@
import type { Passkey } from '@prisma/client';
import { generateAuthenticationOptions } from '@simplewebauthn/server';
import type { AuthenticatorTransportFuture } from '@simplewebauthn/types';
import type { AuthenticatorTransportFuture } from '@simplewebauthn/server';
import { isoBase64URL } from '@simplewebauthn/server/helpers';
import { DateTime } from 'luxon';
import { prisma } from '@documenso/prisma';
@@ -53,8 +54,7 @@ export const createPasskeyAuthenticationOptions = async ({
allowCredentials: preferredPasskey
? [
{
id: preferredPasskey.credentialId,
type: 'public-key',
id: isoBase64URL.fromBuffer(preferredPasskey.credentialId),
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
transports: preferredPasskey.transports as AuthenticatorTransportFuture[],
},
@@ -1,5 +1,6 @@
import { generateRegistrationOptions } from '@simplewebauthn/server';
import type { AuthenticatorTransportFuture } from '@simplewebauthn/types';
import type { AuthenticatorTransportFuture } from '@simplewebauthn/server';
import { isoBase64URL } from '@simplewebauthn/server/helpers';
import { DateTime } from 'luxon';
import { prisma } from '@documenso/prisma';
@@ -32,14 +33,13 @@ export const createPasskeyRegistrationOptions = async ({
const options = await generateRegistrationOptions({
rpName,
rpID,
userID: userId.toString(),
userID: Buffer.from(userId.toString()),
userName: user.email,
userDisplayName: user.name ?? undefined,
timeout: PASSKEY_TIMEOUT,
attestationType: 'none',
excludeCredentials: passkeys.map((passkey) => ({
id: passkey.credentialId,
type: 'public-key',
id: isoBase64URL.fromBuffer(passkey.credentialId),
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
transports: passkey.transports as AuthenticatorTransportFuture[],
})),
@@ -1,6 +1,7 @@
import { UserSecurityAuditLogType } from '@prisma/client';
import { verifyRegistrationResponse } from '@simplewebauthn/server';
import type { RegistrationResponseJSON } from '@simplewebauthn/types';
import type { RegistrationResponseJSON } from '@simplewebauthn/server';
import { isoBase64URL } from '@simplewebauthn/server/helpers';
import { prisma } from '@documenso/prisma';
@@ -83,20 +84,19 @@ export const createPasskey = async ({
});
}
const { credentialPublicKey, credentialID, counter, credentialDeviceType, credentialBackedUp } =
verification.registrationInfo;
const { credentialDeviceType, credentialBackedUp, credential } = verification.registrationInfo;
await prisma.$transaction(async (tx) => {
await tx.passkey.create({
data: {
userId,
name: passkeyName,
credentialId: Buffer.from(credentialID),
credentialPublicKey: Buffer.from(credentialPublicKey),
counter,
credentialId: Buffer.from(isoBase64URL.toBuffer(credential.id)),
credentialPublicKey: Buffer.from(credential.publicKey),
counter: credential.counter,
credentialDeviceType,
credentialBackedUp,
transports: verificationResponse.response.transports,
transports: credential.transports,
},
});
@@ -1,5 +1,6 @@
import type { Envelope, Recipient } from '@prisma/client';
import { verifyAuthenticationResponse } from '@simplewebauthn/server';
import { isoBase64URL } from '@simplewebauthn/server/helpers';
import { match } from 'ts-pattern';
import { prisma } from '@documenso/prisma';
@@ -252,9 +253,9 @@ const verifyPasskey = async ({
expectedChallenge: verificationToken.token,
expectedOrigin: origin,
expectedRPID: rpId,
authenticator: {
credentialID: new Uint8Array(Array.from(passkey.credentialId)),
credentialPublicKey: new Uint8Array(passkey.credentialPublicKey),
credential: {
id: isoBase64URL.fromBuffer(passkey.credentialId),
publicKey: new Uint8Array(passkey.credentialPublicKey),
counter: Number(passkey.counter),
},
}).catch(() => null); // May want to log this for insights.
@@ -33,12 +33,15 @@ import {
mapEnvelopeToWebhookDocumentPayload,
} from '../../types/webhook-payload';
import { getFileServerSide } from '../../universal/upload/get-file.server';
import { putPdfFileServerSide } from '../../universal/upload/put-file.server';
import { putNormalizedPdfFileServerSide } from '../../universal/upload/put-file.server';
import { isDocumentCompleted } from '../../utils/document';
import { extractDocumentAuthMethods } from '../../utils/document-auth';
import { type EnvelopeIdOptions, mapSecondaryIdToDocumentId } from '../../utils/envelope';
import { toCheckboxCustomText, toRadioCustomText } from '../../utils/fields';
import { isRecipientEmailValidForSending } from '../../utils/recipients';
import {
getRecipientsWithMissingFields,
isRecipientEmailValidForSending,
} from '../../utils/recipients';
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
import { insertFormValuesInPdf } from '../pdf/insert-form-values-in-pdf';
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
@@ -148,30 +151,19 @@ export const sendDocument = async ({
}
});
// Commented out server side checks for minimum 1 signature per signer now since we need to
// decide if we want to enforce this for API & templates.
// const fields = await getFieldsForDocument({
// documentId: documentId,
// userId: userId,
// });
// Validate that recipients who require fields (e.g., signers need signature fields) have them.
const recipientsWithMissingFields = getRecipientsWithMissingFields(
envelope.recipients,
envelope.fields,
);
// const fieldsWithSignerEmail = fields.map((field) => ({
// ...field,
// signerEmail:
// envelope.Recipient.find((recipient) => recipient.id === field.recipientId)?.email ?? '',
// }));
if (recipientsWithMissingFields.length > 0) {
const missingRecipientIds = recipientsWithMissingFields.map((r) => r.id).join(', ');
// const everySignerHasSignature = document?.Recipient.every(
// (recipient) =>
// recipient.role !== RecipientRole.SIGNER ||
// fieldsWithSignerEmail.some(
// (field) => field.type === 'SIGNATURE' && field.signerEmail === recipient.email,
// ),
// );
// if (!everySignerHasSignature) {
// throw new Error('Some signers have not been assigned a signature field.');
// }
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: `The following recipients are missing required fields: ${missingRecipientIds}. Signers must have at least one signature field.`,
});
}
const allRecipientsHaveNoActionToTake = envelope.recipients.every(
(recipient) =>
@@ -334,7 +326,7 @@ const injectFormValuesIntoDocument = async (
fileName = `${envelope.title}.pdf`;
}
const newDocumentData = await putPdfFileServerSide({
const newDocumentData = await putNormalizedPdfFileServerSide({
name: fileName,
type: 'application/pdf',
arrayBuffer: async () => Promise.resolve(prefilled),
@@ -11,6 +11,7 @@ import {
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { normalizePdf as makeNormalizedPdf } from '@documenso/lib/server-only/pdf/normalize-pdf';
import { ZDefaultRecipientsSchema } from '@documenso/lib/types/default-recipients';
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { nanoid, prefixedId } from '@documenso/lib/universal/id';
@@ -184,7 +185,9 @@ export const createEnvelope = async ({
const buffer = await getFileServerSide(documentData);
const normalizedPdf = await makeNormalizedPdf(Buffer.from(buffer));
const normalizedPdf = await makeNormalizedPdf(Buffer.from(buffer), {
flattenForm: type !== EnvelopeType.TEMPLATE,
});
const titleToUse = item.title || title;
@@ -345,8 +348,22 @@ export const createEnvelope = async ({
const firstEnvelopeItem = envelope.envelopeItems[0];
const defaultRecipients = settings.defaultRecipients
? ZDefaultRecipientsSchema.parse(settings.defaultRecipients)
: [];
const mappedDefaultRecipients: CreateEnvelopeRecipientOptions[] = defaultRecipients.map(
(recipient) => ({
email: recipient.email,
name: recipient.name,
role: recipient.role,
}),
);
const allRecipients = [...(data.recipients || []), ...mappedDefaultRecipients];
await Promise.all(
(data.recipients || []).map(async (recipient) => {
allRecipients.map(async (recipient) => {
const recipientAuthOptions = createRecipientAuthOptions({
accessAuth: recipient.accessAuth ?? [],
actionAuth: recipient.actionAuth ?? [],
@@ -1,4 +1,4 @@
import type { Prisma } from '@prisma/client';
import { Prisma } from '@prisma/client';
import { OrganisationType } from '@prisma/client';
import { OrganisationMemberRole } from '@prisma/client';
@@ -63,6 +63,7 @@ export const createOrganisation = async ({
const organisationSetting = await tx.organisationGlobalSettings.create({
data: {
...generateDefaultOrganisationSettings(),
defaultRecipients: Prisma.DbNull,
id: generateDatabaseId('org_setting'),
},
});
@@ -1,88 +1,72 @@
import type { PDFDocument } from '@cantoo/pdf-lib';
import { TextAlignment, rgb, setFontAndSize } from '@cantoo/pdf-lib';
import fontkit from '@pdf-lib/fontkit';
import { type PDF, rgb } from '@libpdf/core';
import { NEXT_PRIVATE_INTERNAL_WEBAPP_URL } from '../../constants/app';
import { getPageSize } from './get-page-size';
/**
* Adds a rejection stamp to each page of a PDF document.
* The stamp is placed in the center of the page.
*/
export async function addRejectionStampToPdf(
pdfDoc: PDFDocument,
reason: string,
): Promise<PDFDocument> {
const pages = pdfDoc.getPages();
pdfDoc.registerFontkit(fontkit);
export async function addRejectionStampToPdf(pdf: PDF, reason: string): Promise<PDF> {
const pages = pdf.getPages();
const fontBytes = await fetch(`${NEXT_PRIVATE_INTERNAL_WEBAPP_URL()}/fonts/noto-sans.ttf`).then(
async (res) => res.arrayBuffer(),
);
const font = await pdfDoc.embedFont(fontBytes, {
customName: 'Noto',
});
const font = pdf.embedFont(new Uint8Array(fontBytes));
const form = pdfDoc.getForm();
for (let i = 0; i < pages.length; i++) {
const page = pages[i];
const { width, height } = getPageSize(page);
for (const page of pages) {
const height = page.height;
const width = page.width;
// Draw the "REJECTED" text
const rejectedTitleText = 'DOCUMENT REJECTED';
const rejectedTitleFontSize = 36;
const rejectedTitleTextField = form.createTextField(`internal-document-rejected-title-${i}`);
if (!rejectedTitleTextField.acroField.getDefaultAppearance()) {
rejectedTitleTextField.acroField.setDefaultAppearance(
setFontAndSize('Noto', rejectedTitleFontSize).toString(),
);
}
rejectedTitleTextField.updateAppearances(font);
rejectedTitleTextField.setFontSize(rejectedTitleFontSize);
rejectedTitleTextField.setText(rejectedTitleText);
rejectedTitleTextField.setAlignment(TextAlignment.Center);
const rejectedTitleTextWidth =
font.widthOfTextAtSize(rejectedTitleText, rejectedTitleFontSize) * 1.2;
const rejectedTitleTextHeight = font.heightAtSize(rejectedTitleFontSize);
const rotationAngle = 45;
// Calculate the center position of the page
const centerX = width / 2;
const centerY = height / 2;
// Position the title text at the center of the page
const rejectedTitleTextX = centerX - rejectedTitleTextWidth / 2;
const rejectedTitleTextY = centerY - rejectedTitleTextHeight / 2;
const widthOfText = font.getTextWidth(rejectedTitleText, rejectedTitleFontSize);
// Add padding for the rectangle
const padding = 20;
const rectWidth = widthOfText + padding;
const rectHeight = rejectedTitleFontSize + padding;
const rectX = centerX - rectWidth / 2;
const rectY = centerY - rectHeight / 4;
// Draw the stamp background
page.drawRectangle({
x: rejectedTitleTextX - padding / 2,
y: rejectedTitleTextY - padding / 2,
width: rejectedTitleTextWidth + padding,
height: rejectedTitleTextHeight + padding,
x: rectX,
y: rectY,
width: rectWidth,
height: rectHeight,
borderColor: rgb(220 / 255, 38 / 255, 38 / 255),
borderWidth: 4,
rotate: {
angle: rotationAngle,
origin: 'center',
},
});
rejectedTitleTextField.addToPage(page, {
x: rejectedTitleTextX,
y: rejectedTitleTextY,
width: rejectedTitleTextWidth,
height: rejectedTitleTextHeight,
textColor: rgb(220 / 255, 38 / 255, 38 / 255),
backgroundColor: undefined,
borderWidth: 0,
borderColor: undefined,
const textX = centerX - widthOfText / 2;
const textY = centerY;
// Draw the text centered within the rectangle
page.drawText(rejectedTitleText, {
x: textX,
y: textY,
size: rejectedTitleFontSize,
font,
color: rgb(220 / 255, 38 / 255, 38 / 255),
rotate: {
angle: rotationAngle,
origin: 'center',
},
});
}
return pdfDoc;
return pdf;
}
@@ -1,63 +0,0 @@
import { PDFAnnotation, PDFRef } from '@cantoo/pdf-lib';
import {
PDFDict,
type PDFDocument,
PDFName,
drawObject,
popGraphicsState,
pushGraphicsState,
rotateInPlace,
translate,
} from '@cantoo/pdf-lib';
export const flattenAnnotations = (document: PDFDocument) => {
const pages = document.getPages();
for (const page of pages) {
const annotations = page.node.Annots()?.asArray() ?? [];
annotations.forEach((annotation) => {
if (!(annotation instanceof PDFRef)) {
return;
}
const actualAnnotation = page.node.context.lookup(annotation);
if (!(actualAnnotation instanceof PDFDict)) {
return;
}
const pdfAnnot = PDFAnnotation.fromDict(actualAnnotation);
const appearance = pdfAnnot.ensureAP();
// Skip annotations without a normal appearance
if (!appearance.has(PDFName.of('N'))) {
return;
}
const normalAppearance = pdfAnnot.getNormalAppearance();
const rectangle = pdfAnnot.getRectangle();
if (!(normalAppearance instanceof PDFRef)) {
// Not sure how to get the reference to the normal appearance yet
// so we should skip this annotation for now
return;
}
const xobj = page.node.newXObject('FlatAnnot', normalAppearance);
const operators = [
pushGraphicsState(),
translate(rectangle.x, rectangle.y),
...rotateInPlace({ ...rectangle, rotation: 0 }),
drawObject(xobj),
popGraphicsState(),
].filter((op) => !!op);
page.pushOperators(...operators);
page.node.removeAnnot(annotation);
});
}
};
@@ -1,170 +0,0 @@
import type { PDFField, PDFWidgetAnnotation } from '@cantoo/pdf-lib';
import {
PDFCheckBox,
PDFDict,
type PDFDocument,
PDFName,
PDFNumber,
PDFRadioGroup,
PDFRef,
PDFStream,
drawObject,
popGraphicsState,
pushGraphicsState,
rotateInPlace,
translate,
} from '@cantoo/pdf-lib';
import fontkit from '@pdf-lib/fontkit';
import { NEXT_PRIVATE_INTERNAL_WEBAPP_URL } from '../../constants/app';
export const removeOptionalContentGroups = (document: PDFDocument) => {
const context = document.context;
const catalog = context.lookup(context.trailerInfo.Root);
if (catalog instanceof PDFDict) {
catalog.delete(PDFName.of('OCProperties'));
}
};
export const flattenForm = async (document: PDFDocument) => {
removeOptionalContentGroups(document);
const form = document.getForm();
const fontNoto = await fetch(`${NEXT_PRIVATE_INTERNAL_WEBAPP_URL()}/fonts/noto-sans.ttf`).then(
async (res) => res.arrayBuffer(),
);
document.registerFontkit(fontkit);
const font = await document.embedFont(fontNoto);
form.updateFieldAppearances(font);
for (const field of form.getFields()) {
for (const widget of field.acroField.getWidgets()) {
flattenWidget(document, field, widget);
}
try {
form.removeField(field);
} catch (error) {
console.error(error);
}
}
};
const getPageForWidget = (document: PDFDocument, widget: PDFWidgetAnnotation) => {
const pageRef = widget.P();
let page = document.getPages().find((page) => page.ref === pageRef);
if (!page) {
const widgetRef = document.context.getObjectRef(widget.dict);
if (!widgetRef) {
return null;
}
page = document.findPageForAnnotationRef(widgetRef);
if (!page) {
return null;
}
}
return page;
};
const getAppearanceRefForWidget = (field: PDFField, widget: PDFWidgetAnnotation) => {
try {
const normalAppearance = widget.getNormalAppearance();
let normalAppearanceRef: PDFRef | null = null;
if (normalAppearance instanceof PDFRef) {
normalAppearanceRef = normalAppearance;
}
if (
normalAppearance instanceof PDFDict &&
(field instanceof PDFCheckBox || field instanceof PDFRadioGroup)
) {
const value = field.acroField.getValue();
const ref = normalAppearance.get(value) ?? normalAppearance.get(PDFName.of('Off'));
if (ref instanceof PDFRef) {
normalAppearanceRef = ref;
}
}
return normalAppearanceRef;
} catch (error) {
console.error(error);
return null;
}
};
/**
* Ensures that an appearance stream has the required dictionary entries to be
* used as a Form XObject. Some PDFs have appearance streams that are missing
* the /Subtype /Form entry, which causes Adobe Reader to fail to render them.
*
* Per PDF spec, a Form XObject stream requires:
* - /Subtype /Form (required)
* - /BBox (required, but should already exist for appearance streams)
* - /FormType 1 (optional, defaults to 1)
*/
const normalizeAppearanceStream = (document: PDFDocument, appearanceRef: PDFRef) => {
const appearanceStream = document.context.lookup(appearanceRef);
if (!(appearanceStream instanceof PDFStream)) {
return;
}
const dict = appearanceStream.dict;
// Ensure /Subtype /Form is set (required for XObject Form)
if (!dict.has(PDFName.of('Subtype'))) {
dict.set(PDFName.of('Subtype'), PDFName.of('Form'));
}
// Ensure /FormType is set (optional, but good practice)
if (!dict.has(PDFName.of('FormType'))) {
dict.set(PDFName.of('FormType'), PDFNumber.of(1));
}
};
const flattenWidget = (document: PDFDocument, field: PDFField, widget: PDFWidgetAnnotation) => {
try {
const page = getPageForWidget(document, widget);
if (!page) {
return;
}
const appearanceRef = getAppearanceRefForWidget(field, widget);
if (!appearanceRef) {
return;
}
// Ensure the appearance stream has required XObject Form dictionary entries
normalizeAppearanceStream(document, appearanceRef);
const xObjectKey = page.node.newXObject('FlatWidget', appearanceRef);
const rectangle = widget.getRectangle();
const operators = [
pushGraphicsState(),
translate(rectangle.x, rectangle.y),
...rotateInPlace({ ...rectangle, rotation: 0 }),
drawObject(xObjectKey),
popGraphicsState(),
].filter((op) => !!op);
page.pushOperators(...operators);
} catch (error) {
console.error(error);
}
};
@@ -1,3 +1,4 @@
import { PDF } from '@libpdf/core';
import { i18n } from '@lingui/core';
import { prisma } from '@documenso/prisma';
@@ -7,7 +8,6 @@ import { parseDocumentAuditLogData } from '../../utils/document-audit-logs';
import { getTranslations } from '../../utils/i18n';
import { getOrganisationClaimByTeamId } from '../organisation/get-organisation-claims';
import type { GenerateCertificatePdfOptions } from './generate-certificate-pdf';
import { mergeFilesIntoPdf } from './generate-certificate-pdf';
import { renderAuditLogs } from './render-audit-logs';
type GenerateAuditLogPdfOptions = GenerateCertificatePdfOptions & {
@@ -43,7 +43,9 @@ export const generateAuditLogPdf = async (options: GenerateAuditLogPdfOptions) =
i18n,
});
return await mergeFilesIntoPdf(auditLogPages);
return await PDF.merge(auditLogPages, {
includeAnnotations: true,
});
};
const getAuditLogs = async (envelopeId: string) => {
@@ -1,4 +1,4 @@
import { PDFDocument } from '@cantoo/pdf-lib';
import { PDF } from '@libpdf/core';
import { i18n } from '@lingui/core';
import { msg } from '@lingui/core/macro';
import type { DocumentMeta } from '@prisma/client';
@@ -144,17 +144,5 @@ export const generateCertificatePdf = async (options: GenerateCertificatePdfOpti
const certificatePages = await renderCertificate(payload);
return await mergeFilesIntoPdf(certificatePages);
return await PDF.merge(certificatePages);
};
export async function mergeFilesIntoPdf(buffers: Uint8Array[]) {
const mergedPdf = await PDFDocument.create();
for (const buffer of buffers) {
const pdf = await PDFDocument.load(buffer);
const pages = await mergedPdf.copyPages(pdf, pdf.getPageIndices());
pages.forEach((p) => mergedPdf.addPage(p));
}
return mergedPdf;
}
@@ -1,10 +1,4 @@
import {
PDFCheckBox,
PDFDocument,
PDFDropdown,
PDFRadioGroup,
PDFTextField,
} from '@cantoo/pdf-lib';
import { PDF } from '@libpdf/core';
export type InsertFormValuesInPdfOptions = {
pdf: Buffer;
@@ -12,7 +6,7 @@ export type InsertFormValuesInPdfOptions = {
};
export const insertFormValuesInPdf = async ({ pdf, formValues }: InsertFormValuesInPdfOptions) => {
const doc = await PDFDocument.load(pdf);
const doc = await PDF.load(pdf);
const form = doc.getForm();
@@ -20,41 +14,12 @@ export const insertFormValuesInPdf = async ({ pdf, formValues }: InsertFormValue
return pdf;
}
for (const [key, value] of Object.entries(formValues)) {
try {
const field = form.getField(key);
const filledForm = Object.entries(formValues).map(([key, value]) => [
key,
typeof value === 'boolean' ? value : value.toString(),
]);
if (!field) {
continue;
}
form.fill(Object.fromEntries(filledForm));
if (typeof value === 'boolean' && field instanceof PDFCheckBox) {
if (value) {
field.check();
} else {
field.uncheck();
}
}
if (field instanceof PDFTextField) {
field.setText(value.toString());
}
if (field instanceof PDFDropdown) {
field.select(value.toString());
}
if (field instanceof PDFRadioGroup) {
field.select(value.toString());
}
} catch (err) {
if (err instanceof Error) {
console.error(`Error setting value for field ${key}: ${err.message}`);
} else {
console.error(`Error setting value for field ${key}`);
}
}
}
return await doc.save().then((buf) => Buffer.from(buf));
return await doc.save({ incremental: true }).then((buf) => Buffer.from(buf));
};

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