mirror of
https://github.com/documenso/documenso.git
synced 2026-06-25 22:02:17 +10:00
Compare commits
39 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| aed55e9bcd | |||
| 39ebc8184a | |||
| 2df41b9f01 | |||
| 8704c731c0 | |||
| eaee0d4bc6 | |||
| 0f8b7670f4 | |||
| 25e148d459 | |||
| 97ceb317a8 | |||
| c83109628d | |||
| a4d0e3e873 | |||
| 59a514c238 | |||
| 1b0df2d082 | |||
| d18dcb4d60 | |||
| d77f81163b | |||
| 62fb9e5248 | |||
| 53b0131740 | |||
| 155310b028 | |||
| 28bc2dc975 | |||
| eb3b3b18ce | |||
| 8bc4f1a713 | |||
| d3c898e317 | |||
| d08049ed3b | |||
| 7a583aa7af | |||
| b590076d85 | |||
| 65e30b88be | |||
| 9c6ee88cc4 | |||
| 6028ad9158 | |||
| 7fc6f5bb6e | |||
| 17b261df1f | |||
| c732c85082 | |||
| 7d38e18f93 | |||
| 0a3e0b8727 | |||
| b538580a1e | |||
| 42d6e1cbbd | |||
| 67da488f63 | |||
| fd3ebc08ec | |||
| a7963b385a | |||
| 9035240b4d | |||
| ed7a0011c7 |
@@ -0,0 +1,161 @@
|
||||
---
|
||||
date: 2026-01-28
|
||||
title: Pdf Placeholder Field Positioning
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
This feature enables automatic field placement in PDFs using placeholder text, eliminating the need for manual coordinate-based positioning. It supports two complementary workflows:
|
||||
|
||||
1. **Automatic detection on upload** - PDFs containing structured placeholders like `{{signature, r1}}` have fields created automatically when uploaded
|
||||
2. **API placeholder positioning** - Developers can reference any text in a PDF to position fields instead of calculating coordinates
|
||||
|
||||
## Goals
|
||||
|
||||
- Allow users to prepare documents in Word/Google Docs with placeholders that become signature fields
|
||||
- Reduce friction for document preparation workflows
|
||||
- Provide API developers with a simpler alternative to coordinate-based field positioning
|
||||
- Support documents with repeated placeholders (e.g., initials on every page)
|
||||
|
||||
## Placeholder Format (Automatic Detection)
|
||||
|
||||
```
|
||||
{{FIELD_TYPE, RECIPIENT, option1=value1, option2=value2}}
|
||||
```
|
||||
|
||||
### Components
|
||||
|
||||
- **FIELD_TYPE** (required): One of `signature`, `initials`, `name`, `email`, `date`, `text`, `number`, `radio`, `checkbox`, `dropdown`
|
||||
- **RECIPIENT** (required): `r1`, `r2`, `r3`, etc. - identifies which recipient the field belongs to
|
||||
- **OPTIONS** (optional): Key-value pairs like `required=true`, `fontSize=14`, `readOnly=true`
|
||||
|
||||
### Examples
|
||||
|
||||
- `{{signature, r1}}` - Signature field for first recipient
|
||||
- `{{text, r1, required=true, label=Company Name}}` - Required text field with label
|
||||
- `{{number, r2, minValue=0, maxValue=100}}` - Number field with validation
|
||||
|
||||
### Behavior
|
||||
|
||||
- Placeholders without recipient identifiers (e.g., `{{signature}}`) are skipped during automatic detection - reserved for API use
|
||||
- Invalid field types are silently skipped
|
||||
- Placeholder text is covered with white rectangles after field creation
|
||||
|
||||
## API Placeholder Positioning
|
||||
|
||||
The `/api/v2/envelope/field/create-many` endpoint accepts `placeholder` as an alternative to coordinates:
|
||||
|
||||
```json
|
||||
{
|
||||
"recipientId": 123,
|
||||
"type": "SIGNATURE",
|
||||
"placeholder": "{{signature}}"
|
||||
}
|
||||
```
|
||||
|
||||
### Parameters
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| ------------- | ------- | -------------------------------------------- |
|
||||
| `placeholder` | string | Text to search for in the PDF |
|
||||
| `width` | number | Optional override (percentage) |
|
||||
| `height` | number | Optional override (percentage) |
|
||||
| `matchAll` | boolean | When true, creates fields at ALL occurrences |
|
||||
|
||||
### matchAll Behavior
|
||||
|
||||
- Default (`false`): Only first occurrence gets a field
|
||||
- `true`: Creates a field at every occurrence of the placeholder text
|
||||
|
||||
This is useful for documents requiring initials on every page.
|
||||
|
||||
## Implementation Components
|
||||
|
||||
### Core Functions
|
||||
|
||||
- `extractPlaceholdersFromPDF()` - Scans PDF for `{{...}}` patterns with recipient identifiers
|
||||
- `removePlaceholdersFromPDF()` - Covers placeholder text with white rectangles
|
||||
- `whiteoutRegions()` - Low-level helper for drawing white boxes on PDF pages
|
||||
- `parseFieldTypeFromPlaceholder()` - Converts placeholder field type to FieldType enum
|
||||
- `parseFieldMetaFromPlaceholder()` - Parses options into fieldMeta format
|
||||
|
||||
### Integration Points
|
||||
|
||||
1. **Upload flow** (`create-envelope.ts`, `create-envelope-items.ts`)
|
||||
- Extract placeholders at upload time (before saving to storage)
|
||||
- Pass placeholders in-memory to envelope creation
|
||||
- Create placeholder recipients if none provided
|
||||
- Create fields within the same transaction
|
||||
|
||||
2. **API field creation** (`create-envelope-fields.ts`)
|
||||
- Accept `placeholder` as alternative to coordinates
|
||||
- Search PDF for placeholder text
|
||||
- Resolve position from bounding box
|
||||
- Support `matchAll` for multiple occurrences
|
||||
|
||||
### Field Meta Parsing
|
||||
|
||||
The following properties are explicitly parsed:
|
||||
|
||||
- `required`, `readOnly` → boolean
|
||||
- `fontSize`, `minValue`, `maxValue`, `characterLimit` → number
|
||||
- Other properties pass through as strings
|
||||
|
||||
Note: Signature fields do not support fieldMeta options.
|
||||
|
||||
## Testing
|
||||
|
||||
### E2E Tests
|
||||
|
||||
**UI Tests** (`e2e/auto-placing-fields/`):
|
||||
|
||||
- Single recipient placeholder detection
|
||||
- Multiple recipient placeholder detection
|
||||
- Field configuration from placeholder options
|
||||
- Skipping placeholders without recipient identifiers
|
||||
- Skipping invalid field types
|
||||
|
||||
**API Tests** (`e2e/api/v2/placeholder-fields-api.spec.ts`):
|
||||
|
||||
- Placeholder-based field positioning
|
||||
- Width/height overrides
|
||||
- Error on placeholder not found
|
||||
- Mixed coordinate and placeholder positioning
|
||||
- First occurrence only (default)
|
||||
- All occurrences with `matchAll: true`
|
||||
|
||||
## Documentation
|
||||
|
||||
### User Documentation
|
||||
|
||||
`/users/documents/pdf-placeholders` - Explains:
|
||||
|
||||
- Placeholder format and syntax
|
||||
- Supported field types
|
||||
- Recipient identifiers
|
||||
- Available options per field type
|
||||
- Troubleshooting
|
||||
|
||||
### Developer Documentation
|
||||
|
||||
`/developers/public-api/reference` - Documents:
|
||||
|
||||
- Coordinate-based positioning (existing)
|
||||
- Placeholder-based positioning (new)
|
||||
- matchAll parameter
|
||||
- Mixing both methods
|
||||
|
||||
## Edge Cases Handled
|
||||
|
||||
1. **No placeholders found** - Original PDF returned unchanged
|
||||
2. **Placeholder not found (API)** - Returns error with placeholder text
|
||||
3. **Multiple occurrences** - First only by default, all with `matchAll: true`
|
||||
4. **No recipient identifier** - Skipped during auto-detection, works for API
|
||||
5. **Invalid field type** - Skipped during auto-detection
|
||||
6. **Signature field with options** - Options ignored (signature doesn't support fieldMeta)
|
||||
|
||||
## Future Considerations
|
||||
|
||||
- Support for placeholder text styles (bold, underline) to indicate field properties
|
||||
- Template-level placeholder mapping for reusable configurations
|
||||
- Placeholder validation in document editor before sending
|
||||
@@ -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
|
||||
@@ -1,3 +1,6 @@
|
||||
# The license key to enable enterprise features for self hosters
|
||||
NEXT_PRIVATE_DOCUMENSO_LICENSE_KEY=
|
||||
|
||||
# [[AUTH]]
|
||||
NEXTAUTH_SECRET="secret"
|
||||
|
||||
@@ -59,6 +62,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
@@ -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
|
||||
|
||||
@@ -63,3 +63,7 @@ CLAUDE.md
|
||||
|
||||
# scripts
|
||||
scripts/output*
|
||||
|
||||
# license
|
||||
.documenso-license.json
|
||||
.documenso-license-backup.json
|
||||
|
||||
@@ -31,9 +31,18 @@ Our new API V2 supports the following typed SDKs:
|
||||
|
||||
## API V1 - Deprecated
|
||||
|
||||
Check out the [API V1 documentation](https://app.documenso.com/api/v1/openapi) for details about the API endpoints, request parameters, response formats, and authentication methods.
|
||||
<Callout type="warning">
|
||||
<strong>API V1 is deprecated.</strong>
|
||||
<br />
|
||||
The V1 API will continue to be supported for the foreseeable future, but it is limited to
|
||||
<strong>Legacy Documents</strong> (Documents created using the old non-envelope editor).
|
||||
|
||||
📖 [Documentation](https://documen.so/api-v2-docs)
|
||||
<strong>Important:</strong> To work with the new <strong>Envelope</strong> document system, you
|
||||
must use the
|
||||
<strong> V2 API</strong>.
|
||||
</Callout>
|
||||
|
||||
Check out the [API V1 documentation](https://app.documenso.com/api/v1/openapi) for details about the API endpoints, request parameters, response formats, and authentication methods.
|
||||
|
||||
## Availability
|
||||
|
||||
|
||||
@@ -316,6 +316,8 @@ Before adding fields to an envelope, you will need the following details:
|
||||
|
||||
See the [Get Envelope](#get-envelope) section for more details on how to retrieve these details.
|
||||
|
||||
### Coordinate-Based Positioning
|
||||
|
||||
The following is an example of a request which creates 2 new fields on the first page of the envelope.
|
||||
|
||||
Note that width, height, positionX and positionY are percentage numbers between 0 and 100, which scale the field relative to the size of the PDF.
|
||||
@@ -360,6 +362,95 @@ curl https://app.documenso.com/api/v2/envelope/field/create-many \
|
||||
}'
|
||||
```
|
||||
|
||||
### Placeholder-Based Positioning
|
||||
|
||||
Instead of specifying exact coordinates, you can position fields using placeholder text in the PDF. The API will search for the text and place the field at that location.
|
||||
|
||||
This is useful when:
|
||||
|
||||
- You have PDFs with designated placeholder text (e.g., `{{signature}}`, `[SIGN HERE]`)
|
||||
- You want field positions to adapt to document content changes
|
||||
- You're working with templated documents generated from other systems
|
||||
|
||||
```sh
|
||||
curl https://app.documenso.com/api/v2/envelope/field/create-many \
|
||||
--request POST \
|
||||
--header 'Authorization: api_xxxxxxxxxxxxxx' \
|
||||
--header 'Content-Type: application/json' \
|
||||
--data '{
|
||||
"envelopeId": "envelope_xxxxxxxxxx",
|
||||
"data": [
|
||||
{
|
||||
"recipientId": recipient_id_here,
|
||||
"type": "SIGNATURE",
|
||||
"placeholder": "{{signature}}"
|
||||
},
|
||||
{
|
||||
"recipientId": recipient_id_here,
|
||||
"type": "NAME",
|
||||
"placeholder": "{{name}}",
|
||||
"width": 30,
|
||||
"height": 5
|
||||
}
|
||||
]
|
||||
}'
|
||||
```
|
||||
|
||||
#### Placeholder Parameters
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| ------------- | ------- | -------- | ---------------------------------------------------------------------------------------------------------------- |
|
||||
| `placeholder` | string | Yes | Text to search for in the PDF. The field is placed at the location of this text. |
|
||||
| `width` | number | No | Override the field width (percentage). If omitted, uses the placeholder text width. |
|
||||
| `height` | number | No | Override the field height (percentage). If omitted, uses the placeholder text height. |
|
||||
| `matchAll` | boolean | No | When `true`, creates a field at every occurrence of the placeholder. Default is `false` (first occurrence only). |
|
||||
|
||||
<Callout type="info">
|
||||
The placeholder text is automatically covered with a white rectangle after field creation, so it
|
||||
won't appear in the final signed document.
|
||||
</Callout>
|
||||
|
||||
#### Multiple Occurrences
|
||||
|
||||
If your PDF contains the same placeholder text multiple times (e.g., initials on every page), use `matchAll: true` to create fields at all occurrences:
|
||||
|
||||
```json
|
||||
{
|
||||
"recipientId": 123,
|
||||
"type": "INITIALS",
|
||||
"placeholder": "{{initials}}",
|
||||
"matchAll": true
|
||||
}
|
||||
```
|
||||
|
||||
This will create one INITIALS field for each occurrence of `{{initials}}` in the PDF.
|
||||
|
||||
#### Mixing Positioning Methods
|
||||
|
||||
You can combine coordinate-based and placeholder-based positioning in the same request:
|
||||
|
||||
```json
|
||||
{
|
||||
"envelopeId": "envelope_xxxxxxxxxx",
|
||||
"data": [
|
||||
{
|
||||
"recipientId": 123,
|
||||
"type": "SIGNATURE",
|
||||
"placeholder": "{{signature}}"
|
||||
},
|
||||
{
|
||||
"recipientId": 123,
|
||||
"type": "DATE",
|
||||
"page": 1,
|
||||
"positionX": 70,
|
||||
"positionY": 85,
|
||||
"width": 20,
|
||||
"height": 3
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Field meta allows you to further configure fields, for example it will allow you to add multiple items for checkboxes or radios.
|
||||
|
||||
A successful request will return a JSON response with the newly added fields.
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -544,7 +544,7 @@ Example payload for the `document.rejected` event:
|
||||
}
|
||||
```
|
||||
|
||||
Example payload for the `document.rejected` event:
|
||||
Example payload for the `document.cancelled` event:
|
||||
|
||||
```json
|
||||
{
|
||||
|
||||
@@ -3,6 +3,8 @@ export default {
|
||||
'document-preferences': 'Document Preferences',
|
||||
'document-visibility': 'Document Visibility',
|
||||
fields: 'Document Fields',
|
||||
'pdf-placeholders': 'PDF Placeholders',
|
||||
'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.
|
||||
|
||||

|
||||
|
||||
The recipients are added with the "CC" role by default, but you can select a different role for each recipient.
|
||||
|
||||

|
||||
|
||||
### 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.
|
||||
|
||||

|
||||
@@ -0,0 +1,179 @@
|
||||
---
|
||||
title: PDF Placeholders
|
||||
description: Learn how to use placeholder text in your PDFs for automatic field placement in Documenso.
|
||||
---
|
||||
|
||||
import { Callout } from 'nextra/components';
|
||||
|
||||
# PDF Placeholders
|
||||
|
||||
Documenso can automatically detect placeholder text in your PDF documents and create fields at those locations. This allows you to prepare documents in your preferred editing tool (Word, Google Docs, etc.) with placeholders that become signature fields when uploaded.
|
||||
|
||||
## How It Works
|
||||
|
||||
When you upload a PDF, Documenso scans for text matching the placeholder pattern `{{...}}`. Each placeholder can specify:
|
||||
|
||||
1. **Field type** - What kind of field to create (signature, name, email, etc.)
|
||||
2. **Recipient** - Which signer the field belongs to (r1, r2, etc.)
|
||||
3. **Options** - Additional settings like required, read-only, font size, etc.
|
||||
|
||||
The placeholder text is automatically hidden after fields are created, so your final document looks clean.
|
||||
|
||||
## Placeholder Format
|
||||
|
||||
The basic format is:
|
||||
|
||||
```
|
||||
{{FIELD_TYPE, RECIPIENT, option1=value1, option2=value2}}
|
||||
```
|
||||
|
||||
### Examples
|
||||
|
||||
| Placeholder | Description |
|
||||
| ----------------------------- | ----------------------------------- |
|
||||
| `{{signature, r1}}` | Signature field for recipient 1 |
|
||||
| `{{name, r1}}` | Name field for recipient 1 |
|
||||
| `{{email, r2}}` | Email field for recipient 2 |
|
||||
| `{{date, r1}}` | Date field for recipient 1 |
|
||||
| `{{text, r1, required=true}}` | Required text field for recipient 1 |
|
||||
| `{{initials, r1}}` | Initials field for recipient 1 |
|
||||
|
||||
## Supported Field Types
|
||||
|
||||
The following field types are supported in placeholders:
|
||||
|
||||
| Field Type | Placeholder Value |
|
||||
| ---------- | ----------------- |
|
||||
| Signature | `signature` |
|
||||
| Initials | `initials` |
|
||||
| Name | `name` |
|
||||
| Email | `email` |
|
||||
| Date | `date` |
|
||||
| Text | `text` |
|
||||
| Number | `number` |
|
||||
| Radio | `radio` |
|
||||
| Checkbox | `checkbox` |
|
||||
| Dropdown | `dropdown` |
|
||||
|
||||
<Callout type="info">
|
||||
Field types are case-insensitive. `{{ SIGNATURE, r1 }}` and `{{ signature, r1 }}` are equivalent.
|
||||
</Callout>
|
||||
|
||||
## Recipient Identifiers
|
||||
|
||||
Recipients are identified using `r1`, `r2`, `r3`, etc. The number corresponds to the order in which recipients are created:
|
||||
|
||||
- `r1` - First recipient
|
||||
- `r2` - Second recipient
|
||||
- `r3` - Third recipient
|
||||
|
||||
When you upload a PDF with placeholders, Documenso will:
|
||||
|
||||
1. Create placeholder recipients for each unique identifier found (e.g., `r1`, `r2`)
|
||||
2. You can then update these with real email addresses before sending
|
||||
|
||||
<Callout type="warning">
|
||||
Placeholders without a recipient identifier (e.g., `{{ signature }}` without `r1`) are reserved
|
||||
for API use and will not create fields during upload.
|
||||
</Callout>
|
||||
|
||||
## Field Options
|
||||
|
||||
You can customize fields by adding options after the recipient identifier:
|
||||
|
||||
### Common Options
|
||||
|
||||
| Option | Values | Description |
|
||||
| ----------- | ------------------------- | ------------------------------------------ |
|
||||
| `required` | `true`, `false` | Whether the field must be filled |
|
||||
| `readOnly` | `true`, `false` | Whether the field is pre-filled and locked |
|
||||
| `fontSize` | Number (e.g., `12`) | Font size in points |
|
||||
| `textAlign` | `left`, `center`, `right` | Horizontal text alignment |
|
||||
|
||||
### Text Field Options
|
||||
|
||||
| Option | Values | Description |
|
||||
| ---------------- | ------ | ------------------------------------- |
|
||||
| `label` | Text | Label shown in the field |
|
||||
| `placeholder` | Text | Placeholder text shown before signing |
|
||||
| `text` | Text | Pre-filled text value |
|
||||
| `characterLimit` | Number | Maximum characters allowed |
|
||||
|
||||
### Number Field Options
|
||||
|
||||
| Option | Values | Description |
|
||||
| -------------- | ------------- | --------------------- |
|
||||
| `value` | Number | Pre-filled value |
|
||||
| `minValue` | Number | Minimum allowed value |
|
||||
| `maxValue` | Number | Maximum allowed value |
|
||||
| `numberFormat` | Format string | Number display format |
|
||||
|
||||
### Examples with Options
|
||||
|
||||
```
|
||||
{{text, r1, required=true, label=Company Name}}
|
||||
{{number, r1, minValue=0, maxValue=100, value=50}}
|
||||
{{name, r1, fontSize=14}}
|
||||
{{text, r2, readOnly=true, text=Contract #12345}}
|
||||
```
|
||||
|
||||
<Callout type="info">
|
||||
Signature and Free Signature fields do not support additional options beyond the field type and
|
||||
recipient.
|
||||
</Callout>
|
||||
|
||||
## Multiple Recipients Example
|
||||
|
||||
Here's how a document might look with placeholders for two signers:
|
||||
|
||||
```
|
||||
AGREEMENT
|
||||
|
||||
Party A Signature: {{signature, r1}}
|
||||
Party A Name: {{name, r1}}
|
||||
Party A Date: {{date, r1}}
|
||||
|
||||
Party B Signature: {{signature, r2}}
|
||||
Party B Name: {{name, r2}}
|
||||
Party B Date: {{date, r2}}
|
||||
```
|
||||
|
||||
When uploaded, this creates:
|
||||
|
||||
- 3 fields assigned to recipient 1 (Party A)
|
||||
- 3 fields assigned to recipient 2 (Party B)
|
||||
- 2 placeholder recipients that you can update with real email addresses
|
||||
|
||||
## Tips for Creating Documents
|
||||
|
||||
1. **Use a readable font** - Placeholders need to be readable by the PDF parser. Standard fonts like Arial, Helvetica, or Times New Roman work best.
|
||||
|
||||
2. **Don't split placeholders** - Ensure the entire placeholder text `{{...}}` is on a single line and not broken across text boxes.
|
||||
|
||||
3. **Size matters** - The field will be sized to match the placeholder text width. Use spaces or longer placeholder text if you need wider fields.
|
||||
|
||||
4. **Test with a draft** - Upload your document as a draft first to verify fields are detected correctly before sending.
|
||||
|
||||
<Callout type="info">
|
||||
Placeholder detection happens automatically when you upload a PDF. You can review and adjust the
|
||||
created fields in the document editor before sending.
|
||||
</Callout>
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Placeholders Not Detected
|
||||
|
||||
- Ensure placeholders use double curly braces: `{{...}}`
|
||||
- Check that the placeholder includes a recipient identifier (e.g., `r1`)
|
||||
- Verify the field type is spelled correctly
|
||||
- Try using a standard font in your source document
|
||||
|
||||
### Wrong Field Position
|
||||
|
||||
- The field is placed at the exact location of the placeholder text
|
||||
- If the position seems off, check that your PDF wasn't scaled or reformatted when exported
|
||||
|
||||
### Placeholder Text Still Visible
|
||||
|
||||
- Placeholder text is covered with a white rectangle after field creation
|
||||
- If you see the text, try re-uploading the document
|
||||
@@ -7,28 +7,41 @@ import { Callout } from 'nextra/components';
|
||||
|
||||
# Fair Use Policy
|
||||
|
||||
We offer our plans without any limits on volume because we want our users and customers to make the most of their accounts. Estimating volume is incredibly hard, especially for shorter intervals like a quarter. We are not interested in selling volume packages our customers end up not using.
|
||||
We like to overdeliver, but we cannot overcommit.
|
||||
|
||||
This is why the individual plan and the team plan do not include a limit on signing or API volume. If you are a customer of these [plans](https://documen.so/pricing), we ask you to abide by this fair use policy:
|
||||
Our plans are designed to be generous and flexible without forcing customers into rigid volume limits they may never use. At the same time, estimating usage at scale is hard, especially over short periods. This fair use policy exists to keep plans sustainable while allowing us to add more value wherever possible without overformalizing restrictions.
|
||||
|
||||
We offer our plans without any limits on volume because we want users and customers to make the most of their accounts. Estimating volume is incredibly hard, especially for shorter intervals like a quarter. We are not interested in selling volume packages our customers end up not using.
|
||||
|
||||
This is why our plans not include a limit on signing or API volume. If you are a customer of these plans, we ask you to abide by this fair use policy.
|
||||
|
||||
### Spirit of the Plan
|
||||
|
||||
Use the limitless accounts as much as you like (they are meant to offer a lot) while respecting the spirit and intended scope of the account.
|
||||
Use the limitless plans as much as you like. They are meant to offer a lot. Please respect the spirit and intended scope of the account.
|
||||
|
||||
<Callout type="info">
|
||||
What happens if I violate this policy? We will ask you to upgrade to a fitting plan or custom
|
||||
pricing. We won’t block your account without reaching out. You can [message
|
||||
us](mailto:support@documenso.com) for questions.
|
||||
What happens if I go beyond the scope of this policy? We will ask you to upgrade to a fitting plan
|
||||
or custom pricing. We will not block your account without reaching out. You can message us for
|
||||
questions.
|
||||
</Callout>
|
||||
|
||||
### Fair Support
|
||||
|
||||
We believe in fair support as much as fair usage.
|
||||
|
||||
Fair support includes reasonable and within reason application level help for self hosted users. We will help you get unstuck and point you in the right direction when issues come up. Support is provided in good faith and within reasonable time and effort limits. We are not your operations team and cannot take responsibility for running, monitoring, or maintaining your infrastructure.
|
||||
|
||||
If you are unsure whether something falls within fair use or fair support, reach out. We are happy to talk it through.
|
||||
|
||||
### DO
|
||||
|
||||
- Sign as many documents as you need with the individual plan for your single business or organization you are part of
|
||||
- Use the API and automation tools to automate all your signing workflows
|
||||
- Experiment with the plans and integrations, testing what you want to build
|
||||
- Sign as many documents as you need with the individual plan for your single business or organization
|
||||
- Use the API and automation tools to automate your signing workflows
|
||||
- Experiment with plans and integrations while testing what you want to build
|
||||
|
||||
### DON'T
|
||||
|
||||
- Use the individual account's API to power a platform
|
||||
- Run a huge company, signing thousands of documents per day on a two-user team plan using the API
|
||||
- Let this policy make you overthink. If you are a paying customer, we want you to win
|
||||
- Use an individual account API to power a platform or product
|
||||
- Run a large company signing thousands of documents per day on a small team plan
|
||||
- Expect enterprise level support for fair support plan
|
||||
- Overthink this policy. If you are a paying customer, we want you to win
|
||||
|
||||
@@ -7,20 +7,51 @@ import { Callout } from 'nextra/components';
|
||||
|
||||
# Enterprise Edition
|
||||
|
||||
The Documenso Enterprise Edition is our license for self-hosters that need the full range of support and compliance. Everything in the EE folder and all features listed [here](https://github.com/documenso/documenso/blob/main/packages/ee/FEATURES) can be used after acquiring a paid license.
|
||||
|
||||
## Includes
|
||||
|
||||
- Self-Host Documenso in any context.
|
||||
- Premium Support via Slack, Discord and Email.
|
||||
- Flexible Licensing (e.g. MIT) for deeper custom integration (if needed).
|
||||
- Access to all Enterprise-grade compliance and administration features.
|
||||
|
||||
## Limitations
|
||||
|
||||
The Enterprise Edition currently has no limitations except custom contract terms.
|
||||
|
||||
<Callout type="info">
|
||||
The Enterprise Edition requires a paid subscription. [Contact us for a
|
||||
quote](https://documen.so/enterprise).
|
||||
</Callout>
|
||||
|
||||
The Documenso Enterprise Edition is our license for self-hosters that need the full range of support and compliance.
|
||||
|
||||
The following features are included in the Enterprise Edition:
|
||||
|
||||
{/* Keep this synced with the packages/ee/FEATURES file */}
|
||||
|
||||
- The Stripe Billing Module
|
||||
- Organisation Authentication Portal
|
||||
- Document Action Reauthentication (Passkeys and 2FA)
|
||||
- 21 CFR
|
||||
- Email domains
|
||||
- Embed authoring
|
||||
- Embed authoring white label
|
||||
|
||||
In addition, you will receive:
|
||||
|
||||
- Premium Support via Slack, Discord and Email.
|
||||
- Flexible Licensing (e.g. MIT) for deeper custom integration (if needed).
|
||||
- Access to Enterprise-grade compliance and administration features.
|
||||
- Permission to self-Host Documenso in any context.
|
||||
|
||||
The Enterprise Edition currently has no limitations except custom contract terms.
|
||||
|
||||
## Getting a License
|
||||
|
||||
To acquire an Enterprise Edition license, please [contact our sales team](https://documen.so/enterprise) for a quote. Our team will work with you to understand your requirements and provide a license that fits your needs.
|
||||
|
||||
## Using Your License
|
||||
|
||||
Once you have acquired an Enterprise Edition license:
|
||||
|
||||
1. Access your license key at [license.documenso.com](https://license.documenso.com)
|
||||
2. Set the `NEXT_PRIVATE_DOCUMENSO_LICENSE_KEY` environment variable in your Documenso instance with your license key
|
||||
|
||||
```bash
|
||||
NEXT_PRIVATE_DOCUMENSO_LICENSE_KEY="your-license-key-here"
|
||||
```
|
||||
|
||||
3. You can verify your license status in the Admin Panel under the Stats section.
|
||||
|
||||

|
||||
|
||||
Your license will be verified on startup and periodically to ensure continued access to Enterprise features.
|
||||
|
||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 555 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 928 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 897 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 75 KiB |
@@ -3,6 +3,7 @@ import { useState } from 'react';
|
||||
import { Trans, useLingui } from '@lingui/react/macro';
|
||||
import type { z } from 'zod';
|
||||
|
||||
import type { TLicenseClaim } from '@documenso/lib/types/license';
|
||||
import { generateDefaultSubscriptionClaim } from '@documenso/lib/utils/organisations-claims';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import type { ZCreateSubscriptionClaimRequestSchema } from '@documenso/trpc/server/admin-router/create-subscription-claim.types';
|
||||
@@ -22,7 +23,11 @@ import { SubscriptionClaimForm } from '../forms/subscription-claim-form';
|
||||
|
||||
export type CreateClaimFormValues = z.infer<typeof ZCreateSubscriptionClaimRequestSchema>;
|
||||
|
||||
export const ClaimCreateDialog = () => {
|
||||
type ClaimCreateDialogProps = {
|
||||
licenseFlags?: TLicenseClaim;
|
||||
};
|
||||
|
||||
export const ClaimCreateDialog = ({ licenseFlags }: ClaimCreateDialogProps) => {
|
||||
const { t } = useLingui();
|
||||
const { toast } = useToast();
|
||||
|
||||
@@ -67,6 +72,7 @@ export const ClaimCreateDialog = () => {
|
||||
...generateDefaultSubscriptionClaim(),
|
||||
}}
|
||||
onFormSubmit={createClaim}
|
||||
licenseFlags={licenseFlags}
|
||||
formSubmitTrigger={
|
||||
<DialogFooter>
|
||||
<Button
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useState } from 'react';
|
||||
|
||||
import { Trans, useLingui } from '@lingui/react/macro';
|
||||
|
||||
import type { TLicenseClaim } from '@documenso/lib/types/license';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import type { TFindSubscriptionClaimsResponse } from '@documenso/trpc/server/admin-router/find-subscription-claims.types';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
@@ -21,9 +22,10 @@ import { SubscriptionClaimForm } from '../forms/subscription-claim-form';
|
||||
export type ClaimUpdateDialogProps = {
|
||||
claim: TFindSubscriptionClaimsResponse['data'][number];
|
||||
trigger: React.ReactNode;
|
||||
licenseFlags?: TLicenseClaim;
|
||||
};
|
||||
|
||||
export const ClaimUpdateDialog = ({ claim, trigger }: ClaimUpdateDialogProps) => {
|
||||
export const ClaimUpdateDialog = ({ claim, trigger, licenseFlags }: ClaimUpdateDialogProps) => {
|
||||
const { t } = useLingui();
|
||||
const { toast } = useToast();
|
||||
|
||||
@@ -69,6 +71,7 @@ export const ClaimUpdateDialog = ({ claim, trigger }: ClaimUpdateDialogProps) =>
|
||||
data,
|
||||
})
|
||||
}
|
||||
licenseFlags={licenseFlags}
|
||||
formSubmitTrigger={
|
||||
<DialogFooter>
|
||||
<Button
|
||||
|
||||
@@ -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],
|
||||
);
|
||||
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
import { plural } from '@lingui/core/macro';
|
||||
import { Plural, useLingui } from '@lingui/react/macro';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { EnvelopeType } from '@prisma/client';
|
||||
import type * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@documenso/ui/primitives/dialog';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
export type EnvelopesBulkDeleteDialogProps = {
|
||||
envelopeIds: string[];
|
||||
envelopeType: EnvelopeType;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSuccess?: () => void;
|
||||
} & Omit<DialogPrimitive.DialogProps, 'children'>;
|
||||
|
||||
export const EnvelopesBulkDeleteDialog = ({
|
||||
envelopeIds,
|
||||
envelopeType,
|
||||
open,
|
||||
onOpenChange,
|
||||
onSuccess,
|
||||
...props
|
||||
}: EnvelopesBulkDeleteDialogProps) => {
|
||||
const { t } = useLingui();
|
||||
const { toast } = useToast();
|
||||
|
||||
const trpcUtils = trpc.useUtils();
|
||||
|
||||
const isDocument = envelopeType === EnvelopeType.DOCUMENT;
|
||||
|
||||
const { mutateAsync: bulkDeleteEnvelopes, isPending } = trpc.envelope.bulk.delete.useMutation({
|
||||
onSuccess: async (result) => {
|
||||
// Invalidate the appropriate query based on envelope type.
|
||||
if (isDocument) {
|
||||
await trpcUtils.document.findDocumentsInternal.invalidate();
|
||||
} else {
|
||||
await trpcUtils.template.findTemplates.invalidate();
|
||||
}
|
||||
|
||||
if (result.failedIds.length > 0) {
|
||||
toast({
|
||||
title: isDocument ? t`Documents partially deleted` : t`Templates partially deleted`,
|
||||
description: t`${plural(result.deletedCount, {
|
||||
one: '# item deleted.',
|
||||
other: '# items deleted.',
|
||||
})} ${plural(result.failedIds.length, {
|
||||
one: '# item could not be deleted.',
|
||||
other: '# items could not be deleted.',
|
||||
})}`,
|
||||
variant: 'destructive',
|
||||
});
|
||||
} else {
|
||||
toast({
|
||||
title: isDocument ? t`Documents deleted` : t`Templates deleted`,
|
||||
description: plural(result.deletedCount, {
|
||||
one: '# item has been deleted.',
|
||||
other: '# items have been deleted.',
|
||||
}),
|
||||
variant: 'default',
|
||||
});
|
||||
}
|
||||
|
||||
onSuccess?.();
|
||||
onOpenChange(false);
|
||||
},
|
||||
onError: () => {
|
||||
toast({
|
||||
title: t`Error`,
|
||||
description: t`An error occurred while deleting the items.`,
|
||||
variant: 'destructive',
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Dialog {...props} open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{isDocument ? <Trans>Delete Documents</Trans> : <Trans>Delete Templates</Trans>}
|
||||
</DialogTitle>
|
||||
|
||||
<DialogDescription>
|
||||
{isDocument ? (
|
||||
<Plural
|
||||
value={envelopeIds.length}
|
||||
one="You are about to delete the selected document."
|
||||
other="You are about to delete # documents."
|
||||
/>
|
||||
) : (
|
||||
<Plural
|
||||
value={envelopeIds.length}
|
||||
one="You are about to delete the selected template."
|
||||
other="You are about to delete # templates."
|
||||
/>
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Alert variant="warning">
|
||||
<AlertDescription>
|
||||
<p>
|
||||
<Trans>
|
||||
Please note that this action is <strong>irreversible</strong>.
|
||||
</Trans>
|
||||
</p>
|
||||
|
||||
<p className="mt-1">
|
||||
<Trans>Once confirmed, the following will occur:</Trans>
|
||||
</p>
|
||||
|
||||
<ul className="mt-0.5 list-inside list-disc">
|
||||
{isDocument ? (
|
||||
<>
|
||||
<li>
|
||||
<Trans>Selected documents will be permanently deleted</Trans>
|
||||
</li>
|
||||
<li>
|
||||
<Trans>Pending documents will have their signing process cancelled</Trans>
|
||||
</li>
|
||||
<li>
|
||||
<Trans>All recipients will be notified</Trans>
|
||||
</li>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<li>
|
||||
<Trans>Selected templates will be permanently deleted</Trans>
|
||||
</li>
|
||||
<li>
|
||||
<Trans>Direct links associated with templates will be removed</Trans>
|
||||
</li>
|
||||
</>
|
||||
)}
|
||||
</ul>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="secondary" disabled={isPending}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
void bulkDeleteEnvelopes({ envelopeIds });
|
||||
}}
|
||||
loading={isPending}
|
||||
variant="destructive"
|
||||
>
|
||||
<Trans>Delete</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,256 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { Plural, useLingui } from '@lingui/react/macro';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { EnvelopeType } from '@prisma/client';
|
||||
import type * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||
import { FolderIcon, HomeIcon, Loader2, Search } from 'lucide-react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { match } from 'ts-pattern';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@documenso/ui/primitives/dialog';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@documenso/ui/primitives/form/form';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
export type EnvelopesBulkMoveDialogProps = {
|
||||
envelopeIds: string[];
|
||||
envelopeType: EnvelopeType;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
currentFolderId?: string;
|
||||
onSuccess?: () => void;
|
||||
} & Omit<DialogPrimitive.DialogProps, 'children'>;
|
||||
|
||||
const ZBulkMoveFormSchema = z.object({
|
||||
folderId: z.string().nullable(),
|
||||
});
|
||||
|
||||
type TBulkMoveFormSchema = z.infer<typeof ZBulkMoveFormSchema>;
|
||||
|
||||
export const EnvelopesBulkMoveDialog = ({
|
||||
envelopeIds,
|
||||
envelopeType,
|
||||
open,
|
||||
onOpenChange,
|
||||
currentFolderId,
|
||||
onSuccess,
|
||||
...props
|
||||
}: EnvelopesBulkMoveDialogProps) => {
|
||||
const { t } = useLingui();
|
||||
const { toast } = useToast();
|
||||
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
|
||||
const form = useForm<TBulkMoveFormSchema>({
|
||||
resolver: zodResolver(ZBulkMoveFormSchema),
|
||||
defaultValues: {
|
||||
folderId: currentFolderId ?? null,
|
||||
},
|
||||
});
|
||||
|
||||
const isDocument = envelopeType === EnvelopeType.DOCUMENT;
|
||||
|
||||
const { data: folders, isLoading: isFoldersLoading } = trpc.folder.findFoldersInternal.useQuery(
|
||||
{
|
||||
parentId: currentFolderId,
|
||||
type: envelopeType,
|
||||
},
|
||||
{
|
||||
enabled: open,
|
||||
},
|
||||
);
|
||||
|
||||
const { mutateAsync: bulkMoveEnvelopes } = trpc.envelope.bulk.move.useMutation();
|
||||
|
||||
const trpcUtils = trpc.useUtils();
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setSearchTerm('');
|
||||
|
||||
form.reset({
|
||||
folderId: currentFolderId,
|
||||
});
|
||||
}
|
||||
}, [open, currentFolderId]);
|
||||
|
||||
const onSubmit = async (data: TBulkMoveFormSchema) => {
|
||||
try {
|
||||
await bulkMoveEnvelopes({
|
||||
envelopeIds,
|
||||
folderId: data.folderId,
|
||||
envelopeType,
|
||||
});
|
||||
|
||||
// Invalidate the appropriate query based on envelope type.
|
||||
if (isDocument) {
|
||||
await trpcUtils.document.findDocumentsInternal.invalidate();
|
||||
} else {
|
||||
await trpcUtils.template.findTemplates.invalidate();
|
||||
}
|
||||
|
||||
toast({
|
||||
description: t`Selected items have been moved.`,
|
||||
});
|
||||
|
||||
onSuccess?.();
|
||||
onOpenChange(false);
|
||||
} catch (err) {
|
||||
const error = AppError.parseError(err);
|
||||
|
||||
const errorMessage = match(error.code)
|
||||
.with(
|
||||
AppErrorCode.NOT_FOUND,
|
||||
() => t`The folder you are trying to move the items to does not exist.`,
|
||||
)
|
||||
.with(AppErrorCode.UNAUTHORIZED, () => t`You are not allowed to move these items.`)
|
||||
.with(AppErrorCode.INVALID_BODY, () => t`All items must be of the same type.`)
|
||||
.otherwise(() => t`An error occurred while moving the items.`);
|
||||
|
||||
toast({
|
||||
description: errorMessage,
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const filteredFolders = folders?.data.filter((folder) =>
|
||||
folder.name.toLowerCase().includes(searchTerm.toLowerCase()),
|
||||
);
|
||||
|
||||
return (
|
||||
<Dialog {...props} open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{isDocument ? (
|
||||
<Trans>Move Documents to Folder</Trans>
|
||||
) : (
|
||||
<Trans>Move Templates to Folder</Trans>
|
||||
)}
|
||||
</DialogTitle>
|
||||
|
||||
<DialogDescription>
|
||||
{isDocument ? (
|
||||
<Plural
|
||||
value={envelopeIds.length}
|
||||
one="Select a folder to move the selected document to."
|
||||
other="Select a folder to move the # selected documents to."
|
||||
/>
|
||||
) : (
|
||||
<Plural
|
||||
value={envelopeIds.length}
|
||||
one="Select a folder to move the selected template to."
|
||||
other="Select a folder to move the # selected templates to."
|
||||
/>
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2 top-3 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder={t`Search folders...`}
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="pl-8"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="folderId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Folder</Trans>
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<div className="max-h-96 space-y-2 overflow-y-auto">
|
||||
{isFoldersLoading ? (
|
||||
<div className="flex h-10 items-center justify-center">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
variant={field.value === null ? 'default' : 'outline'}
|
||||
className="w-full justify-start"
|
||||
onClick={() => field.onChange(null)}
|
||||
disabled={currentFolderId === undefined}
|
||||
>
|
||||
<HomeIcon className="mr-2 h-4 w-4" />
|
||||
<Trans>Home (No Folder)</Trans>
|
||||
</Button>
|
||||
|
||||
{filteredFolders?.map((folder) => (
|
||||
<Button
|
||||
key={folder.id}
|
||||
type="button"
|
||||
variant={field.value === folder.id ? 'default' : 'outline'}
|
||||
className="w-full justify-start"
|
||||
onClick={() => field.onChange(folder.id)}
|
||||
disabled={currentFolderId === folder.id}
|
||||
>
|
||||
<FolderIcon className="mr-2 h-4 w-4" />
|
||||
{folder.name}
|
||||
</Button>
|
||||
))}
|
||||
|
||||
{searchTerm && filteredFolders?.length === 0 && (
|
||||
<div className="px-2 py-2 text-center text-sm text-muted-foreground">
|
||||
<Trans>No folders found</Trans>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isFoldersLoading || form.formState.isSubmitting}
|
||||
loading={form.formState.isSubmitting}
|
||||
>
|
||||
<Trans>Move</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -1,10 +1,10 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { Trans, useLingui } from '@lingui/react/macro';
|
||||
import { TeamMemberRole } from '@prisma/client';
|
||||
import type * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||
import { InfoIcon } from 'lucide-react';
|
||||
import { InfoIcon, UserPlusIcon } from 'lucide-react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { Link } from 'react-router';
|
||||
import { match } from 'ts-pattern';
|
||||
@@ -13,6 +13,7 @@ import { z } from 'zod';
|
||||
import { TEAM_MEMBER_ROLE_HIERARCHY } from '@documenso/lib/constants/teams';
|
||||
import { TEAM_MEMBER_ROLE_MAP } from '@documenso/lib/constants/teams-translations';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
Dialog,
|
||||
@@ -44,6 +45,7 @@ import {
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { OrganisationMemberInviteDialog } from '~/components/dialogs/organisation-member-invite-dialog';
|
||||
import { useCurrentTeam } from '~/providers/team';
|
||||
|
||||
export type TeamMemberCreateDialogProps = {
|
||||
@@ -64,11 +66,14 @@ type TAddTeamMembersFormSchema = z.infer<typeof ZAddTeamMembersFormSchema>;
|
||||
export const TeamMemberCreateDialog = ({ trigger, ...props }: TeamMemberCreateDialogProps) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [step, setStep] = useState<'SELECT' | 'MEMBERS'>('SELECT');
|
||||
const [inviteDialogOpen, setInviteDialogOpen] = useState(false);
|
||||
const prevInviteDialogOpenRef = useRef(false);
|
||||
|
||||
const { t } = useLingui();
|
||||
const { toast } = useToast();
|
||||
|
||||
const team = useCurrentTeam();
|
||||
const utils = trpc.useUtils();
|
||||
|
||||
const form = useForm<TAddTeamMembersFormSchema>({
|
||||
resolver: zodResolver(ZAddTeamMembersFormSchema),
|
||||
@@ -96,7 +101,29 @@ export const TeamMemberCreateDialog = ({ trigger, ...props }: TeamMemberCreateDi
|
||||
);
|
||||
}, [organisationMemberQuery, teamMemberQuery]);
|
||||
|
||||
const hasNoAvailableMembers =
|
||||
!organisationMemberQuery.isLoading && avaliableOrganisationMembers.length === 0;
|
||||
|
||||
const onFormSubmit = async ({ members }: TAddTeamMembersFormSchema) => {
|
||||
if (members.length === 0) {
|
||||
if (hasNoAvailableMembers) {
|
||||
setInviteDialogOpen(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// Don't show error if on SELECT step - the disabled Next button already communicates this
|
||||
if (step === 'SELECT') {
|
||||
return;
|
||||
}
|
||||
|
||||
toast({
|
||||
title: t`No members selected`,
|
||||
description: t`Please select at least one member to add to the team.`,
|
||||
variant: 'destructive',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await createTeamMembers({
|
||||
teamId: team.id,
|
||||
@@ -123,9 +150,20 @@ export const TeamMemberCreateDialog = ({ trigger, ...props }: TeamMemberCreateDi
|
||||
if (!open) {
|
||||
form.reset();
|
||||
setStep('SELECT');
|
||||
setInviteDialogOpen(false);
|
||||
}
|
||||
}, [open, form]);
|
||||
|
||||
// Invalidate queries when invite dialog closes (transitions from true to false) to refresh available members
|
||||
useEffect(() => {
|
||||
if (prevInviteDialogOpenRef.current && !inviteDialogOpen) {
|
||||
void utils.organisation.member.find.invalidate({
|
||||
organisationId: team.organisationId,
|
||||
});
|
||||
}
|
||||
prevInviteDialogOpenRef.current = inviteDialogOpen;
|
||||
}, [inviteDialogOpen, utils, team.organisationId]);
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
{...props}
|
||||
@@ -134,9 +172,11 @@ export const TeamMemberCreateDialog = ({ trigger, ...props }: TeamMemberCreateDi
|
||||
// Since it would be annoying to redo the whole process.
|
||||
>
|
||||
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild>
|
||||
<Button variant="secondary" onClick={() => setOpen(true)}>
|
||||
<Trans>Add members</Trans>
|
||||
</Button>
|
||||
{trigger ?? (
|
||||
<Button variant="secondary" onClick={() => setOpen(true)}>
|
||||
<Trans>Add members</Trans>
|
||||
</Button>
|
||||
)}
|
||||
</DialogTrigger>
|
||||
|
||||
<DialogContent hideClose={true} position="center">
|
||||
@@ -149,7 +189,7 @@ export const TeamMemberCreateDialog = ({ trigger, ...props }: TeamMemberCreateDi
|
||||
<TooltipTrigger asChild>
|
||||
<InfoIcon className="mx-2 h-4 w-4" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="text-muted-foreground z-[99999] max-w-xs">
|
||||
<TooltipContent className="z-[99999] max-w-xs text-muted-foreground">
|
||||
<Trans>
|
||||
To be able to add members to a team, you must first add them to the
|
||||
organisation. For more information, please see the{' '}
|
||||
@@ -186,7 +226,18 @@ export const TeamMemberCreateDialog = ({ trigger, ...props }: TeamMemberCreateDi
|
||||
.exhaustive()}
|
||||
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onFormSubmit)}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onFormSubmit)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && form.getValues('members').length === 0) {
|
||||
e.preventDefault();
|
||||
if (hasNoAvailableMembers) {
|
||||
setInviteDialogOpen(true);
|
||||
}
|
||||
// Don't show toast - the disabled Next button already communicates this
|
||||
}
|
||||
}}
|
||||
>
|
||||
<fieldset disabled={form.formState.isSubmitting}>
|
||||
{step === 'SELECT' && (
|
||||
<>
|
||||
@@ -194,46 +245,102 @@ export const TeamMemberCreateDialog = ({ trigger, ...props }: TeamMemberCreateDi
|
||||
control={form.control}
|
||||
name="members"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormItem className="space-y-2">
|
||||
<FormLabel>
|
||||
<Trans>Members</Trans>
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<MultiSelectCombobox
|
||||
options={avaliableOrganisationMembers.map((member) => ({
|
||||
label: member.name,
|
||||
value: member.id,
|
||||
}))}
|
||||
loading={organisationMemberQuery.isLoading}
|
||||
selectedValues={field.value.map(
|
||||
(member) => member.organisationMemberId,
|
||||
)}
|
||||
onChange={(value) => {
|
||||
field.onChange(
|
||||
value.map((organisationMemberId) => ({
|
||||
organisationMemberId,
|
||||
teamRole:
|
||||
field.value.find(
|
||||
(member) =>
|
||||
member.organisationMemberId === organisationMemberId,
|
||||
)?.teamRole || TeamMemberRole.MEMBER,
|
||||
})),
|
||||
);
|
||||
}}
|
||||
className="bg-background w-full"
|
||||
emptySelectionPlaceholder={t`Select members`}
|
||||
/>
|
||||
{hasNoAvailableMembers ? (
|
||||
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed border-muted-foreground/25 bg-muted/30 px-6 py-12 text-center">
|
||||
<div className="mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-muted">
|
||||
<UserPlusIcon className="h-6 w-6 text-muted-foreground" />
|
||||
</div>
|
||||
<h3 className="mb-2 text-sm font-semibold">
|
||||
<Trans>No organisation members available</Trans>
|
||||
</h3>
|
||||
<p className="mb-6 max-w-sm text-sm text-muted-foreground">
|
||||
<Trans>
|
||||
To add members to this team, you must first add them to the
|
||||
organisation.
|
||||
</Trans>
|
||||
</p>
|
||||
<OrganisationMemberInviteDialog
|
||||
open={inviteDialogOpen}
|
||||
onOpenChange={setInviteDialogOpen}
|
||||
trigger={
|
||||
<Button type="button" variant="default">
|
||||
<UserPlusIcon className="mr-2 h-4 w-4" />
|
||||
<Trans>Invite organisation members</Trans>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<MultiSelectCombobox
|
||||
options={avaliableOrganisationMembers.map((member) => ({
|
||||
label: member.name,
|
||||
value: member.id,
|
||||
}))}
|
||||
loading={organisationMemberQuery.isLoading}
|
||||
selectedValues={field.value.map(
|
||||
(member) => member.organisationMemberId,
|
||||
)}
|
||||
onChange={(value) => {
|
||||
field.onChange(
|
||||
value.map((organisationMemberId) => ({
|
||||
organisationMemberId,
|
||||
teamRole:
|
||||
field.value.find(
|
||||
(member) =>
|
||||
member.organisationMemberId === organisationMemberId,
|
||||
)?.teamRole || TeamMemberRole.MEMBER,
|
||||
})),
|
||||
);
|
||||
}}
|
||||
className="w-full bg-background"
|
||||
emptySelectionPlaceholder={t`Select members`}
|
||||
/>
|
||||
)}
|
||||
</FormControl>
|
||||
|
||||
<FormDescription>
|
||||
<Trans>Select members to add to this team</Trans>
|
||||
</FormDescription>
|
||||
{!hasNoAvailableMembers && (
|
||||
<>
|
||||
<FormDescription>
|
||||
<Trans>Select members to add to this team</Trans>
|
||||
</FormDescription>
|
||||
|
||||
<Alert
|
||||
variant="neutral"
|
||||
className="mt-2 flex items-center gap-2 space-y-0"
|
||||
>
|
||||
<div>
|
||||
<UserPlusIcon className="h-5 w-5 text-muted-foreground" />
|
||||
</div>
|
||||
<AlertDescription className="mt-0 flex-1">
|
||||
<Trans>Can't find someone?</Trans>{' '}
|
||||
<OrganisationMemberInviteDialog
|
||||
open={inviteDialogOpen}
|
||||
onOpenChange={setInviteDialogOpen}
|
||||
trigger={
|
||||
<Button
|
||||
type="button"
|
||||
variant="link"
|
||||
className="h-auto p-0 text-sm font-medium text-documenso-700 hover:text-documenso-600"
|
||||
>
|
||||
<Trans>Invite them to the organisation first</Trans>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</>
|
||||
)}
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<DialogFooter>
|
||||
<DialogFooter className="mt-4">
|
||||
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
|
||||
@@ -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) ??
|
||||
|
||||
@@ -402,7 +402,7 @@ export const SignUpForm = ({
|
||||
size="lg"
|
||||
className="mt-6 w-full"
|
||||
>
|
||||
<Trans>Complete</Trans>
|
||||
<Trans>Create account</Trans>
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
|
||||
@@ -2,10 +2,13 @@ import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { Trans, useLingui } from '@lingui/react/macro';
|
||||
import type { SubscriptionClaim } from '@prisma/client';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { Link } from 'react-router';
|
||||
import type { z } from 'zod';
|
||||
|
||||
import type { TLicenseClaim } from '@documenso/lib/types/license';
|
||||
import { SUBSCRIPTION_CLAIM_FEATURE_FLAGS } from '@documenso/lib/types/subscription';
|
||||
import { ZCreateSubscriptionClaimRequestSchema } from '@documenso/trpc/server/admin-router/create-subscription-claim.types';
|
||||
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
|
||||
import { Checkbox } from '@documenso/ui/primitives/checkbox';
|
||||
import {
|
||||
Form,
|
||||
@@ -24,15 +27,22 @@ type SubscriptionClaimFormProps = {
|
||||
subscriptionClaim: Omit<SubscriptionClaim, 'id' | 'createdAt' | 'updatedAt'>;
|
||||
onFormSubmit: (data: SubscriptionClaimFormValues) => Promise<void>;
|
||||
formSubmitTrigger?: React.ReactNode;
|
||||
licenseFlags?: TLicenseClaim;
|
||||
};
|
||||
|
||||
export const SubscriptionClaimForm = ({
|
||||
subscriptionClaim,
|
||||
onFormSubmit,
|
||||
formSubmitTrigger,
|
||||
licenseFlags,
|
||||
}: SubscriptionClaimFormProps) => {
|
||||
const { t } = useLingui();
|
||||
|
||||
const hasRestrictedEnterpriseFeatures = Object.values(SUBSCRIPTION_CLAIM_FEATURE_FLAGS).some(
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
(flag) => flag.isEnterprise && !licenseFlags?.[flag.key as keyof TLicenseClaim],
|
||||
);
|
||||
|
||||
const form = useForm<SubscriptionClaimFormValues>({
|
||||
resolver: zodResolver(ZCreateSubscriptionClaimRequestSchema),
|
||||
defaultValues: {
|
||||
@@ -142,34 +152,59 @@ export const SubscriptionClaimForm = ({
|
||||
</FormLabel>
|
||||
|
||||
<div className="mt-2 space-y-2 rounded-md border p-4">
|
||||
{Object.values(SUBSCRIPTION_CLAIM_FEATURE_FLAGS).map(({ key, label }) => (
|
||||
<FormField
|
||||
key={key}
|
||||
control={form.control}
|
||||
name={`flags.${key}`}
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex items-center space-x-2">
|
||||
<FormControl>
|
||||
<div className="flex items-center">
|
||||
<Checkbox
|
||||
id={`flag-${key}`}
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
{Object.values(SUBSCRIPTION_CLAIM_FEATURE_FLAGS).map(
|
||||
({ key, label, isEnterprise }) => {
|
||||
const isRestrictedFeature =
|
||||
isEnterprise && !licenseFlags?.[key as keyof TLicenseClaim]; // eslint-disable-line @typescript-eslint/consistent-type-assertions
|
||||
|
||||
<label
|
||||
className="text-muted-foreground ml-2 flex flex-row items-center text-sm"
|
||||
htmlFor={`flag-${key}`}
|
||||
>
|
||||
{label}
|
||||
</label>
|
||||
</div>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
return (
|
||||
<FormField
|
||||
key={key}
|
||||
control={form.control}
|
||||
name={`flags.${key}`}
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex items-center space-x-2">
|
||||
<FormControl>
|
||||
<div className="flex items-center">
|
||||
<Checkbox
|
||||
id={`flag-${key}`}
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
disabled={isRestrictedFeature && !field.value} // Allow disabling of restricted features.
|
||||
/>
|
||||
|
||||
<label
|
||||
className="ml-2 flex flex-row items-center text-sm text-muted-foreground"
|
||||
htmlFor={`flag-${key}`}
|
||||
>
|
||||
{label}
|
||||
{isRestrictedFeature && ' ¹'}
|
||||
</label>
|
||||
</div>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
|
||||
{hasRestrictedEnterpriseFeatures && (
|
||||
<Alert variant="neutral" className="mt-4">
|
||||
<AlertDescription>
|
||||
<span>¹ </span>
|
||||
<Trans>Your current license does not include these features.</Trans>{' '}
|
||||
<Link
|
||||
to="https://docs.documenso.com/users/licenses/enterprise-edition"
|
||||
target="_blank"
|
||||
className="text-foreground underline hover:opacity-80"
|
||||
>
|
||||
<Trans>Learn more</Trans>
|
||||
</Link>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{formSubmitTrigger}
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
import { Trans, useLingui } from '@lingui/react/macro';
|
||||
import {
|
||||
ArrowRightIcon,
|
||||
CheckCircle2Icon,
|
||||
KeyRoundIcon,
|
||||
Loader2Icon,
|
||||
RefreshCwIcon,
|
||||
XCircleIcon,
|
||||
} from 'lucide-react';
|
||||
import { DateTime } from 'luxon';
|
||||
import { Link, useRevalidator } from 'react-router';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import type { TCachedLicense } from '@documenso/lib/types/license';
|
||||
import { SUBSCRIPTION_CLAIM_FEATURE_FLAGS } from '@documenso/lib/types/subscription';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { Badge } from '@documenso/ui/primitives/badge';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@documenso/ui/primitives/tooltip';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { CardMetric } from './metric-card';
|
||||
|
||||
type AdminLicenseCardProps = {
|
||||
licenseData: TCachedLicense | null;
|
||||
};
|
||||
|
||||
export const AdminLicenseCard = ({ licenseData }: AdminLicenseCardProps) => {
|
||||
const { t, i18n } = useLingui();
|
||||
|
||||
const { license } = licenseData || {};
|
||||
|
||||
if (!license) {
|
||||
return (
|
||||
<div className="relative">
|
||||
<div className="absolute right-3 top-3 z-10">
|
||||
<AdminLicenseResyncButton />
|
||||
</div>
|
||||
<CardMetric icon={KeyRoundIcon} title={t`License`} className="h-fit max-h-fit">
|
||||
<div className="mt-1 flex items-center justify-center gap-2">
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg border border-dashed border-muted-foreground/30 bg-muted/50">
|
||||
<KeyRoundIcon className="h-5 w-5 text-muted-foreground/50" />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{licenseData?.requestedLicenseKey ? (
|
||||
<>
|
||||
<p className="text-sm font-medium text-destructive">
|
||||
<Trans>Invalid License Key</Trans>
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">{licenseData.requestedLicenseKey}</p>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-sm font-medium text-muted-foreground">
|
||||
<Trans>No License Configured</Trans>
|
||||
</p>
|
||||
)}
|
||||
|
||||
<Link
|
||||
to="https://docs.documenso.com/users/licenses/enterprise-edition"
|
||||
target="_blank"
|
||||
className="flex flex-row items-center text-xs text-muted-foreground hover:text-muted-foreground/80"
|
||||
>
|
||||
<Trans>Learn more</Trans> <ArrowRightIcon className="h-3 w-3" />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</CardMetric>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const enabledFlags = Object.entries(license.flags).filter(([, enabled]) => enabled);
|
||||
|
||||
return (
|
||||
<div className="relative max-w-full overflow-hidden rounded-lg border border-border bg-background px-4 pb-6 pt-4 shadow shadow-transparent duration-200 hover:shadow-border/80">
|
||||
<div className="absolute right-3 top-3">
|
||||
<AdminLicenseResyncButton />
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-2">
|
||||
<div className="h-4 w-4">
|
||||
<KeyRoundIcon className="h-4 w-4 text-muted-foreground" />
|
||||
</div>
|
||||
|
||||
<h3 className="text-primary-forground mb-2 flex items-end text-sm font-medium leading-tight">
|
||||
<Trans>Documenso License</Trans>
|
||||
</h3>
|
||||
|
||||
{match(license.status)
|
||||
.with('ACTIVE', () => (
|
||||
<Badge variant="default" size="small">
|
||||
<CheckCircle2Icon className="mr-1 h-3 w-3" />
|
||||
<Trans context="Subscription status">Active</Trans>
|
||||
</Badge>
|
||||
))
|
||||
.with('PAST_DUE', () => (
|
||||
<Badge variant="warning" size="small">
|
||||
<XCircleIcon className="mr-1 h-3 w-3" />
|
||||
<Trans context="Subscription status">Past Due</Trans>
|
||||
</Badge>
|
||||
))
|
||||
.with('EXPIRED', () => (
|
||||
<Badge variant="destructive" size="small">
|
||||
<XCircleIcon className="mr-1 h-3 w-3" />
|
||||
<Trans context="Subscription status">Expired</Trans>
|
||||
</Badge>
|
||||
))
|
||||
.otherwise(() => null)}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
<Trans>License</Trans>
|
||||
</p>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">{license.name}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
<Trans>Expires</Trans>
|
||||
</p>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||
{i18n.date(license.periodEnd, DateTime.DATE_MED)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
<Trans>License Key</Trans>
|
||||
</p>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">{license.licenseKey}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
<Trans>Features</Trans>
|
||||
</p>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||
{enabledFlags.length > 0 ? (
|
||||
enabledFlags
|
||||
.map(
|
||||
([flag]) =>
|
||||
SUBSCRIPTION_CLAIM_FEATURE_FLAGS[
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
flag as keyof typeof SUBSCRIPTION_CLAIM_FEATURE_FLAGS
|
||||
]?.label || flag,
|
||||
)
|
||||
.join(', ')
|
||||
) : (
|
||||
<Trans>No features enabled</Trans>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const AdminLicenseResyncButton = () => {
|
||||
const { t } = useLingui();
|
||||
const { toast } = useToast();
|
||||
const { revalidate } = useRevalidator();
|
||||
|
||||
const { mutate: resyncLicense, isPending: isResyncingLicense } =
|
||||
trpc.admin.license.resync.useMutation({
|
||||
onSuccess: async () => {
|
||||
toast({
|
||||
title: t`License synced`,
|
||||
});
|
||||
|
||||
await revalidate();
|
||||
},
|
||||
onError: () => {
|
||||
toast({
|
||||
title: t`Failed to sync license`,
|
||||
variant: 'destructive',
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0"
|
||||
disabled={isResyncingLicense}
|
||||
onClick={() => resyncLicense()}
|
||||
>
|
||||
{isResyncingLicense ? (
|
||||
<Loader2Icon className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<RefreshCwIcon className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<Trans>Sync license from server</Trans>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,78 @@
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { AlertTriangleIcon, KeyRoundIcon } from 'lucide-react';
|
||||
import { Link } from 'react-router';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import type { TCachedLicense } from '@documenso/lib/types/license';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
|
||||
export type AdminLicenseStatusBannerProps = {
|
||||
license: TCachedLicense | null;
|
||||
};
|
||||
|
||||
export const AdminLicenseStatusBanner = ({ license }: AdminLicenseStatusBannerProps) => {
|
||||
const licenseStatus = license?.derivedStatus;
|
||||
|
||||
if (!license || licenseStatus === 'ACTIVE' || licenseStatus === 'NOT_FOUND') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn('mb-8 rounded-lg bg-yellow-200 text-yellow-900 dark:bg-yellow-400', {
|
||||
'bg-destructive text-destructive-foreground':
|
||||
licenseStatus === 'EXPIRED' || licenseStatus === 'UNAUTHORIZED',
|
||||
})}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-x-4 px-4 py-3 text-sm font-medium">
|
||||
<div className="flex items-center">
|
||||
<AlertTriangleIcon className="mr-2.5 h-5 w-5" />
|
||||
|
||||
{match(licenseStatus)
|
||||
.with('PAST_DUE', () => (
|
||||
<Trans>
|
||||
License Payment Overdue - Please update your payment to avoid service disruptions.
|
||||
</Trans>
|
||||
))
|
||||
.with('EXPIRED', () => (
|
||||
<Trans>
|
||||
License Expired - Please renew your license to continue using enterprise features.
|
||||
</Trans>
|
||||
))
|
||||
.with('UNAUTHORIZED', () =>
|
||||
license ? (
|
||||
<Trans>
|
||||
Invalid License Type - Your Documenso instance is using features that are not part
|
||||
of your license.
|
||||
</Trans>
|
||||
) : (
|
||||
<Trans>
|
||||
Missing License - Your Documenso instance is using features that require a
|
||||
license.
|
||||
</Trans>
|
||||
),
|
||||
)
|
||||
.otherwise(() => null)}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className={cn({
|
||||
'border-yellow-900/30 text-yellow-900 hover:bg-yellow-100 dark:hover:bg-yellow-500':
|
||||
licenseStatus === 'PAST_DUE',
|
||||
'border-destructive-foreground/30 text-destructive-foreground hover:bg-destructive/80':
|
||||
licenseStatus === 'EXPIRED' || licenseStatus === 'UNAUTHORIZED',
|
||||
})}
|
||||
asChild
|
||||
>
|
||||
<Link to="https://docs.documenso.com/users/licenses/enterprise-edition" target="_blank">
|
||||
<KeyRoundIcon className="mr-1.5 h-4 w-4" />
|
||||
<Trans>See Documentation</Trans>
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,10 +1,13 @@
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans, useLingui as useLinguiMacro } from '@lingui/react/macro';
|
||||
import { RecipientRole } from '@prisma/client';
|
||||
|
||||
import type { TDefaultRecipient } from '@documenso/lib/types/default-recipients';
|
||||
import { isRecipientEmailValidForSending } from '@documenso/lib/utils/recipients';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { MultiSelect, type Option } from '@documenso/ui/primitives/multiselect';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
type DefaultRecipientsMultiSelectComboboxProps = {
|
||||
listValues: TDefaultRecipient[];
|
||||
@@ -20,6 +23,8 @@ export const DefaultRecipientsMultiSelectCombobox = ({
|
||||
organisationId,
|
||||
}: DefaultRecipientsMultiSelectComboboxProps) => {
|
||||
const { _ } = useLingui();
|
||||
const { t } = useLinguiMacro();
|
||||
const { toast } = useToast();
|
||||
|
||||
const { data: organisationData, isLoading: isLoadingOrganisation } =
|
||||
trpc.organisation.member.find.useQuery(
|
||||
@@ -60,31 +65,56 @@ export const DefaultRecipientsMultiSelectCombobox = ({
|
||||
}));
|
||||
|
||||
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);
|
||||
const invalidEmails = selected.filter(
|
||||
(option) => !isRecipientEmailValidForSending({ email: option.value }),
|
||||
);
|
||||
|
||||
return {
|
||||
email: option.value,
|
||||
name: member?.name || option.value,
|
||||
role: existingRecipient?.role ?? RecipientRole.CC,
|
||||
};
|
||||
});
|
||||
if (invalidEmails.length > 0) {
|
||||
toast({
|
||||
title: t`Invalid email`,
|
||||
description: t`"${invalidEmails[0].value}" is not a valid email address.`,
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
|
||||
const updatedRecipients = selected
|
||||
.filter((option) => !invalidEmails.includes(option))
|
||||
.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`) }}
|
||||
commandProps={{ label: _(msg`Select or add recipients`) }}
|
||||
options={options}
|
||||
value={value}
|
||||
onChange={onSelectionChange}
|
||||
placeholder={_(msg`Select recipients`)}
|
||||
placeholder={_(msg`Select or enter email address`)}
|
||||
hideClearAllButton
|
||||
hidePlaceholderWhenSelected
|
||||
loadingIndicator={isLoading ? <p className="text-center text-sm">Loading...</p> : undefined}
|
||||
emptyIndicator={<p className="text-center text-sm">No members found</p>}
|
||||
creatable
|
||||
loadingIndicator={
|
||||
isLoading ? (
|
||||
<p className="text-center text-sm">
|
||||
<Trans>Loading...</Trans>
|
||||
</p>
|
||||
) : undefined
|
||||
}
|
||||
emptyIndicator={
|
||||
<p className="text-center text-sm">
|
||||
<Trans>Type an email address to add a recipient</Trans>
|
||||
</p>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -3,6 +3,8 @@ import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { DownloadIcon } from 'lucide-react';
|
||||
|
||||
import { downloadFile } from '@documenso/lib/client-only/download-file';
|
||||
import { base64 } from '@documenso/lib/universal/base64';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
@@ -25,36 +27,15 @@ export const DocumentAuditLogDownloadButton = ({
|
||||
|
||||
const onDownloadAuditLogsClick = async () => {
|
||||
try {
|
||||
const { url } = await downloadAuditLogs({ documentId });
|
||||
const { data, envelopeTitle } = await downloadAuditLogs({ documentId });
|
||||
|
||||
const iframe = Object.assign(document.createElement('iframe'), {
|
||||
src: url,
|
||||
const buffer = new Uint8Array(base64.decode(data));
|
||||
const blob = new Blob([buffer], { type: 'application/pdf' });
|
||||
|
||||
downloadFile({
|
||||
data: blob,
|
||||
filename: `${envelopeTitle} - Audit Logs.pdf`,
|
||||
});
|
||||
|
||||
Object.assign(iframe.style, {
|
||||
position: 'fixed',
|
||||
top: '0',
|
||||
left: '0',
|
||||
width: '0',
|
||||
height: '0',
|
||||
});
|
||||
|
||||
const onLoaded = () => {
|
||||
if (iframe.contentDocument?.readyState === 'complete') {
|
||||
iframe.contentWindow?.print();
|
||||
|
||||
iframe.contentWindow?.addEventListener('afterprint', () => {
|
||||
document.body.removeChild(iframe);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// When the iframe has loaded, print the iframe and remove it from the dom
|
||||
iframe.addEventListener('load', onLoaded);
|
||||
|
||||
document.body.appendChild(iframe);
|
||||
|
||||
onLoaded();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
|
||||
|
||||
+9
-28
@@ -4,6 +4,8 @@ import { Trans } from '@lingui/react/macro';
|
||||
import type { DocumentStatus } from '@prisma/client';
|
||||
import { DownloadIcon } from 'lucide-react';
|
||||
|
||||
import { downloadFile } from '@documenso/lib/client-only/download-file';
|
||||
import { base64 } from '@documenso/lib/universal/base64';
|
||||
import { isDocumentCompleted } from '@documenso/lib/utils/document';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
@@ -29,36 +31,15 @@ export const DocumentCertificateDownloadButton = ({
|
||||
|
||||
const onDownloadCertificatesClick = async () => {
|
||||
try {
|
||||
const { url } = await downloadCertificate({ documentId });
|
||||
const { data, envelopeTitle } = await downloadCertificate({ documentId });
|
||||
|
||||
const iframe = Object.assign(document.createElement('iframe'), {
|
||||
src: url,
|
||||
const buffer = new Uint8Array(base64.decode(data));
|
||||
const blob = new Blob([buffer], { type: 'application/pdf' });
|
||||
|
||||
downloadFile({
|
||||
data: blob,
|
||||
filename: `${envelopeTitle} - Certificate.pdf`,
|
||||
});
|
||||
|
||||
Object.assign(iframe.style, {
|
||||
position: 'fixed',
|
||||
top: '0',
|
||||
left: '0',
|
||||
width: '0',
|
||||
height: '0',
|
||||
});
|
||||
|
||||
const onLoaded = () => {
|
||||
if (iframe.contentDocument?.readyState === 'complete') {
|
||||
iframe.contentWindow?.print();
|
||||
|
||||
iframe.contentWindow?.addEventListener('afterprint', () => {
|
||||
document.body.removeChild(iframe);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// When the iframe has loaded, print the iframe and remove it from the dom
|
||||
iframe.addEventListener('load', onLoaded);
|
||||
|
||||
document.body.appendChild(iframe);
|
||||
|
||||
onLoaded();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
|
||||
|
||||
+242
-286
@@ -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;
|
||||
@@ -652,8 +594,12 @@ export const EnvelopeEditorRecipientForm = () => {
|
||||
<Card backdropBlur={false} className="border">
|
||||
<CardHeader className="flex flex-row justify-between">
|
||||
<div>
|
||||
<CardTitle>Recipients</CardTitle>
|
||||
<CardDescription className="mt-1.5">Add recipients to your document</CardDescription>
|
||||
<CardTitle>
|
||||
<Trans>Recipients</Trans>
|
||||
</CardTitle>
|
||||
<CardDescription className="mt-1.5">
|
||||
<Trans>Add recipients to your document</Trans>
|
||||
</CardDescription>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-row items-center space-x-2">
|
||||
@@ -848,246 +794,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 +1005,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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -5,15 +5,16 @@ import { cn } from '@documenso/ui/lib/utils';
|
||||
export type CardMetricProps = {
|
||||
icon?: LucideIcon;
|
||||
title: string;
|
||||
value: string | number;
|
||||
value?: string | number;
|
||||
className?: string;
|
||||
children?: React.ReactNode;
|
||||
};
|
||||
|
||||
export const CardMetric = ({ icon: Icon, title, value, className }: CardMetricProps) => {
|
||||
export const CardMetric = ({ icon: Icon, title, value, className, children }: CardMetricProps) => {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'border-border bg-background hover:shadow-border/80 h-32 max-h-32 max-w-full overflow-hidden rounded-lg border shadow shadow-transparent duration-200',
|
||||
'h-32 max-h-32 max-w-full overflow-hidden rounded-lg border border-border bg-background shadow shadow-transparent duration-200 hover:shadow-border/80',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
@@ -21,7 +22,7 @@ export const CardMetric = ({ icon: Icon, title, value, className }: CardMetricPr
|
||||
<div className="flex items-start">
|
||||
{Icon && (
|
||||
<div className="mr-2 h-4 w-4">
|
||||
<Icon className="text-muted-foreground h-4 w-4" />
|
||||
<Icon className="h-4 w-4 text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -30,9 +31,11 @@ export const CardMetric = ({ icon: Icon, title, value, className }: CardMetricPr
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<p className="text-foreground mt-auto text-4xl font-semibold leading-8">
|
||||
{typeof value === 'number' ? value.toLocaleString('en-US') : value}
|
||||
</p>
|
||||
{children || (
|
||||
<p className="mt-auto text-4xl font-semibold leading-8 text-foreground">
|
||||
{typeof value === 'number' ? value.toLocaleString('en-US') : value}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -6,6 +6,7 @@ import { EditIcon, MoreHorizontalIcon, Trash2Icon } from 'lucide-react';
|
||||
import { Link, useSearchParams } from 'react-router';
|
||||
|
||||
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
|
||||
import type { TLicenseClaim } from '@documenso/lib/types/license';
|
||||
import { ZUrlSearchParamsSchema } from '@documenso/lib/types/search-params';
|
||||
import { SUBSCRIPTION_CLAIM_FEATURE_FLAGS } from '@documenso/lib/types/subscription';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
@@ -27,7 +28,11 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
import { ClaimDeleteDialog } from '../dialogs/claim-delete-dialog';
|
||||
import { ClaimUpdateDialog } from '../dialogs/claim-update-dialog';
|
||||
|
||||
export const AdminClaimsTable = () => {
|
||||
type AdminClaimsTableProps = {
|
||||
licenseFlags?: TLicenseClaim;
|
||||
};
|
||||
|
||||
export const AdminClaimsTable = ({ licenseFlags }: AdminClaimsTableProps) => {
|
||||
const { t } = useLingui();
|
||||
const { toast } = useToast();
|
||||
|
||||
@@ -97,11 +102,11 @@ export const AdminClaimsTable = () => {
|
||||
);
|
||||
|
||||
if (flags.length === 0) {
|
||||
return <p className="text-muted-foreground text-xs">{t`None`}</p>;
|
||||
return <p className="text-xs text-muted-foreground">{t`None`}</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<ul className="text-muted-foreground list-disc space-y-1 text-xs">
|
||||
<ul className="list-disc space-y-1 text-xs text-muted-foreground">
|
||||
{flags.map(({ key, label }) => (
|
||||
<li key={key}>{label}</li>
|
||||
))}
|
||||
@@ -114,7 +119,7 @@ export const AdminClaimsTable = () => {
|
||||
cell: ({ row }) => (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger>
|
||||
<MoreHorizontalIcon className="text-muted-foreground h-5 w-5" />
|
||||
<MoreHorizontalIcon className="h-5 w-5 text-muted-foreground" />
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent className="w-52" align="start" forceMount>
|
||||
@@ -124,6 +129,7 @@ export const AdminClaimsTable = () => {
|
||||
|
||||
<ClaimUpdateDialog
|
||||
claim={row.original}
|
||||
licenseFlags={licenseFlags}
|
||||
trigger={
|
||||
<DropdownMenuItem asChild onSelect={(e) => e.preventDefault()}>
|
||||
<div>
|
||||
|
||||
@@ -12,7 +12,8 @@ import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||
import { isDocumentCompleted } from '@documenso/lib/utils/document';
|
||||
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
||||
import type { TFindDocumentsResponse } from '@documenso/trpc/server/document-router/find-documents.types';
|
||||
import type { DataTableColumnDef } from '@documenso/ui/primitives/data-table';
|
||||
import { Checkbox } from '@documenso/ui/primitives/checkbox';
|
||||
import type { DataTableColumnDef, RowSelectionState } from '@documenso/ui/primitives/data-table';
|
||||
import { DataTable } from '@documenso/ui/primitives/data-table';
|
||||
import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
|
||||
import { Skeleton } from '@documenso/ui/primitives/skeleton';
|
||||
@@ -30,6 +31,9 @@ export type DocumentsTableProps = {
|
||||
isLoading?: boolean;
|
||||
isLoadingError?: boolean;
|
||||
onMoveDocument?: (documentId: number) => void;
|
||||
enableSelection?: boolean;
|
||||
rowSelection?: RowSelectionState;
|
||||
onRowSelectionChange?: (selection: RowSelectionState) => void;
|
||||
};
|
||||
|
||||
type DocumentsTableRow = TFindDocumentsResponse['data'][number];
|
||||
@@ -39,6 +43,9 @@ export const DocumentsTable = ({
|
||||
isLoading,
|
||||
isLoadingError,
|
||||
onMoveDocument,
|
||||
enableSelection,
|
||||
rowSelection,
|
||||
onRowSelectionChange,
|
||||
}: DocumentsTableProps) => {
|
||||
const { _, i18n } = useLingui();
|
||||
|
||||
@@ -48,7 +55,34 @@ export const DocumentsTable = ({
|
||||
const updateSearchParams = useUpdateSearchParams();
|
||||
|
||||
const columns = useMemo(() => {
|
||||
return [
|
||||
const cols: DataTableColumnDef<DocumentsTableRow>[] = [];
|
||||
|
||||
if (enableSelection) {
|
||||
cols.push({
|
||||
id: 'select',
|
||||
header: ({ table }) => (
|
||||
<Checkbox
|
||||
checked={table.getIsAllPageRowsSelected()}
|
||||
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
|
||||
aria-label={_(msg`Select all`)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<Checkbox
|
||||
checked={row.getIsSelected()}
|
||||
onCheckedChange={(value) => row.toggleSelected(!!value)}
|
||||
aria-label={_(msg`Select row`)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
),
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
size: 40,
|
||||
});
|
||||
}
|
||||
|
||||
cols.push(
|
||||
{
|
||||
header: _(msg`Created`),
|
||||
accessorKey: 'createdAt',
|
||||
@@ -93,8 +127,10 @@ export const DocumentsTable = ({
|
||||
</div>
|
||||
),
|
||||
},
|
||||
] satisfies DataTableColumnDef<DocumentsTableRow>[];
|
||||
}, [team, onMoveDocument]);
|
||||
);
|
||||
|
||||
return cols;
|
||||
}, [team, onMoveDocument, enableSelection]);
|
||||
|
||||
const onPaginationChange = (page: number, perPage: number) => {
|
||||
startTransition(() => {
|
||||
@@ -132,6 +168,11 @@ export const DocumentsTable = ({
|
||||
rows: 5,
|
||||
component: (
|
||||
<>
|
||||
{enableSelection && (
|
||||
<TableCell>
|
||||
<Skeleton className="h-4 w-4 rounded" />
|
||||
</TableCell>
|
||||
)}
|
||||
<TableCell>
|
||||
<Skeleton className="h-4 w-40 rounded-full" />
|
||||
</TableCell>
|
||||
@@ -152,13 +193,17 @@ export const DocumentsTable = ({
|
||||
</>
|
||||
),
|
||||
}}
|
||||
enableRowSelection={enableSelection}
|
||||
rowSelection={rowSelection}
|
||||
onRowSelectionChange={onRowSelectionChange}
|
||||
getRowId={(row) => row.envelopeId}
|
||||
>
|
||||
{(table) => <DataTablePagination additionalInformation="VisibleCount" table={table} />}
|
||||
</DataTable>
|
||||
|
||||
{isPending && (
|
||||
<div className="bg-background/50 absolute inset-0 flex items-center justify-center">
|
||||
<Loader className="text-muted-foreground h-8 w-8 animate-spin" />
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-background/50">
|
||||
<Loader className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { FolderInputIcon, Trash2Icon, XIcon } from 'lucide-react';
|
||||
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
|
||||
export type EnvelopesTableBulkActionBarProps = {
|
||||
selectedCount: number;
|
||||
onMoveClick: () => void;
|
||||
onDeleteClick: () => void;
|
||||
onClearSelection: () => void;
|
||||
};
|
||||
|
||||
export const EnvelopesTableBulkActionBar = ({
|
||||
selectedCount,
|
||||
onMoveClick,
|
||||
onDeleteClick,
|
||||
onClearSelection,
|
||||
}: EnvelopesTableBulkActionBarProps) => {
|
||||
const { t } = useLingui();
|
||||
|
||||
if (selectedCount === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-4 left-1/2 z-50 flex -translate-x-1/2 items-center gap-x-4 rounded-lg border border-border bg-widget px-4 py-3 shadow-lg">
|
||||
<span className="text-sm font-medium">
|
||||
<Trans>{selectedCount} selected</Trans>
|
||||
</span>
|
||||
|
||||
<div className="h-6 w-px bg-border" />
|
||||
|
||||
<Button type="button" variant="outline" size="sm" onClick={onMoveClick}>
|
||||
<FolderInputIcon className="mr-2 h-4 w-4" />
|
||||
<Trans>Move to Folder</Trans>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onDeleteClick}
|
||||
className="text-destructive hover:text-destructive"
|
||||
>
|
||||
<Trash2Icon className="mr-2 h-4 w-4" />
|
||||
<Trans>Delete</Trans>
|
||||
</Button>
|
||||
|
||||
<Button variant="ghost" size="sm" onClick={onClearSelection} aria-label={t`Clear selection`}>
|
||||
<XIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -12,7 +12,8 @@ import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/org
|
||||
import { formatTemplatesPath } from '@documenso/lib/utils/teams';
|
||||
import type { TFindTemplatesResponse } from '@documenso/trpc/server/template-router/schema';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
|
||||
import type { DataTableColumnDef } from '@documenso/ui/primitives/data-table';
|
||||
import { Checkbox } from '@documenso/ui/primitives/checkbox';
|
||||
import type { DataTableColumnDef, RowSelectionState } from '@documenso/ui/primitives/data-table';
|
||||
import { DataTable } from '@documenso/ui/primitives/data-table';
|
||||
import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
|
||||
import { Skeleton } from '@documenso/ui/primitives/skeleton';
|
||||
@@ -32,6 +33,9 @@ type TemplatesTableProps = {
|
||||
isLoadingError?: boolean;
|
||||
documentRootPath: string;
|
||||
templateRootPath: string;
|
||||
enableSelection?: boolean;
|
||||
rowSelection?: RowSelectionState;
|
||||
onRowSelectionChange?: (selection: RowSelectionState) => void;
|
||||
};
|
||||
|
||||
type TemplatesTableRow = TFindTemplatesResponse['data'][number];
|
||||
@@ -42,6 +46,9 @@ export const TemplatesTable = ({
|
||||
isLoadingError,
|
||||
documentRootPath,
|
||||
templateRootPath,
|
||||
enableSelection,
|
||||
rowSelection,
|
||||
onRowSelectionChange,
|
||||
}: TemplatesTableProps) => {
|
||||
const { _, i18n } = useLingui();
|
||||
const { remaining } = useLimits();
|
||||
@@ -60,7 +67,34 @@ export const TemplatesTable = ({
|
||||
};
|
||||
|
||||
const columns = useMemo(() => {
|
||||
return [
|
||||
const cols: DataTableColumnDef<TemplatesTableRow>[] = [];
|
||||
|
||||
if (enableSelection) {
|
||||
cols.push({
|
||||
id: 'select',
|
||||
header: ({ table }) => (
|
||||
<Checkbox
|
||||
checked={table.getIsAllPageRowsSelected()}
|
||||
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
|
||||
aria-label={_(msg`Select all`)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<Checkbox
|
||||
checked={row.getIsSelected()}
|
||||
onCheckedChange={(value) => row.toggleSelected(!!value)}
|
||||
aria-label={_(msg`Select row`)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
),
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
size: 40,
|
||||
});
|
||||
}
|
||||
|
||||
cols.push(
|
||||
{
|
||||
header: _(msg`Created`),
|
||||
accessorKey: 'createdAt',
|
||||
@@ -86,8 +120,8 @@ export const TemplatesTable = ({
|
||||
<InfoIcon className="mx-2 h-4 w-4" />
|
||||
</TooltipTrigger>
|
||||
|
||||
<TooltipContent className="text-foreground max-w-md space-y-2 !p-0">
|
||||
<ul className="text-muted-foreground space-y-0.5 divide-y [&>li]:p-4">
|
||||
<TooltipContent className="max-w-md space-y-2 !p-0 text-foreground">
|
||||
<ul className="space-y-0.5 divide-y text-muted-foreground [&>li]:p-4">
|
||||
<li>
|
||||
<h2 className="mb-2 flex flex-row items-center font-semibold">
|
||||
<Globe2Icon className="mr-2 h-5 w-5 text-green-500 dark:text-green-300" />
|
||||
@@ -176,8 +210,10 @@ export const TemplatesTable = ({
|
||||
);
|
||||
},
|
||||
},
|
||||
] satisfies DataTableColumnDef<TemplatesTableRow>[];
|
||||
}, [documentRootPath, team?.id, templateRootPath]);
|
||||
);
|
||||
|
||||
return cols;
|
||||
}, [documentRootPath, team?.id, templateRootPath, enableSelection]);
|
||||
|
||||
const onPaginationChange = (page: number, perPage: number) => {
|
||||
startTransition(() => {
|
||||
@@ -224,6 +260,10 @@ export const TemplatesTable = ({
|
||||
currentPage={results.currentPage}
|
||||
totalPages={results.totalPages}
|
||||
onPaginationChange={onPaginationChange}
|
||||
enableRowSelection={enableSelection}
|
||||
rowSelection={rowSelection}
|
||||
onRowSelectionChange={onRowSelectionChange}
|
||||
getRowId={(row) => row.envelopeId}
|
||||
error={{
|
||||
enable: isLoadingError || false,
|
||||
}}
|
||||
@@ -232,6 +272,11 @@ export const TemplatesTable = ({
|
||||
rows: 5,
|
||||
component: (
|
||||
<>
|
||||
{enableSelection && (
|
||||
<TableCell className="w-10">
|
||||
<Skeleton className="h-4 w-4 rounded" />
|
||||
</TableCell>
|
||||
)}
|
||||
<TableCell>
|
||||
<Skeleton className="h-4 w-40 rounded-full" />
|
||||
</TableCell>
|
||||
|
||||
+12
-3
@@ -7,7 +7,6 @@ import {
|
||||
data,
|
||||
isRouteErrorResponse,
|
||||
useLoaderData,
|
||||
useLocation,
|
||||
} from 'react-router';
|
||||
import { PreventFlashOnWrongTheme, ThemeProvider, useTheme } from 'remix-themes';
|
||||
|
||||
@@ -87,8 +86,6 @@ export async function loader({ request }: Route.LoaderArgs) {
|
||||
export function Layout({ children }: { children: React.ReactNode }) {
|
||||
const { theme } = useLoaderData<typeof loader>() || {};
|
||||
|
||||
const location = useLocation();
|
||||
|
||||
return (
|
||||
<ThemeProvider specifiedTheme={theme} themeAction="/api/theme">
|
||||
<LayoutContent>{children}</LayoutContent>
|
||||
@@ -129,6 +126,18 @@ export function LayoutContent({ children }: { children: React.ReactNode }) {
|
||||
<script>0</script>
|
||||
</head>
|
||||
<body>
|
||||
{/* Global license banner currently disabled. Need to wait until after a few releases. */}
|
||||
{/* {licenseStatus === '?' && (
|
||||
<div className="bg-destructive text-destructive-foreground">
|
||||
<div className="mx-auto flex h-auto max-w-screen-xl items-center justify-center px-4 py-3 text-sm font-medium">
|
||||
<div className="flex items-center">
|
||||
<AlertTriangleIcon className="mr-2 h-4 w-4" />
|
||||
<Trans>This is an expired license instance of Documenso</Trans>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)} */}
|
||||
|
||||
<SessionProvider initialSession={session}>
|
||||
<TooltipProvider>
|
||||
<TrpcProvider>
|
||||
|
||||
@@ -11,25 +11,37 @@ import {
|
||||
import { Link, Outlet, redirect, useLocation } from 'react-router';
|
||||
|
||||
import { getSession } from '@documenso/auth/server/lib/utils/get-session';
|
||||
import { LicenseClient } from '@documenso/lib/server-only/license/license-client';
|
||||
import { isAdmin } from '@documenso/lib/utils/is-admin';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
|
||||
import { AdminLicenseStatusBanner } from '~/components/general/admin-license-status-banner';
|
||||
|
||||
import type { Route } from './+types/_layout';
|
||||
|
||||
export async function loader({ request }: Route.LoaderArgs) {
|
||||
const { user } = await getSession(request);
|
||||
|
||||
const license = await LicenseClient.getInstance()?.getCachedLicense();
|
||||
|
||||
if (!user || !isAdmin(user)) {
|
||||
throw redirect('/');
|
||||
}
|
||||
|
||||
return {
|
||||
license: license || null,
|
||||
};
|
||||
}
|
||||
|
||||
export default function AdminLayout() {
|
||||
export default function AdminLayout({ loaderData }: Route.ComponentProps) {
|
||||
const { license } = loaderData;
|
||||
const { pathname } = useLocation();
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-screen-xl px-4 md:px-8">
|
||||
<AdminLicenseStatusBanner license={license} />
|
||||
|
||||
<h1 className="text-4xl font-semibold">
|
||||
<Trans>Admin Panel</Trans>
|
||||
</h1>
|
||||
|
||||
@@ -4,13 +4,26 @@ import { useLingui } from '@lingui/react/macro';
|
||||
import { useLocation, useSearchParams } from 'react-router';
|
||||
|
||||
import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value';
|
||||
import { LicenseClient } from '@documenso/lib/server-only/license/license-client';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
|
||||
import { ClaimCreateDialog } from '~/components/dialogs/claim-create-dialog';
|
||||
import { SettingsHeader } from '~/components/general/settings-header';
|
||||
import { AdminClaimsTable } from '~/components/tables/admin-claims-table';
|
||||
|
||||
export default function Claims() {
|
||||
import type { Route } from './+types/claims';
|
||||
|
||||
export async function loader() {
|
||||
const licenseData = await LicenseClient.getInstance()?.getCachedLicense();
|
||||
|
||||
return {
|
||||
licenseFlags: licenseData?.license?.flags,
|
||||
};
|
||||
}
|
||||
|
||||
export default function Claims({ loaderData }: Route.ComponentProps) {
|
||||
const { licenseFlags } = loaderData;
|
||||
|
||||
const { t } = useLingui();
|
||||
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
@@ -47,7 +60,7 @@ export default function Claims() {
|
||||
subtitle={t`Manage all subscription claims`}
|
||||
hideDivider
|
||||
>
|
||||
<ClaimCreateDialog />
|
||||
<ClaimCreateDialog licenseFlags={licenseFlags} />
|
||||
</SettingsHeader>
|
||||
|
||||
<div className="mt-4">
|
||||
@@ -58,7 +71,7 @@ export default function Claims() {
|
||||
className="mb-4"
|
||||
/>
|
||||
|
||||
<AdminClaimsTable />
|
||||
<AdminClaimsTable licenseFlags={licenseFlags} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -12,6 +12,8 @@ import type { z } from 'zod';
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import { SUBSCRIPTION_STATUS_MAP } from '@documenso/lib/constants/billing';
|
||||
import { AppError } from '@documenso/lib/errors/app-error';
|
||||
import { LicenseClient } from '@documenso/lib/server-only/license/license-client';
|
||||
import type { TLicenseClaim } from '@documenso/lib/types/license';
|
||||
import { SUBSCRIPTION_CLAIM_FEATURE_FLAGS } from '@documenso/lib/types/subscription';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import type { TGetAdminOrganisationResponse } from '@documenso/trpc/server/admin-router/get-admin-organisation.types';
|
||||
@@ -40,7 +42,20 @@ import { SettingsHeader } from '~/components/general/settings-header';
|
||||
|
||||
import type { Route } from './+types/organisations.$id';
|
||||
|
||||
export default function OrganisationGroupSettingsPage({ params }: Route.ComponentProps) {
|
||||
export async function loader() {
|
||||
const licenseData = await LicenseClient.getInstance()?.getCachedLicense();
|
||||
|
||||
return {
|
||||
licenseFlags: licenseData?.license?.flags,
|
||||
};
|
||||
}
|
||||
|
||||
export default function OrganisationGroupSettingsPage({
|
||||
params,
|
||||
loaderData,
|
||||
}: Route.ComponentProps) {
|
||||
const { licenseFlags } = loaderData;
|
||||
|
||||
const { t } = useLingui();
|
||||
const { toast } = useToast();
|
||||
|
||||
@@ -129,7 +144,7 @@ export default function OrganisationGroupSettingsPage({ params }: Route.Componen
|
||||
if (isLoadingOrganisation) {
|
||||
return (
|
||||
<div className="flex items-center justify-center rounded-lg py-32">
|
||||
<Loader className="text-muted-foreground h-6 w-6 animate-spin" />
|
||||
<Loader className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -239,7 +254,7 @@ export default function OrganisationGroupSettingsPage({ params }: Route.Componen
|
||||
)}
|
||||
</Alert>
|
||||
|
||||
<OrganisationAdminForm organisation={organisation} />
|
||||
<OrganisationAdminForm organisation={organisation} licenseFlags={licenseFlags} />
|
||||
|
||||
<div className="mt-16 space-y-10">
|
||||
<div>
|
||||
@@ -278,6 +293,7 @@ type TUpdateGenericOrganisationDataFormSchema = z.infer<
|
||||
|
||||
type OrganisationAdminFormOptions = {
|
||||
organisation: TGetAdminOrganisationResponse;
|
||||
licenseFlags?: TLicenseClaim;
|
||||
};
|
||||
|
||||
const GenericOrganisationAdminForm = ({ organisation }: OrganisationAdminFormOptions) => {
|
||||
@@ -349,7 +365,7 @@ const GenericOrganisationAdminForm = ({ organisation }: OrganisationAdminFormOpt
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
{!form.formState.errors.url && (
|
||||
<span className="text-foreground/50 text-xs font-normal">
|
||||
<span className="text-xs font-normal text-foreground/50">
|
||||
{field.value ? (
|
||||
`${NEXT_PUBLIC_WEBAPP_URL()}/o/${field.value}`
|
||||
) : (
|
||||
@@ -381,12 +397,17 @@ const ZUpdateOrganisationBillingFormSchema = ZUpdateAdminOrganisationRequestSche
|
||||
|
||||
type TUpdateOrganisationBillingFormSchema = z.infer<typeof ZUpdateOrganisationBillingFormSchema>;
|
||||
|
||||
const OrganisationAdminForm = ({ organisation }: OrganisationAdminFormOptions) => {
|
||||
const OrganisationAdminForm = ({ organisation, licenseFlags }: OrganisationAdminFormOptions) => {
|
||||
const { toast } = useToast();
|
||||
const { t } = useLingui();
|
||||
|
||||
const { mutateAsync: updateOrganisation } = trpc.admin.organisation.update.useMutation();
|
||||
|
||||
const hasRestrictedEnterpriseFeatures = Object.values(SUBSCRIPTION_CLAIM_FEATURE_FLAGS).some(
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
(flag) => flag.isEnterprise && !licenseFlags?.[flag.key as keyof TLicenseClaim],
|
||||
);
|
||||
|
||||
const form = useForm<TUpdateOrganisationBillingFormSchema>({
|
||||
resolver: zodResolver(ZUpdateOrganisationBillingFormSchema),
|
||||
defaultValues: {
|
||||
@@ -440,7 +461,7 @@ const OrganisationAdminForm = ({ organisation }: OrganisationAdminFormOptions) =
|
||||
<InfoIcon className="mx-2 h-4 w-4" />
|
||||
</TooltipTrigger>
|
||||
|
||||
<TooltipContent className="text-foreground max-w-md space-y-2 p-4">
|
||||
<TooltipContent className="max-w-md space-y-2 p-4 text-foreground">
|
||||
<h2>
|
||||
<strong>
|
||||
<Trans>Inherited subscription claim</Trans>
|
||||
@@ -493,7 +514,7 @@ const OrganisationAdminForm = ({ organisation }: OrganisationAdminFormOptions) =
|
||||
<Link
|
||||
target="_blank"
|
||||
to={`https://dashboard.stripe.com/customers/${field.value}`}
|
||||
className="text-foreground/50 text-xs font-normal"
|
||||
className="text-xs font-normal text-foreground/50"
|
||||
>
|
||||
{`https://dashboard.stripe.com/customers/${field.value}`}
|
||||
</Link>
|
||||
@@ -582,34 +603,57 @@ const OrganisationAdminForm = ({ organisation }: OrganisationAdminFormOptions) =
|
||||
</FormLabel>
|
||||
|
||||
<div className="mt-2 space-y-2 rounded-md border p-4">
|
||||
{Object.values(SUBSCRIPTION_CLAIM_FEATURE_FLAGS).map(({ key, label }) => (
|
||||
<FormField
|
||||
key={key}
|
||||
control={form.control}
|
||||
name={`claims.flags.${key}`}
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex items-center space-x-2">
|
||||
<FormControl>
|
||||
<div className="flex items-center">
|
||||
<Checkbox
|
||||
id={`flag-${key}`}
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
{Object.values(SUBSCRIPTION_CLAIM_FEATURE_FLAGS).map(({ key, label, isEnterprise }) => {
|
||||
const isRestrictedFeature =
|
||||
isEnterprise && !licenseFlags?.[key as keyof TLicenseClaim]; // eslint-disable-line @typescript-eslint/consistent-type-assertions
|
||||
|
||||
<label
|
||||
className="text-muted-foreground ml-2 flex flex-row items-center text-sm"
|
||||
htmlFor={`flag-${key}`}
|
||||
>
|
||||
{label}
|
||||
</label>
|
||||
</div>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
return (
|
||||
<FormField
|
||||
key={key}
|
||||
control={form.control}
|
||||
name={`claims.flags.${key}`}
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex items-center space-x-2">
|
||||
<FormControl>
|
||||
<div className="flex items-center">
|
||||
<Checkbox
|
||||
id={`flag-${key}`}
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
disabled={isRestrictedFeature && !field.value} // Allow disabling of restricted features.
|
||||
/>
|
||||
|
||||
<label
|
||||
className="ml-2 flex flex-row items-center text-sm text-muted-foreground"
|
||||
htmlFor={`flag-${key}`}
|
||||
>
|
||||
{label}
|
||||
{isRestrictedFeature && ' ¹'}
|
||||
</label>
|
||||
</div>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{hasRestrictedEnterpriseFeatures && (
|
||||
<Alert variant="neutral" className="mt-4">
|
||||
<AlertDescription>
|
||||
<span>¹ </span>
|
||||
<Trans>Your current license does not include these features.</Trans>{' '}
|
||||
<Link
|
||||
to="https://docs.documenso.com/users/licenses/enterprise-edition"
|
||||
target="_blank"
|
||||
className="text-foreground underline hover:opacity-80"
|
||||
>
|
||||
<Trans>Learn more</Trans>
|
||||
</Link>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
|
||||
@@ -23,8 +23,10 @@ import {
|
||||
getUserWithSignedDocumentMonthlyGrowth,
|
||||
getUsersCount,
|
||||
} from '@documenso/lib/server-only/admin/get-users-stats';
|
||||
import { LicenseClient } from '@documenso/lib/server-only/license/license-client';
|
||||
import { getSignerConversionMonthly } from '@documenso/lib/server-only/user/get-signer-conversion';
|
||||
|
||||
import { AdminLicenseCard } from '~/components/general/admin-license-card';
|
||||
import { MonthlyActiveUsersChart } from '~/components/general/admin-monthly-active-user-charts';
|
||||
import { AdminStatsSignerConversionChart } from '~/components/general/admin-stats-signer-conversion-chart';
|
||||
import { AdminStatsUsersWithDocumentsChart } from '~/components/general/admin-stats-users-with-documents';
|
||||
@@ -42,6 +44,7 @@ export async function loader() {
|
||||
signerConversionMonthly,
|
||||
monthlyUsersWithDocuments,
|
||||
monthlyActiveUsers,
|
||||
licenseData,
|
||||
] = await Promise.all([
|
||||
getUsersCount(),
|
||||
getOrganisationsWithSubscriptionsCount(),
|
||||
@@ -50,6 +53,7 @@ export async function loader() {
|
||||
getSignerConversionMonthly(),
|
||||
getUserWithSignedDocumentMonthlyGrowth(),
|
||||
getMonthlyActiveUsers(),
|
||||
LicenseClient.getInstance()?.getCachedLicense(),
|
||||
]);
|
||||
|
||||
return {
|
||||
@@ -60,6 +64,7 @@ export async function loader() {
|
||||
signerConversionMonthly,
|
||||
monthlyUsersWithDocuments,
|
||||
monthlyActiveUsers,
|
||||
licenseData: licenseData || null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -74,6 +79,7 @@ export default function AdminStatsPage({ loaderData }: Route.ComponentProps) {
|
||||
signerConversionMonthly,
|
||||
monthlyUsersWithDocuments,
|
||||
monthlyActiveUsers,
|
||||
licenseData,
|
||||
} = loaderData;
|
||||
|
||||
return (
|
||||
@@ -94,6 +100,10 @@ export default function AdminStatsPage({ loaderData }: Route.ComponentProps) {
|
||||
<CardMetric icon={FileCog} title={_(msg`App Version`)} value={`v${version}`} />
|
||||
</div>
|
||||
|
||||
<div className="mb-8 mt-4">
|
||||
<AdminLicenseCard licenseData={licenseData} />
|
||||
</div>
|
||||
|
||||
<div className="mt-16 gap-8">
|
||||
<div>
|
||||
<h3 className="text-3xl font-semibold">
|
||||
|
||||
@@ -179,7 +179,9 @@ export default function DocumentsLogsPage({ loaderData }: Route.ComponentProps)
|
||||
))}
|
||||
|
||||
<div className="text-sm text-foreground">
|
||||
<h3 className="font-semibold">Recipients</h3>
|
||||
<h3 className="font-semibold">
|
||||
<Trans>Recipients</Trans>
|
||||
</h3>
|
||||
<ul className="list-inside list-disc text-muted-foreground">
|
||||
{recipients.map((recipient) => (
|
||||
<li key={`recipient-${recipient.id}`}>
|
||||
|
||||
@@ -16,9 +16,12 @@ import { trpc } from '@documenso/trpc/react';
|
||||
import type { TFindDocumentsInternalResponse } from '@documenso/trpc/server/document-router/find-documents-internal.types';
|
||||
import { ZFindDocumentsInternalRequestSchema } from '@documenso/trpc/server/document-router/find-documents-internal.types';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@documenso/ui/primitives/avatar';
|
||||
import type { RowSelectionState } from '@documenso/ui/primitives/data-table';
|
||||
import { Tabs, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs';
|
||||
|
||||
import { DocumentMoveToFolderDialog } from '~/components/dialogs/document-move-to-folder-dialog';
|
||||
import { EnvelopesBulkDeleteDialog } from '~/components/dialogs/envelopes-bulk-delete-dialog';
|
||||
import { EnvelopesBulkMoveDialog } from '~/components/dialogs/envelopes-bulk-move-dialog';
|
||||
import { DocumentSearch } from '~/components/general/document/document-search';
|
||||
import { DocumentStatus } from '~/components/general/document/document-status';
|
||||
import { EnvelopeDropZoneWrapper } from '~/components/general/envelope/envelope-drop-zone-wrapper';
|
||||
@@ -27,6 +30,7 @@ import { PeriodSelector } from '~/components/general/period-selector';
|
||||
import { DocumentsTable } from '~/components/tables/documents-table';
|
||||
import { DocumentsTableEmptyState } from '~/components/tables/documents-table-empty-state';
|
||||
import { DocumentsTableSenderFilter } from '~/components/tables/documents-table-sender-filter';
|
||||
import { EnvelopesTableBulkActionBar } from '~/components/tables/envelopes-table-bulk-action-bar';
|
||||
import { useCurrentTeam } from '~/providers/team';
|
||||
import { appMetaTags } from '~/utils/meta';
|
||||
|
||||
@@ -54,6 +58,14 @@ export default function DocumentsPage() {
|
||||
const [isMovingDocument, setIsMovingDocument] = useState(false);
|
||||
const [documentToMove, setDocumentToMove] = useState<number | null>(null);
|
||||
|
||||
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
|
||||
const [isBulkMoveDialogOpen, setIsBulkMoveDialogOpen] = useState(false);
|
||||
const [isBulkDeleteDialogOpen, setIsBulkDeleteDialogOpen] = useState(false);
|
||||
|
||||
const selectedEnvelopeIds = useMemo(() => {
|
||||
return Object.keys(rowSelection).filter((id) => rowSelection[id]);
|
||||
}, [rowSelection]);
|
||||
|
||||
const [stats, setStats] = useState<TFindDocumentsInternalResponse['stats']>({
|
||||
[ExtendedDocumentStatus.DRAFT]: 0,
|
||||
[ExtendedDocumentStatus.PENDING]: 0,
|
||||
@@ -109,6 +121,11 @@ export default function DocumentsPage() {
|
||||
}
|
||||
}, [data?.stats]);
|
||||
|
||||
// Clear selection when navigation or filters change
|
||||
useEffect(() => {
|
||||
setRowSelection({});
|
||||
}, [folderId, findDocumentSearchParams]);
|
||||
|
||||
return (
|
||||
<EnvelopeDropZoneWrapper type={EnvelopeType.DOCUMENT}>
|
||||
<div className="mx-auto w-full max-w-screen-xl px-4 md:px-8">
|
||||
@@ -116,9 +133,9 @@ export default function DocumentsPage() {
|
||||
|
||||
<div className="mt-8 flex flex-wrap items-center justify-between gap-x-4 gap-y-8">
|
||||
<div className="flex flex-row items-center">
|
||||
<Avatar className="dark:border-border mr-3 h-12 w-12 border-2 border-solid border-white">
|
||||
<Avatar className="mr-3 h-12 w-12 border-2 border-solid border-white dark:border-border">
|
||||
{team.avatarImageId && <AvatarImage src={formatAvatarUrl(team.avatarImageId)} />}
|
||||
<AvatarFallback className="text-muted-foreground text-xs">
|
||||
<AvatarFallback className="text-xs text-muted-foreground">
|
||||
{team.name.slice(0, 1)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
@@ -148,7 +165,7 @@ export default function DocumentsPage() {
|
||||
.map((value) => (
|
||||
<TabsTrigger
|
||||
key={value}
|
||||
className="hover:text-foreground min-w-[60px]"
|
||||
className="min-w-[60px] hover:text-foreground"
|
||||
value={value}
|
||||
asChild
|
||||
>
|
||||
@@ -190,6 +207,9 @@ export default function DocumentsPage() {
|
||||
setDocumentToMove(documentId);
|
||||
setIsMovingDocument(true);
|
||||
}}
|
||||
enableSelection
|
||||
rowSelection={rowSelection}
|
||||
onRowSelectionChange={setRowSelection}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -209,6 +229,30 @@ export default function DocumentsPage() {
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<EnvelopesTableBulkActionBar
|
||||
selectedCount={selectedEnvelopeIds.length}
|
||||
onMoveClick={() => setIsBulkMoveDialogOpen(true)}
|
||||
onDeleteClick={() => setIsBulkDeleteDialogOpen(true)}
|
||||
onClearSelection={() => setRowSelection({})}
|
||||
/>
|
||||
|
||||
<EnvelopesBulkMoveDialog
|
||||
envelopeIds={selectedEnvelopeIds}
|
||||
envelopeType={EnvelopeType.DOCUMENT}
|
||||
open={isBulkMoveDialogOpen}
|
||||
currentFolderId={folderId}
|
||||
onOpenChange={setIsBulkMoveDialogOpen}
|
||||
onSuccess={() => setRowSelection({})}
|
||||
/>
|
||||
|
||||
<EnvelopesBulkDeleteDialog
|
||||
envelopeIds={selectedEnvelopeIds}
|
||||
envelopeType={EnvelopeType.DOCUMENT}
|
||||
open={isBulkDeleteDialogOpen}
|
||||
onOpenChange={setIsBulkDeleteDialogOpen}
|
||||
onSuccess={() => setRowSelection({})}
|
||||
/>
|
||||
</div>
|
||||
</EnvelopeDropZoneWrapper>
|
||||
);
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { EnvelopeType } from '@prisma/client';
|
||||
import { Bird } from 'lucide-react';
|
||||
@@ -8,9 +10,13 @@ import { formatAvatarUrl } from '@documenso/lib/utils/avatars';
|
||||
import { formatDocumentsPath, formatTemplatesPath } from '@documenso/lib/utils/teams';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@documenso/ui/primitives/avatar';
|
||||
import type { RowSelectionState } from '@documenso/ui/primitives/data-table';
|
||||
|
||||
import { EnvelopesBulkDeleteDialog } from '~/components/dialogs/envelopes-bulk-delete-dialog';
|
||||
import { EnvelopesBulkMoveDialog } from '~/components/dialogs/envelopes-bulk-move-dialog';
|
||||
import { EnvelopeDropZoneWrapper } from '~/components/general/envelope/envelope-drop-zone-wrapper';
|
||||
import { FolderGrid } from '~/components/general/folder/folder-grid';
|
||||
import { EnvelopesTableBulkActionBar } from '~/components/tables/envelopes-table-bulk-action-bar';
|
||||
import { TemplatesTable } from '~/components/tables/templates-table';
|
||||
import { useCurrentTeam } from '~/providers/team';
|
||||
import { appMetaTags } from '~/utils/meta';
|
||||
@@ -28,6 +34,14 @@ export default function TemplatesPage() {
|
||||
const page = Number(searchParams.get('page')) || 1;
|
||||
const perPage = Number(searchParams.get('perPage')) || 10;
|
||||
|
||||
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
|
||||
const [isBulkMoveDialogOpen, setIsBulkMoveDialogOpen] = useState(false);
|
||||
const [isBulkDeleteDialogOpen, setIsBulkDeleteDialogOpen] = useState(false);
|
||||
|
||||
const selectedEnvelopeIds = useMemo(() => {
|
||||
return Object.keys(rowSelection).filter((id) => rowSelection[id]);
|
||||
}, [rowSelection]);
|
||||
|
||||
const documentRootPath = formatDocumentsPath(team.url);
|
||||
const templateRootPath = formatTemplatesPath(team.url);
|
||||
|
||||
@@ -37,6 +51,11 @@ export default function TemplatesPage() {
|
||||
folderId,
|
||||
});
|
||||
|
||||
// Clear selection when navigation or filters change
|
||||
useEffect(() => {
|
||||
setRowSelection({});
|
||||
}, [folderId, page, perPage]);
|
||||
|
||||
return (
|
||||
<EnvelopeDropZoneWrapper type={EnvelopeType.TEMPLATE}>
|
||||
<div className="mx-auto max-w-screen-xl px-4 md:px-8">
|
||||
@@ -44,9 +63,9 @@ export default function TemplatesPage() {
|
||||
|
||||
<div className="mt-8">
|
||||
<div className="flex flex-row items-center">
|
||||
<Avatar className="dark:border-border mr-3 h-12 w-12 border-2 border-solid border-white">
|
||||
<Avatar className="mr-3 h-12 w-12 border-2 border-solid border-white dark:border-border">
|
||||
{team.avatarImageId && <AvatarImage src={formatAvatarUrl(team.avatarImageId)} />}
|
||||
<AvatarFallback className="text-muted-foreground text-xs">
|
||||
<AvatarFallback className="text-xs text-muted-foreground">
|
||||
{team.name.slice(0, 1)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
@@ -58,7 +77,7 @@ export default function TemplatesPage() {
|
||||
|
||||
<div className="mt-8">
|
||||
{data && data.count === 0 ? (
|
||||
<div className="text-muted-foreground/60 flex h-96 flex-col items-center justify-center gap-y-4">
|
||||
<div className="flex h-96 flex-col items-center justify-center gap-y-4 text-muted-foreground/60">
|
||||
<Bird className="h-12 w-12" strokeWidth={1.5} />
|
||||
|
||||
<div className="text-center">
|
||||
@@ -81,10 +100,37 @@ export default function TemplatesPage() {
|
||||
isLoadingError={isLoadingError}
|
||||
documentRootPath={documentRootPath}
|
||||
templateRootPath={templateRootPath}
|
||||
enableSelection
|
||||
rowSelection={rowSelection}
|
||||
onRowSelectionChange={setRowSelection}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<EnvelopesTableBulkActionBar
|
||||
selectedCount={selectedEnvelopeIds.length}
|
||||
onMoveClick={() => setIsBulkMoveDialogOpen(true)}
|
||||
onDeleteClick={() => setIsBulkDeleteDialogOpen(true)}
|
||||
onClearSelection={() => setRowSelection({})}
|
||||
/>
|
||||
|
||||
<EnvelopesBulkMoveDialog
|
||||
envelopeIds={selectedEnvelopeIds}
|
||||
envelopeType={EnvelopeType.TEMPLATE}
|
||||
open={isBulkMoveDialogOpen}
|
||||
currentFolderId={folderId}
|
||||
onOpenChange={setIsBulkMoveDialogOpen}
|
||||
onSuccess={() => setRowSelection({})}
|
||||
/>
|
||||
|
||||
<EnvelopesBulkDeleteDialog
|
||||
envelopeIds={selectedEnvelopeIds}
|
||||
envelopeType={EnvelopeType.TEMPLATE}
|
||||
open={isBulkDeleteDialogOpen}
|
||||
onOpenChange={setIsBulkDeleteDialogOpen}
|
||||
onSuccess={() => setRowSelection({})}
|
||||
/>
|
||||
</div>
|
||||
</EnvelopeDropZoneWrapper>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { EnvelopeType, FieldType, SigningStatus } from '@prisma/client';
|
||||
import { DateTime } from 'luxon';
|
||||
import { redirect } from 'react-router';
|
||||
@@ -290,7 +291,9 @@ export default function SigningCertificate({ loaderData }: Route.ComponentProps)
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-muted-foreground">N/A</p>
|
||||
<p className="text-muted-foreground">
|
||||
<Trans>N/A</Trans>
|
||||
</p>
|
||||
)}
|
||||
|
||||
<p className="mt-2 text-sm text-muted-foreground print:text-xs">
|
||||
|
||||
Binary file not shown.
@@ -106,5 +106,5 @@
|
||||
"vite-plugin-babel-macros": "^1.0.6",
|
||||
"vite-tsconfig-paths": "^5.1.4"
|
||||
},
|
||||
"version": "2.4.0"
|
||||
"version": "2.6.0"
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ server.use(
|
||||
serveStatic({
|
||||
root: 'build/client',
|
||||
onFound: (path, c) => {
|
||||
if (path.startsWith('./build/client/assets')) {
|
||||
if (path.startsWith('build/client/assets')) {
|
||||
// Hard cache assets with hashed file names.
|
||||
c.header('Cache-Control', 'public, immutable, max-age=31536000');
|
||||
} else {
|
||||
|
||||
@@ -10,6 +10,7 @@ import { tsRestHonoApp } from '@documenso/api/hono';
|
||||
import { auth } from '@documenso/auth/server';
|
||||
import { API_V2_BETA_URL, API_V2_URL } from '@documenso/lib/constants/app';
|
||||
import { jobsClient } from '@documenso/lib/jobs/client';
|
||||
import { LicenseClient } from '@documenso/lib/server-only/license/license-client';
|
||||
import { TelemetryClient } from '@documenso/lib/server-only/telemetry/telemetry-client';
|
||||
import { getIpAddress } from '@documenso/lib/universal/get-ip-address';
|
||||
import { env } from '@documenso/lib/utils/env';
|
||||
@@ -140,4 +141,7 @@ if (env('NODE_ENV') !== 'development') {
|
||||
void TelemetryClient.start();
|
||||
}
|
||||
|
||||
// Start license client to verify license on startup.
|
||||
void LicenseClient.start();
|
||||
|
||||
export default app;
|
||||
|
||||
@@ -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/,
|
||||
|
||||
+60
-50
@@ -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
@@ -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
|
||||
|
||||
Generated
+655
-715
File diff suppressed because it is too large
Load Diff
+5
-6
@@ -5,7 +5,7 @@
|
||||
"apps/*",
|
||||
"packages/*"
|
||||
],
|
||||
"version": "2.4.0",
|
||||
"version": "2.6.0",
|
||||
"scripts": {
|
||||
"postinstall": "patch-package",
|
||||
"build": "turbo run build",
|
||||
@@ -61,6 +61,7 @@
|
||||
"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",
|
||||
@@ -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.5",
|
||||
"@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",
|
||||
@@ -103,4 +102,4 @@
|
||||
"typescript": "5.6.2",
|
||||
"zod": "^3.25.76"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,6 +43,9 @@ import {
|
||||
|
||||
const c = initContract();
|
||||
|
||||
const deprecatedDescription =
|
||||
'This endpoint is deprecated, but will continue to be supported. For more details, see https://docs.documenso.com/developers/public-api.';
|
||||
|
||||
export const ApiContractV1 = c.router(
|
||||
{
|
||||
getDocuments: {
|
||||
@@ -55,6 +58,8 @@ export const ApiContractV1 = c.router(
|
||||
404: ZUnsuccessfulResponseSchema,
|
||||
},
|
||||
summary: 'Get all documents',
|
||||
deprecated: true,
|
||||
description: deprecatedDescription,
|
||||
},
|
||||
|
||||
getDocument: {
|
||||
@@ -66,6 +71,8 @@ export const ApiContractV1 = c.router(
|
||||
404: ZUnsuccessfulResponseSchema,
|
||||
},
|
||||
summary: 'Get a single document',
|
||||
deprecated: true,
|
||||
description: deprecatedDescription,
|
||||
},
|
||||
|
||||
downloadSignedDocument: {
|
||||
@@ -78,6 +85,8 @@ export const ApiContractV1 = c.router(
|
||||
404: ZUnsuccessfulResponseSchema,
|
||||
},
|
||||
summary: 'Download a signed document when the storage transport is S3',
|
||||
deprecated: true,
|
||||
description: deprecatedDescription,
|
||||
},
|
||||
|
||||
createDocument: {
|
||||
@@ -90,6 +99,8 @@ export const ApiContractV1 = c.router(
|
||||
404: ZUnsuccessfulResponseSchema,
|
||||
},
|
||||
summary: 'Upload a new document and get a presigned URL',
|
||||
deprecated: true,
|
||||
description: deprecatedDescription,
|
||||
},
|
||||
|
||||
createTemplate: {
|
||||
@@ -102,6 +113,8 @@ export const ApiContractV1 = c.router(
|
||||
404: ZUnsuccessfulResponseSchema,
|
||||
},
|
||||
summary: 'Create a new template and get a presigned URL',
|
||||
deprecated: true,
|
||||
description: deprecatedDescription,
|
||||
},
|
||||
|
||||
deleteTemplate: {
|
||||
@@ -114,6 +127,8 @@ export const ApiContractV1 = c.router(
|
||||
404: ZUnsuccessfulResponseSchema,
|
||||
},
|
||||
summary: 'Delete a template',
|
||||
deprecated: true,
|
||||
description: deprecatedDescription,
|
||||
},
|
||||
|
||||
getTemplate: {
|
||||
@@ -125,6 +140,8 @@ export const ApiContractV1 = c.router(
|
||||
404: ZUnsuccessfulResponseSchema,
|
||||
},
|
||||
summary: 'Get a single template',
|
||||
deprecated: true,
|
||||
description: deprecatedDescription,
|
||||
},
|
||||
|
||||
getTemplates: {
|
||||
@@ -137,6 +154,8 @@ export const ApiContractV1 = c.router(
|
||||
404: ZUnsuccessfulResponseSchema,
|
||||
},
|
||||
summary: 'Get all templates',
|
||||
deprecated: true,
|
||||
description: deprecatedDescription,
|
||||
},
|
||||
|
||||
createDocumentFromTemplate: {
|
||||
@@ -150,7 +169,7 @@ export const ApiContractV1 = c.router(
|
||||
},
|
||||
summary: 'Create a new document from an existing template',
|
||||
deprecated: true,
|
||||
description: `This has been deprecated in favour of "/api/v1/templates/:templateId/generate-document". You may face unpredictable behavior using this endpoint as it is no longer maintained.`,
|
||||
description: `${deprecatedDescription} \n\nIf you must use the V1 API, use "/api/v1/templates/:templateId/generate-document" instead.`,
|
||||
},
|
||||
|
||||
generateDocumentFromTemplate: {
|
||||
@@ -165,8 +184,8 @@ export const ApiContractV1 = c.router(
|
||||
500: ZUnsuccessfulResponseSchema,
|
||||
},
|
||||
summary: 'Create a new document from an existing template',
|
||||
description:
|
||||
'Create a new document from an existing template. Passing in values for title and meta will override the original values defined in the template. If you do not pass in values for recipients, it will use the values defined in the template.',
|
||||
deprecated: true,
|
||||
description: `${deprecatedDescription} \n\nCreate a new document from an existing template. Passing in values for title and meta will override the original values defined in the template. If you do not pass in values for recipients, it will use the values defined in the template.`,
|
||||
},
|
||||
|
||||
sendDocument: {
|
||||
@@ -181,9 +200,8 @@ export const ApiContractV1 = c.router(
|
||||
500: ZUnsuccessfulResponseSchema,
|
||||
},
|
||||
summary: 'Send a document for signing',
|
||||
// I'm aware this should be in the variable itself, which it is, however it's difficult for users to find in our current UI.
|
||||
description:
|
||||
'Notes\n\n`sendEmail` - Whether to send an email to the recipients asking them to action the document. If you disable this, you will need to manually distribute the document to the recipients using the generated signing links. Defaults to true',
|
||||
deprecated: true,
|
||||
description: `${deprecatedDescription} \n\nNotes\n\nsendEmail - Whether to send an email to the recipients asking them to action the document. If you disable this, you will need to manually distribute the document to the recipients using the generated signing links. Defaults to true`,
|
||||
},
|
||||
|
||||
resendDocument: {
|
||||
@@ -198,6 +216,8 @@ export const ApiContractV1 = c.router(
|
||||
500: ZUnsuccessfulResponseSchema,
|
||||
},
|
||||
summary: 'Re-send a document for signing',
|
||||
deprecated: true,
|
||||
description: deprecatedDescription,
|
||||
},
|
||||
|
||||
deleteDocument: {
|
||||
@@ -210,6 +230,8 @@ export const ApiContractV1 = c.router(
|
||||
404: ZUnsuccessfulResponseSchema,
|
||||
},
|
||||
summary: 'Delete a document',
|
||||
deprecated: true,
|
||||
description: deprecatedDescription,
|
||||
},
|
||||
|
||||
createRecipient: {
|
||||
@@ -224,6 +246,8 @@ export const ApiContractV1 = c.router(
|
||||
500: ZUnsuccessfulResponseSchema,
|
||||
},
|
||||
summary: 'Create a recipient for a document',
|
||||
deprecated: true,
|
||||
description: deprecatedDescription,
|
||||
},
|
||||
|
||||
updateRecipient: {
|
||||
@@ -238,6 +262,8 @@ export const ApiContractV1 = c.router(
|
||||
500: ZUnsuccessfulResponseSchema,
|
||||
},
|
||||
summary: 'Update a recipient for a document',
|
||||
deprecated: true,
|
||||
description: deprecatedDescription,
|
||||
},
|
||||
|
||||
deleteRecipient: {
|
||||
@@ -252,6 +278,8 @@ export const ApiContractV1 = c.router(
|
||||
500: ZUnsuccessfulResponseSchema,
|
||||
},
|
||||
summary: 'Delete a recipient from a document',
|
||||
deprecated: true,
|
||||
description: deprecatedDescription,
|
||||
},
|
||||
|
||||
createField: {
|
||||
@@ -266,6 +294,8 @@ export const ApiContractV1 = c.router(
|
||||
500: ZUnsuccessfulResponseSchema,
|
||||
},
|
||||
summary: 'Create a field for a document',
|
||||
deprecated: true,
|
||||
description: deprecatedDescription,
|
||||
},
|
||||
|
||||
updateField: {
|
||||
@@ -280,6 +310,8 @@ export const ApiContractV1 = c.router(
|
||||
500: ZUnsuccessfulResponseSchema,
|
||||
},
|
||||
summary: 'Update a field for a document',
|
||||
deprecated: true,
|
||||
description: deprecatedDescription,
|
||||
},
|
||||
|
||||
deleteField: {
|
||||
@@ -294,6 +326,8 @@ export const ApiContractV1 = c.router(
|
||||
500: ZUnsuccessfulResponseSchema,
|
||||
},
|
||||
summary: 'Delete a field from a document',
|
||||
deprecated: true,
|
||||
description: deprecatedDescription,
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -796,6 +796,7 @@ export const ApiContractV1Implementation = tsr.router(ApiContractV1, {
|
||||
title: body.title,
|
||||
},
|
||||
attachments: body.attachments,
|
||||
formValues: body.formValues,
|
||||
requestMetadata: metadata,
|
||||
});
|
||||
|
||||
@@ -1041,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);
|
||||
}
|
||||
}),
|
||||
|
||||
|
||||
@@ -11,7 +11,8 @@ export const OpenAPIV1 = Object.assign(
|
||||
info: {
|
||||
title: 'Documenso API',
|
||||
version: '1.0.0',
|
||||
description: 'The Documenso API for retrieving, creating, updating and deleting documents.',
|
||||
description:
|
||||
'API V1 is deprecated, but will continue to be supported. For more details, see https://docs.documenso.com/developers/public-api. \n\nThe Documenso API for retrieving, creating, updating and deleting documents.',
|
||||
},
|
||||
servers: [
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,453 @@
|
||||
import { PDF, StandardFonts } from '@libpdf/core';
|
||||
import type { APIRequestContext } from '@playwright/test';
|
||||
import { 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 { prisma } from '@documenso/prisma';
|
||||
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`;
|
||||
|
||||
const FIXTURES_DIR = path.join(__dirname, '../../../../assets/fixtures/auto-placement');
|
||||
|
||||
test.describe.configure({ mode: 'parallel' });
|
||||
|
||||
test.describe('Placeholder-based field creation', () => {
|
||||
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 createEnvelopeWithPdf = async (
|
||||
request: APIRequestContext,
|
||||
pdfFilename: string,
|
||||
): Promise<TCreateEnvelopeResponse> => {
|
||||
const pdfPath = path.join(FIXTURES_DIR, pdfFilename);
|
||||
const pdfData = fs.readFileSync(pdfPath);
|
||||
|
||||
const formData = new FormData();
|
||||
|
||||
formData.append(
|
||||
'payload',
|
||||
JSON.stringify({
|
||||
type: EnvelopeType.DOCUMENT,
|
||||
title: 'Placeholder Fields Test',
|
||||
} satisfies TCreateEnvelopePayload),
|
||||
);
|
||||
|
||||
formData.append('files', new File([pdfData], pdfFilename, { type: 'application/pdf' }));
|
||||
|
||||
const res = await request.post(`${baseUrl}/envelope/create`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
multipart: formData,
|
||||
});
|
||||
|
||||
expect(res.ok()).toBeTruthy();
|
||||
|
||||
return res.json();
|
||||
};
|
||||
|
||||
const createEnvelopeItemsWithPdf = async (
|
||||
request: APIRequestContext,
|
||||
envelopeId: string,
|
||||
pdfFilename: string,
|
||||
) => {
|
||||
const pdfPath = path.join(FIXTURES_DIR, pdfFilename);
|
||||
const pdfData = fs.readFileSync(pdfPath);
|
||||
|
||||
const formData = new FormData();
|
||||
|
||||
formData.append('payload', JSON.stringify({ envelopeId }));
|
||||
formData.append('files', new File([pdfData], pdfFilename, { type: 'application/pdf' }));
|
||||
|
||||
const res = await request.post(`${baseUrl}/envelope/item/create-many`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
multipart: formData,
|
||||
});
|
||||
|
||||
expect(res.ok()).toBeTruthy();
|
||||
|
||||
return res.json();
|
||||
};
|
||||
|
||||
const addRecipient = async (request: APIRequestContext, envelopeId: string) => {
|
||||
const payload: TCreateEnvelopeRecipientsRequest = {
|
||||
envelopeId,
|
||||
data: [
|
||||
{
|
||||
email: user.email,
|
||||
name: user.name || '',
|
||||
role: RecipientRole.SIGNER,
|
||||
accessAuth: [],
|
||||
actionAuth: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const res = await request.post(`${baseUrl}/envelope/recipient/create-many`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
data: payload,
|
||||
});
|
||||
|
||||
expect(res.ok()).toBeTruthy();
|
||||
};
|
||||
|
||||
const addRecipients = async (
|
||||
request: APIRequestContext,
|
||||
envelopeId: string,
|
||||
recipients: TCreateEnvelopeRecipientsRequest['data'],
|
||||
) => {
|
||||
const payload: TCreateEnvelopeRecipientsRequest = {
|
||||
envelopeId,
|
||||
data: recipients,
|
||||
};
|
||||
|
||||
const res = await request.post(`${baseUrl}/envelope/recipient/create-many`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
data: payload,
|
||||
});
|
||||
|
||||
expect(res.ok()).toBeTruthy();
|
||||
};
|
||||
|
||||
const getEnvelope = async (
|
||||
request: APIRequestContext,
|
||||
envelopeId: string,
|
||||
): Promise<TGetEnvelopeResponse> => {
|
||||
const res = await request.get(`${baseUrl}/envelope/${envelopeId}`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
|
||||
expect(res.ok()).toBeTruthy();
|
||||
|
||||
return res.json();
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a PDF with the same placeholder appearing multiple times at different locations.
|
||||
*/
|
||||
const createPdfWithDuplicatePlaceholders = async (): Promise<Buffer> => {
|
||||
const pdf = PDF.create();
|
||||
const page = pdf.addPage({ size: 'letter' });
|
||||
|
||||
// Draw the same placeholder text at three different Y positions.
|
||||
page.drawText('{{initials}}', { x: 50, y: 700, font: StandardFonts.Helvetica, size: 12 });
|
||||
page.drawText('{{initials}}', { x: 50, y: 500, font: StandardFonts.Helvetica, size: 12 });
|
||||
page.drawText('{{initials}}', { x: 50, y: 300, font: StandardFonts.Helvetica, size: 12 });
|
||||
|
||||
const bytes = await pdf.save();
|
||||
|
||||
return Buffer.from(bytes);
|
||||
};
|
||||
|
||||
const createEnvelopeWithPdfBuffer = async (
|
||||
request: APIRequestContext,
|
||||
pdfBuffer: Buffer,
|
||||
filename: string,
|
||||
): Promise<TCreateEnvelopeResponse> => {
|
||||
const formData = new FormData();
|
||||
|
||||
formData.append(
|
||||
'payload',
|
||||
JSON.stringify({
|
||||
type: EnvelopeType.DOCUMENT,
|
||||
title: 'Placeholder Fields Test',
|
||||
} satisfies TCreateEnvelopePayload),
|
||||
);
|
||||
|
||||
formData.append('files', new File([pdfBuffer], filename, { type: 'application/pdf' }));
|
||||
|
||||
const res = await request.post(`${baseUrl}/envelope/create`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
multipart: formData,
|
||||
});
|
||||
|
||||
expect(res.ok()).toBeTruthy();
|
||||
|
||||
return res.json();
|
||||
};
|
||||
|
||||
test('should create a field at a placeholder location', async ({ request }) => {
|
||||
const envelope = await createEnvelopeWithPdf(request, 'no-recipient-placeholders.pdf');
|
||||
await addRecipient(request, envelope.id);
|
||||
|
||||
const envelopeData = await getEnvelope(request, envelope.id);
|
||||
const recipientId = envelopeData.recipients[0].id;
|
||||
|
||||
const createFieldsRes = await request.post(`${baseUrl}/envelope/field/create-many`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
data: {
|
||||
envelopeId: envelope.id,
|
||||
data: [
|
||||
{
|
||||
recipientId,
|
||||
type: FieldType.SIGNATURE,
|
||||
placeholder: '{{signature}}',
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(createFieldsRes.ok()).toBeTruthy();
|
||||
|
||||
const fields = await prisma.field.findMany({
|
||||
where: { envelopeId: envelope.id },
|
||||
});
|
||||
|
||||
expect(fields).toHaveLength(1);
|
||||
expect(fields[0].type).toBe(FieldType.SIGNATURE);
|
||||
|
||||
// Verify the field has non-zero position/dimensions resolved from the placeholder.
|
||||
expect(fields[0].positionX.toNumber()).toBeGreaterThan(0);
|
||||
expect(fields[0].positionY.toNumber()).toBeGreaterThan(0);
|
||||
expect(fields[0].width.toNumber()).toBeGreaterThan(0);
|
||||
expect(fields[0].height.toNumber()).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('should override width and height when provided', async ({ request }) => {
|
||||
const envelope = await createEnvelopeWithPdf(request, 'no-recipient-placeholders.pdf');
|
||||
await addRecipient(request, envelope.id);
|
||||
|
||||
const envelopeData = await getEnvelope(request, envelope.id);
|
||||
const recipientId = envelopeData.recipients[0].id;
|
||||
|
||||
const createFieldsRes = await request.post(`${baseUrl}/envelope/field/create-many`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
data: {
|
||||
envelopeId: envelope.id,
|
||||
data: [
|
||||
{
|
||||
recipientId,
|
||||
type: FieldType.NAME,
|
||||
placeholder: '{{name}}',
|
||||
width: 30,
|
||||
height: 5,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(createFieldsRes.ok()).toBeTruthy();
|
||||
|
||||
const fields = await prisma.field.findMany({
|
||||
where: { envelopeId: envelope.id },
|
||||
});
|
||||
|
||||
expect(fields).toHaveLength(1);
|
||||
expect(fields[0].width.toNumber()).toBeCloseTo(30, 1);
|
||||
expect(fields[0].height.toNumber()).toBeCloseTo(5, 1);
|
||||
});
|
||||
|
||||
test('should fail when placeholder text is not found in the PDF', async ({ request }) => {
|
||||
const envelope = await createEnvelopeWithPdf(request, 'no-recipient-placeholders.pdf');
|
||||
await addRecipient(request, envelope.id);
|
||||
|
||||
const envelopeData = await getEnvelope(request, envelope.id);
|
||||
const recipientId = envelopeData.recipients[0].id;
|
||||
|
||||
const createFieldsRes = await request.post(`${baseUrl}/envelope/field/create-many`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
data: {
|
||||
envelopeId: envelope.id,
|
||||
data: [
|
||||
{
|
||||
recipientId,
|
||||
type: FieldType.TEXT,
|
||||
placeholder: '{{nonexistent}}',
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(createFieldsRes.ok()).toBeFalsy();
|
||||
});
|
||||
|
||||
test('should create fields using a mix of coordinate and placeholder positioning', async ({
|
||||
request,
|
||||
}) => {
|
||||
const envelope = await createEnvelopeWithPdf(request, 'no-recipient-placeholders.pdf');
|
||||
await addRecipient(request, envelope.id);
|
||||
|
||||
const envelopeData = await getEnvelope(request, envelope.id);
|
||||
const recipientId = envelopeData.recipients[0].id;
|
||||
|
||||
const createFieldsRes = await request.post(`${baseUrl}/envelope/field/create-many`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
data: {
|
||||
envelopeId: envelope.id,
|
||||
data: [
|
||||
{
|
||||
recipientId,
|
||||
type: FieldType.SIGNATURE,
|
||||
placeholder: '{{signature}}',
|
||||
},
|
||||
{
|
||||
recipientId,
|
||||
type: FieldType.DATE,
|
||||
page: 1,
|
||||
positionX: 10,
|
||||
positionY: 20,
|
||||
width: 15,
|
||||
height: 3,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(createFieldsRes.ok()).toBeTruthy();
|
||||
|
||||
const fields = await prisma.field.findMany({
|
||||
where: { envelopeId: envelope.id },
|
||||
orderBy: { type: 'asc' },
|
||||
});
|
||||
|
||||
expect(fields).toHaveLength(2);
|
||||
|
||||
const dateField = fields.find((f) => f.type === FieldType.DATE);
|
||||
const signatureField = fields.find((f) => f.type === FieldType.SIGNATURE);
|
||||
|
||||
expect(dateField).toBeDefined();
|
||||
expect(dateField!.positionX.toNumber()).toBeCloseTo(10, 1);
|
||||
expect(dateField!.positionY.toNumber()).toBeCloseTo(20, 1);
|
||||
|
||||
expect(signatureField).toBeDefined();
|
||||
expect(signatureField!.positionX.toNumber()).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('should create a field only at first occurrence by default', async ({ request }) => {
|
||||
const pdfBuffer = await createPdfWithDuplicatePlaceholders();
|
||||
const envelope = await createEnvelopeWithPdfBuffer(request, pdfBuffer, 'duplicates.pdf');
|
||||
await addRecipient(request, envelope.id);
|
||||
|
||||
const envelopeData = await getEnvelope(request, envelope.id);
|
||||
const recipientId = envelopeData.recipients[0].id;
|
||||
|
||||
const createFieldsRes = await request.post(`${baseUrl}/envelope/field/create-many`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
data: {
|
||||
envelopeId: envelope.id,
|
||||
data: [
|
||||
{
|
||||
recipientId,
|
||||
type: FieldType.INITIALS,
|
||||
placeholder: '{{initials}}',
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(createFieldsRes.ok()).toBeTruthy();
|
||||
|
||||
const fields = await prisma.field.findMany({
|
||||
where: { envelopeId: envelope.id },
|
||||
});
|
||||
|
||||
// Should only create one field (first occurrence).
|
||||
expect(fields).toHaveLength(1);
|
||||
expect(fields[0].type).toBe(FieldType.INITIALS);
|
||||
});
|
||||
|
||||
test('should create fields at all occurrences when matchAll is true', async ({ request }) => {
|
||||
const pdfBuffer = await createPdfWithDuplicatePlaceholders();
|
||||
const envelope = await createEnvelopeWithPdfBuffer(request, pdfBuffer, 'duplicates.pdf');
|
||||
await addRecipient(request, envelope.id);
|
||||
|
||||
const envelopeData = await getEnvelope(request, envelope.id);
|
||||
const recipientId = envelopeData.recipients[0].id;
|
||||
|
||||
const createFieldsRes = await request.post(`${baseUrl}/envelope/field/create-many`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
data: {
|
||||
envelopeId: envelope.id,
|
||||
data: [
|
||||
{
|
||||
recipientId,
|
||||
type: FieldType.INITIALS,
|
||||
placeholder: '{{initials}}',
|
||||
matchAll: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(createFieldsRes.ok()).toBeTruthy();
|
||||
|
||||
const fields = await prisma.field.findMany({
|
||||
where: { envelopeId: envelope.id },
|
||||
orderBy: { positionY: 'asc' },
|
||||
});
|
||||
|
||||
// Should create three fields (one for each occurrence).
|
||||
expect(fields).toHaveLength(3);
|
||||
|
||||
// All should be INITIALS type.
|
||||
expect(fields.every((f) => f.type === FieldType.INITIALS)).toBe(true);
|
||||
|
||||
// Verify they're at different Y positions.
|
||||
const yPositions = fields.map((f) => f.positionY.toNumber());
|
||||
const uniqueYPositions = new Set(yPositions);
|
||||
|
||||
expect(uniqueYPositions.size).toBe(3);
|
||||
});
|
||||
|
||||
test('should map placeholder recipients by signing order when adding items', async ({
|
||||
request,
|
||||
}) => {
|
||||
const envelope = await createEnvelopeWithPdf(request, 'no-recipient-placeholders.pdf');
|
||||
|
||||
await addRecipients(request, envelope.id, [
|
||||
{
|
||||
email: 'second.recipient@documenso.com',
|
||||
name: 'Second Recipient',
|
||||
role: RecipientRole.SIGNER,
|
||||
signingOrder: 2,
|
||||
accessAuth: [],
|
||||
actionAuth: [],
|
||||
},
|
||||
{
|
||||
email: 'first.recipient@documenso.com',
|
||||
name: 'First Recipient',
|
||||
role: RecipientRole.SIGNER,
|
||||
signingOrder: 1,
|
||||
accessAuth: [],
|
||||
actionAuth: [],
|
||||
},
|
||||
]);
|
||||
|
||||
await createEnvelopeItemsWithPdf(request, envelope.id, 'project-proposal-single-recipient.pdf');
|
||||
|
||||
const recipients = await prisma.recipient.findMany({
|
||||
where: { envelopeId: envelope.id },
|
||||
});
|
||||
|
||||
const firstRecipient = recipients.find((recipient) => recipient.signingOrder === 1);
|
||||
|
||||
expect(firstRecipient).toBeDefined();
|
||||
|
||||
const fields = await prisma.field.findMany({
|
||||
where: { envelopeId: envelope.id },
|
||||
});
|
||||
|
||||
expect(fields.length).toBeGreaterThan(0);
|
||||
expect(fields.every((field) => field.recipientId === firstRecipient!.id)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -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 },
|
||||
@@ -4635,252 +4730,6 @@ test.describe('Document API V2', () => {
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Envelope item delete endpoint', () => {
|
||||
test('should block unauthorized access to envelope item delete endpoint', async ({
|
||||
request,
|
||||
}) => {
|
||||
const doc = await seedBlankDocument(userA, teamA.id);
|
||||
|
||||
const envelopeItem = await prisma.envelopeItem.findFirstOrThrow({
|
||||
where: { envelopeId: doc.id },
|
||||
});
|
||||
|
||||
const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/envelope/item/delete`, {
|
||||
headers: { Authorization: `Bearer ${tokenB}` },
|
||||
data: {
|
||||
envelopeId: doc.id,
|
||||
envelopeItemId: envelopeItem.id,
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.ok()).toBeFalsy();
|
||||
expect(res.status()).toBe(404);
|
||||
});
|
||||
|
||||
test('should allow authorized access to envelope item delete endpoint', async ({
|
||||
request,
|
||||
}) => {
|
||||
const doc = await seedBlankDocument(userA, teamA.id);
|
||||
|
||||
const envelopeItem = await prisma.envelopeItem.findFirstOrThrow({
|
||||
where: { envelopeId: doc.id },
|
||||
});
|
||||
|
||||
const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/envelope/item/delete`, {
|
||||
headers: { Authorization: `Bearer ${tokenA}` },
|
||||
data: {
|
||||
envelopeId: doc.id,
|
||||
envelopeItemId: envelopeItem.id,
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.ok()).toBeTruthy();
|
||||
expect(res.status()).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Envelope attachment find endpoint', () => {
|
||||
test('should block unauthorized access to envelope attachment find endpoint', async ({
|
||||
request,
|
||||
}) => {
|
||||
const doc = await seedBlankDocument(userA, teamA.id);
|
||||
|
||||
const res = await request.get(
|
||||
`${WEBAPP_BASE_URL}/api/v2-beta/envelope/attachment?envelopeId=${doc.id}`,
|
||||
{
|
||||
headers: { Authorization: `Bearer ${tokenB}` },
|
||||
},
|
||||
);
|
||||
|
||||
expect(res.ok()).toBeFalsy();
|
||||
expect(res.status()).toBe(404);
|
||||
});
|
||||
|
||||
test('should allow authorized access to envelope attachment find endpoint', async ({
|
||||
request,
|
||||
}) => {
|
||||
const doc = await seedBlankDocument(userA, teamA.id);
|
||||
|
||||
const res = await request.get(
|
||||
`${WEBAPP_BASE_URL}/api/v2-beta/envelope/attachment?envelopeId=${doc.id}`,
|
||||
{
|
||||
headers: { Authorization: `Bearer ${tokenA}` },
|
||||
},
|
||||
);
|
||||
|
||||
expect(res.ok()).toBeTruthy();
|
||||
expect(res.status()).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Envelope attachment create endpoint', () => {
|
||||
test('should block unauthorized access to envelope attachment create endpoint', async ({
|
||||
request,
|
||||
}) => {
|
||||
const doc = await seedBlankDocument(userA, teamA.id);
|
||||
|
||||
const res = await request.post(
|
||||
`${WEBAPP_BASE_URL}/api/v2-beta/envelope/attachment/create`,
|
||||
{
|
||||
headers: { Authorization: `Bearer ${tokenB}` },
|
||||
data: {
|
||||
envelopeId: doc.id,
|
||||
data: {
|
||||
label: 'Test Attachment',
|
||||
data: 'https://example.com/file.pdf',
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(res.ok()).toBeFalsy();
|
||||
expect(res.status()).toBe(404);
|
||||
});
|
||||
|
||||
test('should allow authorized access to envelope attachment create endpoint', async ({
|
||||
request,
|
||||
}) => {
|
||||
const doc = await seedBlankDocument(userA, teamA.id);
|
||||
|
||||
const res = await request.post(
|
||||
`${WEBAPP_BASE_URL}/api/v2-beta/envelope/attachment/create`,
|
||||
{
|
||||
headers: { Authorization: `Bearer ${tokenA}` },
|
||||
data: {
|
||||
envelopeId: doc.id,
|
||||
data: {
|
||||
label: 'Test Attachment',
|
||||
data: 'https://example.com/file.pdf',
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(res.ok()).toBeTruthy();
|
||||
expect(res.status()).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Envelope attachment update endpoint', () => {
|
||||
test('should block unauthorized access to envelope attachment update endpoint', async ({
|
||||
request,
|
||||
}) => {
|
||||
const doc = await seedBlankDocument(userA, teamA.id);
|
||||
|
||||
const attachment = await prisma.envelopeAttachment.create({
|
||||
data: {
|
||||
envelopeId: doc.id,
|
||||
type: 'link',
|
||||
label: 'Original Label',
|
||||
data: 'https://example.com/original.pdf',
|
||||
},
|
||||
});
|
||||
|
||||
const res = await request.post(
|
||||
`${WEBAPP_BASE_URL}/api/v2-beta/envelope/attachment/update`,
|
||||
{
|
||||
headers: { Authorization: `Bearer ${tokenB}` },
|
||||
data: {
|
||||
id: attachment.id,
|
||||
data: {
|
||||
label: 'Updated Label',
|
||||
data: 'https://example.com/updated.pdf',
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(res.ok()).toBeFalsy();
|
||||
expect(res.status()).toBe(404);
|
||||
});
|
||||
|
||||
test('should allow authorized access to envelope attachment update endpoint', async ({
|
||||
request,
|
||||
}) => {
|
||||
const doc = await seedBlankDocument(userA, teamA.id);
|
||||
|
||||
const attachment = await prisma.envelopeAttachment.create({
|
||||
data: {
|
||||
envelopeId: doc.id,
|
||||
type: 'link',
|
||||
label: 'Original Label',
|
||||
data: 'https://example.com/original.pdf',
|
||||
},
|
||||
});
|
||||
|
||||
const res = await request.post(
|
||||
`${WEBAPP_BASE_URL}/api/v2-beta/envelope/attachment/update`,
|
||||
{
|
||||
headers: { Authorization: `Bearer ${tokenA}` },
|
||||
data: {
|
||||
id: attachment.id,
|
||||
data: {
|
||||
label: 'Updated Label',
|
||||
data: 'https://example.com/updated.pdf',
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(res.ok()).toBeTruthy();
|
||||
expect(res.status()).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Envelope attachment delete endpoint', () => {
|
||||
test('should block unauthorized access to envelope attachment delete endpoint', async ({
|
||||
request,
|
||||
}) => {
|
||||
const doc = await seedBlankDocument(userA, teamA.id);
|
||||
|
||||
const attachment = await prisma.envelopeAttachment.create({
|
||||
data: {
|
||||
envelopeId: doc.id,
|
||||
type: 'link',
|
||||
label: 'Test Attachment',
|
||||
data: 'https://example.com/file.pdf',
|
||||
},
|
||||
});
|
||||
|
||||
const res = await request.post(
|
||||
`${WEBAPP_BASE_URL}/api/v2-beta/envelope/attachment/delete`,
|
||||
{
|
||||
headers: { Authorization: `Bearer ${tokenB}` },
|
||||
data: { id: attachment.id },
|
||||
},
|
||||
);
|
||||
|
||||
expect(res.ok()).toBeFalsy();
|
||||
expect(res.status()).toBe(404);
|
||||
});
|
||||
|
||||
test('should allow authorized access to envelope attachment delete endpoint', async ({
|
||||
request,
|
||||
}) => {
|
||||
const doc = await seedBlankDocument(userA, teamA.id);
|
||||
|
||||
const attachment = await prisma.envelopeAttachment.create({
|
||||
data: {
|
||||
envelopeId: doc.id,
|
||||
type: 'link',
|
||||
label: 'Test Attachment',
|
||||
data: 'https://example.com/file.pdf',
|
||||
},
|
||||
});
|
||||
|
||||
const res = await request.post(
|
||||
`${WEBAPP_BASE_URL}/api/v2-beta/envelope/attachment/delete`,
|
||||
{
|
||||
headers: { Authorization: `Bearer ${tokenA}` },
|
||||
data: { id: attachment.id },
|
||||
},
|
||||
);
|
||||
|
||||
expect(res.ok()).toBeTruthy();
|
||||
expect(res.status()).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Envelope audit logs endpoint', () => {
|
||||
test('should block unauthorized access to envelope audit logs endpoint', async ({
|
||||
request,
|
||||
|
||||
+220
@@ -0,0 +1,220 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
import type { Team, User } from '@prisma/client';
|
||||
|
||||
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 { seedBlankDocument } from '@documenso/prisma/seed/documents';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
|
||||
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
|
||||
|
||||
test.describe.configure({
|
||||
mode: 'parallel',
|
||||
});
|
||||
|
||||
test.describe('Envelope Attachments API V2', () => {
|
||||
let userA: User, teamA: Team, userB: User, teamB: Team, tokenA: string, tokenB: string;
|
||||
|
||||
test.beforeEach(async () => {
|
||||
({ user: userA, team: teamA } = await seedUser());
|
||||
({ token: tokenA } = await createApiToken({
|
||||
userId: userA.id,
|
||||
teamId: teamA.id,
|
||||
tokenName: 'userA',
|
||||
expiresIn: null,
|
||||
}));
|
||||
|
||||
({ user: userB, team: teamB } = await seedUser());
|
||||
({ token: tokenB } = await createApiToken({
|
||||
userId: userB.id,
|
||||
teamId: teamB.id,
|
||||
tokenName: 'userB',
|
||||
expiresIn: null,
|
||||
}));
|
||||
});
|
||||
|
||||
test.describe('Envelope attachment find endpoint', () => {
|
||||
test('should block unauthorized access to envelope attachment find endpoint', async ({
|
||||
request,
|
||||
}) => {
|
||||
const doc = await seedBlankDocument(userA, teamA.id);
|
||||
|
||||
const res = await request.get(
|
||||
`${WEBAPP_BASE_URL}/api/v2-beta/envelope/attachment?envelopeId=${doc.id}`,
|
||||
{
|
||||
headers: { Authorization: `Bearer ${tokenB}` },
|
||||
},
|
||||
);
|
||||
|
||||
expect(res.ok()).toBeFalsy();
|
||||
expect(res.status()).toBe(404);
|
||||
});
|
||||
|
||||
test('should allow authorized access to envelope attachment find endpoint', async ({
|
||||
request,
|
||||
}) => {
|
||||
const doc = await seedBlankDocument(userA, teamA.id);
|
||||
|
||||
const res = await request.get(
|
||||
`${WEBAPP_BASE_URL}/api/v2-beta/envelope/attachment?envelopeId=${doc.id}`,
|
||||
{
|
||||
headers: { Authorization: `Bearer ${tokenA}` },
|
||||
},
|
||||
);
|
||||
|
||||
expect(res.ok()).toBeTruthy();
|
||||
expect(res.status()).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Envelope attachment create endpoint', () => {
|
||||
test('should block unauthorized access to envelope attachment create endpoint', async ({
|
||||
request,
|
||||
}) => {
|
||||
const doc = await seedBlankDocument(userA, teamA.id);
|
||||
|
||||
const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/envelope/attachment/create`, {
|
||||
headers: { Authorization: `Bearer ${tokenB}` },
|
||||
data: {
|
||||
envelopeId: doc.id,
|
||||
data: {
|
||||
label: 'Test Attachment',
|
||||
data: 'https://example.com/file.pdf',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.ok()).toBeFalsy();
|
||||
expect(res.status()).toBe(404);
|
||||
});
|
||||
|
||||
test('should allow authorized access to envelope attachment create endpoint', async ({
|
||||
request,
|
||||
}) => {
|
||||
const doc = await seedBlankDocument(userA, teamA.id);
|
||||
|
||||
const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/envelope/attachment/create`, {
|
||||
headers: { Authorization: `Bearer ${tokenA}` },
|
||||
data: {
|
||||
envelopeId: doc.id,
|
||||
data: {
|
||||
label: 'Test Attachment',
|
||||
data: 'https://example.com/file.pdf',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.ok()).toBeTruthy();
|
||||
expect(res.status()).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Envelope attachment update endpoint', () => {
|
||||
test('should block unauthorized access to envelope attachment update endpoint', async ({
|
||||
request,
|
||||
}) => {
|
||||
const doc = await seedBlankDocument(userA, teamA.id);
|
||||
|
||||
const attachment = await prisma.envelopeAttachment.create({
|
||||
data: {
|
||||
envelopeId: doc.id,
|
||||
type: 'link',
|
||||
label: 'Original Label',
|
||||
data: 'https://example.com/original.pdf',
|
||||
},
|
||||
});
|
||||
|
||||
const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/envelope/attachment/update`, {
|
||||
headers: { Authorization: `Bearer ${tokenB}` },
|
||||
data: {
|
||||
id: attachment.id,
|
||||
data: {
|
||||
label: 'Updated Label',
|
||||
data: 'https://example.com/updated.pdf',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.ok()).toBeFalsy();
|
||||
expect(res.status()).toBe(404);
|
||||
});
|
||||
|
||||
test('should allow authorized access to envelope attachment update endpoint', async ({
|
||||
request,
|
||||
}) => {
|
||||
const doc = await seedBlankDocument(userA, teamA.id);
|
||||
|
||||
const attachment = await prisma.envelopeAttachment.create({
|
||||
data: {
|
||||
envelopeId: doc.id,
|
||||
type: 'link',
|
||||
label: 'Original Label',
|
||||
data: 'https://example.com/original.pdf',
|
||||
},
|
||||
});
|
||||
|
||||
const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/envelope/attachment/update`, {
|
||||
headers: { Authorization: `Bearer ${tokenA}` },
|
||||
data: {
|
||||
id: attachment.id,
|
||||
data: {
|
||||
label: 'Updated Label',
|
||||
data: 'https://example.com/updated.pdf',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.ok()).toBeTruthy();
|
||||
expect(res.status()).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Envelope attachment delete endpoint', () => {
|
||||
test('should block unauthorized access to envelope attachment delete endpoint', async ({
|
||||
request,
|
||||
}) => {
|
||||
const doc = await seedBlankDocument(userA, teamA.id);
|
||||
|
||||
const attachment = await prisma.envelopeAttachment.create({
|
||||
data: {
|
||||
envelopeId: doc.id,
|
||||
type: 'link',
|
||||
label: 'Test Attachment',
|
||||
data: 'https://example.com/file.pdf',
|
||||
},
|
||||
});
|
||||
|
||||
const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/envelope/attachment/delete`, {
|
||||
headers: { Authorization: `Bearer ${tokenB}` },
|
||||
data: { id: attachment.id },
|
||||
});
|
||||
|
||||
expect(res.ok()).toBeFalsy();
|
||||
expect(res.status()).toBe(404);
|
||||
});
|
||||
|
||||
test('should allow authorized access to envelope attachment delete endpoint', async ({
|
||||
request,
|
||||
}) => {
|
||||
const doc = await seedBlankDocument(userA, teamA.id);
|
||||
|
||||
const attachment = await prisma.envelopeAttachment.create({
|
||||
data: {
|
||||
envelopeId: doc.id,
|
||||
type: 'link',
|
||||
label: 'Test Attachment',
|
||||
data: 'https://example.com/file.pdf',
|
||||
},
|
||||
});
|
||||
|
||||
const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/envelope/attachment/delete`, {
|
||||
headers: { Authorization: `Bearer ${tokenA}` },
|
||||
data: { id: attachment.id },
|
||||
});
|
||||
|
||||
expect(res.ok()).toBeTruthy();
|
||||
expect(res.status()).toBe(200);
|
||||
});
|
||||
});
|
||||
});
|
||||
+390
@@ -0,0 +1,390 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
import type { Team, User } from '@prisma/client';
|
||||
import { EnvelopeType, FolderType } from '@prisma/client';
|
||||
|
||||
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 { seedBlankDocument } from '@documenso/prisma/seed/documents';
|
||||
import { seedBlankFolder } from '@documenso/prisma/seed/folders';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
|
||||
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
|
||||
|
||||
test.describe.configure({
|
||||
mode: 'parallel',
|
||||
});
|
||||
|
||||
// Todo: Remove skip once the API endpoints are released.
|
||||
test.describe.skip('Envelope Bulk API V2', () => {
|
||||
let userA: User, teamA: Team, userB: User, teamB: Team, tokenA: string, tokenB: string;
|
||||
|
||||
test.beforeEach(async () => {
|
||||
({ user: userA, team: teamA } = await seedUser());
|
||||
({ token: tokenA } = await createApiToken({
|
||||
userId: userA.id,
|
||||
teamId: teamA.id,
|
||||
tokenName: 'userA',
|
||||
expiresIn: null,
|
||||
}));
|
||||
|
||||
({ user: userB, team: teamB } = await seedUser());
|
||||
({ token: tokenB } = await createApiToken({
|
||||
userId: userB.id,
|
||||
teamId: teamB.id,
|
||||
tokenName: 'userB',
|
||||
expiresIn: null,
|
||||
}));
|
||||
});
|
||||
|
||||
test.describe('Envelope bulk move endpoint', () => {
|
||||
test('should block unauthorized access to envelope bulk move endpoint', async ({ request }) => {
|
||||
// Create a document owned by userA
|
||||
const doc = await seedBlankDocument(userA, teamA.id);
|
||||
|
||||
// UserB tries to move userA's document
|
||||
const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/envelope/bulk/move`, {
|
||||
headers: { Authorization: `Bearer ${tokenB}` },
|
||||
data: {
|
||||
envelopeIds: [doc.id],
|
||||
envelopeType: EnvelopeType.DOCUMENT,
|
||||
folderId: null,
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.ok()).toBeTruthy();
|
||||
expect(res.status()).toBe(200);
|
||||
|
||||
const body = await res.json();
|
||||
expect(body.movedCount).toBe(0);
|
||||
|
||||
// Verify in database that the document was not modified
|
||||
const docInDb = await prisma.envelope.findFirst({
|
||||
where: { id: doc.id },
|
||||
});
|
||||
|
||||
expect(docInDb).not.toBeNull();
|
||||
expect(docInDb?.folderId).toBeNull();
|
||||
});
|
||||
|
||||
test('should block moving envelopes to unauthorized folder', async ({ request }) => {
|
||||
// Create a document owned by userB
|
||||
const doc = await seedBlankDocument(userB, teamB.id);
|
||||
|
||||
// Create a folder owned by userA
|
||||
const folderA = await seedBlankFolder(userA, teamA.id, {
|
||||
createFolderOptions: {
|
||||
name: 'UserA Folder',
|
||||
type: FolderType.DOCUMENT,
|
||||
},
|
||||
});
|
||||
|
||||
// UserB tries to move their document to userA's folder
|
||||
const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/envelope/bulk/move`, {
|
||||
headers: { Authorization: `Bearer ${tokenB}` },
|
||||
data: {
|
||||
envelopeIds: [doc.id],
|
||||
envelopeType: EnvelopeType.DOCUMENT,
|
||||
folderId: folderA.id,
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.ok()).toBeFalsy();
|
||||
expect(res.status()).toBe(404);
|
||||
|
||||
// Verify in database that the document was not modified
|
||||
const docInDb = await prisma.envelope.findFirst({
|
||||
where: { id: doc.id },
|
||||
});
|
||||
|
||||
expect(docInDb).not.toBeNull();
|
||||
expect(docInDb?.folderId).toBeNull();
|
||||
});
|
||||
|
||||
test('should allow authorized access to envelope bulk move endpoint', async ({ request }) => {
|
||||
// Create a document owned by userA
|
||||
const doc = await seedBlankDocument(userA, teamA.id);
|
||||
|
||||
// Create a folder owned by userA
|
||||
const folderA = await seedBlankFolder(userA, teamA.id, {
|
||||
createFolderOptions: {
|
||||
name: 'UserA Folder',
|
||||
type: FolderType.DOCUMENT,
|
||||
},
|
||||
});
|
||||
|
||||
// UserA moves their own document to their own folder
|
||||
const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/envelope/bulk/move`, {
|
||||
headers: { Authorization: `Bearer ${tokenA}` },
|
||||
data: {
|
||||
envelopeIds: [doc.id],
|
||||
envelopeType: EnvelopeType.DOCUMENT,
|
||||
folderId: folderA.id,
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.ok()).toBeTruthy();
|
||||
expect(res.status()).toBe(200);
|
||||
|
||||
const body = await res.json();
|
||||
expect(body.movedCount).toBe(1);
|
||||
|
||||
// Verify in database that the document was moved to the folder
|
||||
const docInDb = await prisma.envelope.findFirst({
|
||||
where: { id: doc.id },
|
||||
});
|
||||
|
||||
expect(docInDb).not.toBeNull();
|
||||
expect(docInDb?.folderId).toBe(folderA.id);
|
||||
});
|
||||
|
||||
test('should only move authorized envelopes when given mixed array of envelope IDs', async ({
|
||||
request,
|
||||
}) => {
|
||||
// Create documents owned by userA
|
||||
const docA1 = await seedBlankDocument(userA, teamA.id);
|
||||
const docA2 = await seedBlankDocument(userA, teamA.id);
|
||||
|
||||
// Create a document owned by userB
|
||||
const docB = await seedBlankDocument(userB, teamB.id);
|
||||
|
||||
// Create a folder owned by userA
|
||||
const folderA = await seedBlankFolder(userA, teamA.id, {
|
||||
createFolderOptions: {
|
||||
name: 'UserA Folder',
|
||||
type: FolderType.DOCUMENT,
|
||||
},
|
||||
});
|
||||
|
||||
// UserA tries to move a mix of their own documents and userB's document
|
||||
const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/envelope/bulk/move`, {
|
||||
headers: { Authorization: `Bearer ${tokenA}` },
|
||||
data: {
|
||||
envelopeIds: [docA1.id, docB.id, docA2.id],
|
||||
envelopeType: EnvelopeType.DOCUMENT,
|
||||
folderId: folderA.id,
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.ok()).toBeTruthy();
|
||||
expect(res.status()).toBe(200);
|
||||
|
||||
const body = await res.json();
|
||||
// Only userA's documents should be moved
|
||||
expect(body.movedCount).toBe(2);
|
||||
|
||||
// Verify userA's documents were moved
|
||||
const docA1InDb = await prisma.envelope.findFirst({
|
||||
where: { id: docA1.id },
|
||||
});
|
||||
expect(docA1InDb).not.toBeNull();
|
||||
expect(docA1InDb?.folderId).toBe(folderA.id);
|
||||
|
||||
const docA2InDb = await prisma.envelope.findFirst({
|
||||
where: { id: docA2.id },
|
||||
});
|
||||
expect(docA2InDb).not.toBeNull();
|
||||
expect(docA2InDb?.folderId).toBe(folderA.id);
|
||||
|
||||
// Verify userB's document was NOT moved
|
||||
const docBInDb = await prisma.envelope.findFirst({
|
||||
where: { id: docB.id },
|
||||
});
|
||||
expect(docBInDb).not.toBeNull();
|
||||
expect(docBInDb?.folderId).toBeNull();
|
||||
});
|
||||
|
||||
test('should move zero envelopes when all envelope IDs in array are unauthorized', async ({
|
||||
request,
|
||||
}) => {
|
||||
// Create documents owned by userB
|
||||
const docB1 = await seedBlankDocument(userB, teamB.id);
|
||||
const docB2 = await seedBlankDocument(userB, teamB.id);
|
||||
|
||||
// Create a folder owned by userA
|
||||
const folderA = await seedBlankFolder(userA, teamA.id, {
|
||||
createFolderOptions: {
|
||||
name: 'UserA Folder',
|
||||
type: FolderType.DOCUMENT,
|
||||
},
|
||||
});
|
||||
|
||||
// UserA tries to move userB's documents
|
||||
const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/envelope/bulk/move`, {
|
||||
headers: { Authorization: `Bearer ${tokenA}` },
|
||||
data: {
|
||||
envelopeIds: [docB1.id, docB2.id],
|
||||
envelopeType: EnvelopeType.DOCUMENT,
|
||||
folderId: folderA.id,
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.ok()).toBeTruthy();
|
||||
expect(res.status()).toBe(200);
|
||||
|
||||
const body = await res.json();
|
||||
expect(body.movedCount).toBe(0);
|
||||
|
||||
// Verify userB's documents were NOT moved
|
||||
const docB1InDb = await prisma.envelope.findFirst({
|
||||
where: { id: docB1.id },
|
||||
});
|
||||
expect(docB1InDb).not.toBeNull();
|
||||
expect(docB1InDb?.folderId).toBeNull();
|
||||
|
||||
const docB2InDb = await prisma.envelope.findFirst({
|
||||
where: { id: docB2.id },
|
||||
});
|
||||
expect(docB2InDb).not.toBeNull();
|
||||
expect(docB2InDb?.folderId).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Envelope bulk delete endpoint', () => {
|
||||
test('should block unauthorized access to envelope bulk delete endpoint', async ({
|
||||
request,
|
||||
}) => {
|
||||
// Create a document owned by userA
|
||||
const doc = await seedBlankDocument(userA, teamA.id);
|
||||
|
||||
// UserB tries to delete userA's document
|
||||
const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/envelope/bulk/delete`, {
|
||||
headers: { Authorization: `Bearer ${tokenB}` },
|
||||
data: {
|
||||
envelopeIds: [doc.id],
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.ok()).toBeTruthy();
|
||||
expect(res.status()).toBe(200);
|
||||
|
||||
const body = await res.json();
|
||||
expect(body.deletedCount).toBe(0);
|
||||
// Unauthorized envelope ID should be in failedIds
|
||||
expect(body.failedIds).toEqual([doc.id]);
|
||||
|
||||
// Verify in database that the document still exists
|
||||
const docInDb = await prisma.envelope.findFirst({
|
||||
where: { id: doc.id },
|
||||
});
|
||||
|
||||
expect(docInDb).not.toBeNull();
|
||||
expect(docInDb?.id).toBe(doc.id);
|
||||
expect(docInDb?.deletedAt).toBeNull();
|
||||
});
|
||||
|
||||
test('should allow authorized access to envelope bulk delete endpoint', async ({ request }) => {
|
||||
// Create a document owned by userA
|
||||
const doc = await seedBlankDocument(userA, teamA.id);
|
||||
|
||||
// UserA deletes their own document
|
||||
const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/envelope/bulk/delete`, {
|
||||
headers: { Authorization: `Bearer ${tokenA}` },
|
||||
data: {
|
||||
envelopeIds: [doc.id],
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.ok()).toBeTruthy();
|
||||
expect(res.status()).toBe(200);
|
||||
|
||||
const body = await res.json();
|
||||
expect(body.deletedCount).toBe(1);
|
||||
expect(body.failedIds).toEqual([]);
|
||||
|
||||
// Verify in database that the document no longer exists
|
||||
const docInDb = await prisma.envelope.findFirst({
|
||||
where: { id: doc.id },
|
||||
});
|
||||
|
||||
expect(docInDb).toBeNull();
|
||||
});
|
||||
|
||||
test('should only delete authorized envelopes when given mixed array of envelope IDs', async ({
|
||||
request,
|
||||
}) => {
|
||||
// Create documents owned by userA
|
||||
const docA1 = await seedBlankDocument(userA, teamA.id);
|
||||
const docA2 = await seedBlankDocument(userA, teamA.id);
|
||||
|
||||
// Create a document owned by userB
|
||||
const docB = await seedBlankDocument(userB, teamB.id);
|
||||
|
||||
// UserA tries to delete a mix of their own documents and userB's document
|
||||
const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/envelope/bulk/delete`, {
|
||||
headers: { Authorization: `Bearer ${tokenA}` },
|
||||
data: {
|
||||
envelopeIds: [docA1.id, docB.id, docA2.id],
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.ok()).toBeTruthy();
|
||||
expect(res.status()).toBe(200);
|
||||
|
||||
const body = await res.json();
|
||||
// Only userA's documents should be deleted
|
||||
expect(body.deletedCount).toBe(2);
|
||||
// Unauthorized envelope ID (docB) should be in failedIds
|
||||
expect(body.failedIds).toEqual([docB.id]);
|
||||
|
||||
// Verify userA's documents were deleted
|
||||
const docA1InDb = await prisma.envelope.findFirst({
|
||||
where: { id: docA1.id },
|
||||
});
|
||||
expect(docA1InDb).toBeNull();
|
||||
|
||||
const docA2InDb = await prisma.envelope.findFirst({
|
||||
where: { id: docA2.id },
|
||||
});
|
||||
expect(docA2InDb).toBeNull();
|
||||
|
||||
// Verify userB's document was NOT deleted
|
||||
const docBInDb = await prisma.envelope.findFirst({
|
||||
where: { id: docB.id },
|
||||
});
|
||||
expect(docBInDb).not.toBeNull();
|
||||
expect(docBInDb?.id).toBe(docB.id);
|
||||
expect(docBInDb?.deletedAt).toBeNull();
|
||||
});
|
||||
|
||||
test('should delete zero envelopes when all envelope IDs in array are unauthorized', async ({
|
||||
request,
|
||||
}) => {
|
||||
// Create documents owned by userB
|
||||
const docB1 = await seedBlankDocument(userB, teamB.id);
|
||||
const docB2 = await seedBlankDocument(userB, teamB.id);
|
||||
|
||||
// UserA tries to delete userB's documents
|
||||
const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/envelope/bulk/delete`, {
|
||||
headers: { Authorization: `Bearer ${tokenA}` },
|
||||
data: {
|
||||
envelopeIds: [docB1.id, docB2.id],
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.ok()).toBeTruthy();
|
||||
expect(res.status()).toBe(200);
|
||||
|
||||
const body = await res.json();
|
||||
expect(body.deletedCount).toBe(0);
|
||||
// All unauthorized envelope IDs should be in failedIds
|
||||
expect(body.failedIds).toEqual(expect.arrayContaining([docB1.id, docB2.id]));
|
||||
expect(body.failedIds).toHaveLength(2);
|
||||
|
||||
// Verify userB's documents were NOT deleted
|
||||
const docB1InDb = await prisma.envelope.findFirst({
|
||||
where: { id: docB1.id },
|
||||
});
|
||||
expect(docB1InDb).not.toBeNull();
|
||||
expect(docB1InDb?.id).toBe(docB1.id);
|
||||
expect(docB1InDb?.deletedAt).toBeNull();
|
||||
|
||||
const docB2InDb = await prisma.envelope.findFirst({
|
||||
where: { id: docB2.id },
|
||||
});
|
||||
expect(docB2InDb).not.toBeNull();
|
||||
expect(docB2InDb?.id).toBe(docB2.id);
|
||||
expect(docB2InDb?.deletedAt).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,307 @@
|
||||
import { type Page, expect, test } from '@playwright/test';
|
||||
import path from 'path';
|
||||
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { RecipientRole } from '@documenso/prisma/client';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
|
||||
import { apiSignin } from '../fixtures/authentication';
|
||||
|
||||
const FIXTURES_DIR = path.join(__dirname, '../../../assets/fixtures/auto-placement');
|
||||
|
||||
const SINGLE_PLACEHOLDER_PDF_PATH = path.join(
|
||||
FIXTURES_DIR,
|
||||
'project-proposal-single-recipient.pdf',
|
||||
);
|
||||
|
||||
const MULTIPLE_PLACEHOLDER_PDF_PATH = path.join(
|
||||
FIXTURES_DIR,
|
||||
'project-proposal-multiple-fields-and-recipients.pdf',
|
||||
);
|
||||
|
||||
const NO_RECIPIENT_PDF_PATH = path.join(FIXTURES_DIR, 'no-recipient-placeholders.pdf');
|
||||
|
||||
const INVALID_FIELD_TYPE_PDF_PATH = path.join(FIXTURES_DIR, 'invalid-field-type.pdf');
|
||||
|
||||
const FIELD_TYPE_ONLY_PDF_PATH = path.join(FIXTURES_DIR, 'field-type-only.pdf');
|
||||
|
||||
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,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const setupUserAndSignIn = async (page: Page) => {
|
||||
const { user, team } = await seedUser();
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
redirectPath: `/t/${team.url}/documents`,
|
||||
});
|
||||
|
||||
return { user, team };
|
||||
};
|
||||
|
||||
const uploadPdf = async (page: Page, team: { url: string }, pdfPath: string) => {
|
||||
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(pdfPath);
|
||||
|
||||
// Wait for redirect to v2 envelope editor.
|
||||
await page.waitForURL(new RegExp(`/t/${team.url}/documents/envelope_.*`));
|
||||
|
||||
// Extract envelope ID from URL.
|
||||
const urlParts = page.url().split('/');
|
||||
const envelopeId = urlParts.find((part) => part.startsWith('envelope_'));
|
||||
|
||||
if (!envelopeId) {
|
||||
throw new Error('Could not extract envelope ID from URL');
|
||||
}
|
||||
|
||||
return envelopeId;
|
||||
};
|
||||
|
||||
test.describe('PDF Placeholders with single recipient', () => {
|
||||
test('[AUTO_PLACING_FIELDS]: should create placeholder recipients even with default recipients', async ({
|
||||
page,
|
||||
}) => {
|
||||
const { user, team } = await seedUser();
|
||||
|
||||
await setTeamDefaultRecipients(team.id, [
|
||||
{
|
||||
email: user.email,
|
||||
name: user.name || user.email,
|
||||
role: RecipientRole.CC,
|
||||
},
|
||||
]);
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
redirectPath: `/t/${team.url}/documents`,
|
||||
});
|
||||
|
||||
const envelopeId = await uploadPdf(page, team, SINGLE_PLACEHOLDER_PDF_PATH);
|
||||
|
||||
await expect(async () => {
|
||||
const recipients = await prisma.recipient.findMany({
|
||||
where: { envelopeId },
|
||||
});
|
||||
|
||||
const placeholderRecipient = recipients.find(
|
||||
(recipient) => recipient.email === 'recipient.1@documenso.com',
|
||||
);
|
||||
|
||||
const defaultRecipient = recipients.find((recipient) => recipient.email === user.email);
|
||||
|
||||
expect(placeholderRecipient).toBeDefined();
|
||||
expect(defaultRecipient).toBeDefined();
|
||||
|
||||
const fields = await prisma.field.findMany({
|
||||
where: { envelopeId },
|
||||
});
|
||||
|
||||
expect(fields.length).toBeGreaterThan(0);
|
||||
expect(fields.every((field) => field.recipientId === placeholderRecipient!.id)).toBe(true);
|
||||
}).toPass();
|
||||
});
|
||||
|
||||
test('[AUTO_PLACING_FIELDS]: should automatically create recipients from PDF placeholders', async ({
|
||||
page,
|
||||
}) => {
|
||||
const { team } = await setupUserAndSignIn(page);
|
||||
await uploadPdf(page, team, SINGLE_PLACEHOLDER_PDF_PATH);
|
||||
|
||||
// V2 editor shows recipients on the upload page under "Recipients" heading.
|
||||
await expect(page.getByRole('heading', { name: 'Recipients' })).toBeVisible();
|
||||
await expect(page.getByTestId('signer-email-input').first()).toHaveValue(
|
||||
'recipient.1@documenso.com',
|
||||
);
|
||||
await expect(page.getByLabel('Name').first()).toHaveValue('Recipient 1');
|
||||
});
|
||||
|
||||
test('[AUTO_PLACING_FIELDS]: should automatically place fields from PDF placeholders', async ({
|
||||
page,
|
||||
}) => {
|
||||
const { team } = await setupUserAndSignIn(page);
|
||||
const envelopeId = await uploadPdf(page, team, SINGLE_PLACEHOLDER_PDF_PATH);
|
||||
|
||||
// V2 editor renders fields on a Konva canvas, so we verify via the database.
|
||||
await expect(async () => {
|
||||
const fields = await prisma.field.findMany({
|
||||
where: { envelopeId },
|
||||
});
|
||||
|
||||
const fieldTypes = fields.map((f) => f.type).sort();
|
||||
expect(fieldTypes).toEqual(['EMAIL', 'NAME', 'SIGNATURE', 'TEXT'].sort());
|
||||
}).toPass();
|
||||
});
|
||||
|
||||
test('[AUTO_PLACING_FIELDS]: should automatically configure fields from PDF placeholders', async ({
|
||||
page,
|
||||
}) => {
|
||||
const { team } = await setupUserAndSignIn(page);
|
||||
const envelopeId = await uploadPdf(page, team, SINGLE_PLACEHOLDER_PDF_PATH);
|
||||
|
||||
// Verify field metadata was correctly parsed from the placeholder.
|
||||
await expect(async () => {
|
||||
const textField = await prisma.field.findFirst({
|
||||
where: { envelopeId, type: 'TEXT' },
|
||||
});
|
||||
|
||||
expect(textField).toBeDefined();
|
||||
expect(textField!.fieldMeta).toBeDefined();
|
||||
|
||||
const meta = textField!.fieldMeta as Record<string, unknown>;
|
||||
expect(meta.required).toBe(true);
|
||||
expect(meta.textAlign).toBe('right');
|
||||
}).toPass();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('PDF Placeholders with multiple recipients', () => {
|
||||
test('[AUTO_PLACING_FIELDS]: should automatically create recipients from PDF placeholders', async ({
|
||||
page,
|
||||
}) => {
|
||||
const { team } = await setupUserAndSignIn(page);
|
||||
const envelopeId = await uploadPdf(page, team, MULTIPLE_PLACEHOLDER_PDF_PATH);
|
||||
|
||||
// V2 editor shows recipients on the upload page.
|
||||
await expect(page.getByRole('heading', { name: 'Recipients' })).toBeVisible();
|
||||
|
||||
await expect(page.getByTestId('signer-email-input').first()).toHaveValue(
|
||||
'recipient.1@documenso.com',
|
||||
);
|
||||
|
||||
await expect(page.getByTestId('signer-email-input').nth(1)).toHaveValue(
|
||||
'recipient.2@documenso.com',
|
||||
);
|
||||
|
||||
await expect(page.getByTestId('signer-email-input').nth(2)).toHaveValue(
|
||||
'recipient.3@documenso.com',
|
||||
);
|
||||
|
||||
// Verify recipients via the database for name validation since the v2 editor
|
||||
// only shows the "Name" label on the first recipient row.
|
||||
await expect(async () => {
|
||||
const recipients = await prisma.recipient.findMany({
|
||||
where: { envelopeId },
|
||||
orderBy: { signingOrder: 'asc' },
|
||||
});
|
||||
|
||||
expect(recipients).toHaveLength(3);
|
||||
expect(recipients[0].name).toBe('Recipient 1');
|
||||
expect(recipients[1].name).toBe('Recipient 2');
|
||||
expect(recipients[2].name).toBe('Recipient 3');
|
||||
}).toPass();
|
||||
});
|
||||
|
||||
test('[AUTO_PLACING_FIELDS]: should automatically create fields from PDF placeholders', async ({
|
||||
page,
|
||||
}) => {
|
||||
const { team } = await setupUserAndSignIn(page);
|
||||
const envelopeId = await uploadPdf(page, team, MULTIPLE_PLACEHOLDER_PDF_PATH);
|
||||
|
||||
// V2 editor renders fields on a Konva canvas, so we verify via the database.
|
||||
await expect(async () => {
|
||||
const fields = await prisma.field.findMany({
|
||||
where: { envelopeId },
|
||||
});
|
||||
|
||||
const fieldTypes = fields.map((f) => f.type).sort();
|
||||
expect(fieldTypes).toEqual(
|
||||
['SIGNATURE', 'SIGNATURE', 'SIGNATURE', 'EMAIL', 'EMAIL', 'NAME', 'TEXT', 'NUMBER'].sort(),
|
||||
);
|
||||
}).toPass();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('PDF Placeholders without recipient identifier', () => {
|
||||
test('[AUTO_PLACING_FIELDS]: should skip placeholders without a recipient identifier', async ({
|
||||
page,
|
||||
}) => {
|
||||
const { team } = await setupUserAndSignIn(page);
|
||||
const envelopeId = await uploadPdf(page, team, NO_RECIPIENT_PDF_PATH);
|
||||
|
||||
// Placeholders like {{signature}}, {{name}}, {{email}} have no recipient
|
||||
// identifier and should be skipped entirely. No fields or auto-created
|
||||
// recipients should exist.
|
||||
await expect(async () => {
|
||||
const fields = await prisma.field.findMany({
|
||||
where: { envelopeId },
|
||||
});
|
||||
|
||||
expect(fields).toHaveLength(0);
|
||||
}).toPass();
|
||||
});
|
||||
|
||||
test('[AUTO_PLACING_FIELDS]: should skip a bare field type placeholder', async ({ page }) => {
|
||||
const { team } = await setupUserAndSignIn(page);
|
||||
const envelopeId = await uploadPdf(page, team, FIELD_TYPE_ONLY_PDF_PATH);
|
||||
|
||||
await expect(async () => {
|
||||
const fields = await prisma.field.findMany({
|
||||
where: { envelopeId },
|
||||
});
|
||||
|
||||
expect(fields).toHaveLength(0);
|
||||
}).toPass();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('PDF Placeholders with invalid field types', () => {
|
||||
test('[AUTO_PLACING_FIELDS]: should skip invalid field types and process valid ones', async ({
|
||||
page,
|
||||
}) => {
|
||||
const { team } = await setupUserAndSignIn(page);
|
||||
const envelopeId = await uploadPdf(page, team, INVALID_FIELD_TYPE_PDF_PATH);
|
||||
|
||||
// Only the valid placeholders (signature,r1 and email,r2) should create fields.
|
||||
// The invalid ones (bogus,r1 and foobar,r2) should be skipped.
|
||||
await expect(async () => {
|
||||
const fields = await prisma.field.findMany({
|
||||
where: { envelopeId },
|
||||
});
|
||||
|
||||
const fieldTypes = fields.map((f) => f.type).sort();
|
||||
expect(fieldTypes).toEqual(['EMAIL', 'SIGNATURE'].sort());
|
||||
}).toPass();
|
||||
|
||||
// Both valid recipients should still be created.
|
||||
await expect(async () => {
|
||||
const recipients = await prisma.recipient.findMany({
|
||||
where: { envelopeId },
|
||||
orderBy: { signingOrder: 'asc' },
|
||||
});
|
||||
|
||||
expect(recipients).toHaveLength(2);
|
||||
}).toPass();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { seedDraftDocument } from '@documenso/prisma/seed/documents';
|
||||
import { seedBlankFolder } from '@documenso/prisma/seed/folders';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
|
||||
import { apiSignin } from '../fixtures/authentication';
|
||||
import { expectToastTextToBeVisible } from '../fixtures/generic';
|
||||
|
||||
test.describe.configure({ mode: 'parallel' });
|
||||
|
||||
const seedBulkActionsTestRequirements = async () => {
|
||||
const sender = await seedUser({ setTeamEmailAsOwner: true });
|
||||
|
||||
const [doc1, doc2, doc3] = await Promise.all([
|
||||
seedDraftDocument(sender.user, sender.team.id, [], {
|
||||
createDocumentOptions: { title: 'Bulk Test Doc 1' },
|
||||
}),
|
||||
seedDraftDocument(sender.user, sender.team.id, [], {
|
||||
createDocumentOptions: { title: 'Bulk Test Doc 2' },
|
||||
}),
|
||||
seedDraftDocument(sender.user, sender.team.id, [], {
|
||||
createDocumentOptions: { title: 'Bulk Test Doc 3' },
|
||||
}),
|
||||
]);
|
||||
|
||||
const folder = await seedBlankFolder(sender.user, sender.team.id, {
|
||||
createFolderOptions: {
|
||||
name: 'Target Folder',
|
||||
teamId: sender.team.id,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
sender,
|
||||
documents: [doc1, doc2, doc3],
|
||||
folder,
|
||||
};
|
||||
};
|
||||
|
||||
test('[BULK_ACTIONS]: can select multiple documents with checkboxes', async ({ page }) => {
|
||||
const { sender } = await seedBulkActionsTestRequirements();
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: sender.user.email,
|
||||
redirectPath: `/t/${sender.team.url}/documents`,
|
||||
});
|
||||
|
||||
await page.locator('tr', { hasText: 'Bulk Test Doc 1' }).getByRole('checkbox').click();
|
||||
await expect(page.getByText('1 selected')).toBeVisible();
|
||||
|
||||
await page.locator('tr', { hasText: 'Bulk Test Doc 2' }).getByRole('checkbox').click();
|
||||
await expect(page.getByText('2 selected')).toBeVisible();
|
||||
});
|
||||
|
||||
test('[BULK_ACTIONS]: header checkbox selects all documents on page', async ({ page }) => {
|
||||
const { sender, documents } = await seedBulkActionsTestRequirements();
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: sender.user.email,
|
||||
redirectPath: `/t/${sender.team.url}/documents`,
|
||||
});
|
||||
|
||||
await page.locator('thead').getByRole('checkbox').click();
|
||||
|
||||
await expect(page.getByText(`${documents.length} selected`)).toBeVisible();
|
||||
});
|
||||
|
||||
test('[BULK_ACTIONS]: can clear selection with X button', async ({ page }) => {
|
||||
const { sender } = await seedBulkActionsTestRequirements();
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: sender.user.email,
|
||||
redirectPath: `/t/${sender.team.url}/documents`,
|
||||
});
|
||||
|
||||
await page.locator('thead').getByRole('checkbox').click();
|
||||
await expect(page.getByText(/\d+ selected/)).toBeVisible();
|
||||
|
||||
await page.getByLabel('Clear selection').click();
|
||||
|
||||
await expect(page.getByText(/\d+ selected/)).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('[BULK_ACTIONS]: can move multiple documents to a folder', async ({ page }) => {
|
||||
const { sender, folder } = await seedBulkActionsTestRequirements();
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: sender.user.email,
|
||||
redirectPath: `/t/${sender.team.url}/documents`,
|
||||
});
|
||||
|
||||
await page.locator('tr', { hasText: 'Bulk Test Doc 1' }).getByRole('checkbox').click();
|
||||
await page.locator('tr', { hasText: 'Bulk Test Doc 2' }).getByRole('checkbox').click();
|
||||
await page.getByRole('button', { name: 'Move to Folder' }).click();
|
||||
|
||||
await expect(page.getByRole('dialog')).toBeVisible();
|
||||
await expect(page.getByText('Move Documents to Folder')).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: folder.name }).click();
|
||||
await page.getByRole('button', { name: 'Move' }).click();
|
||||
|
||||
await expectToastTextToBeVisible(page, 'Selected items have been moved.');
|
||||
|
||||
await page.goto(`/t/${sender.team.url}/documents/f/${folder.id}`);
|
||||
await expect(page.getByRole('link', { name: 'Bulk Test Doc 1' })).toBeVisible();
|
||||
await expect(page.getByRole('link', { name: 'Bulk Test Doc 2' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('[BULK_ACTIONS]: can delete multiple draft documents', async ({ page }) => {
|
||||
const { sender } = await seedBulkActionsTestRequirements();
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: sender.user.email,
|
||||
redirectPath: `/t/${sender.team.url}/documents`,
|
||||
});
|
||||
|
||||
await page.locator('tr', { hasText: 'Bulk Test Doc 1' }).getByRole('checkbox').click();
|
||||
await page.locator('tr', { hasText: 'Bulk Test Doc 2' }).getByRole('checkbox').click();
|
||||
|
||||
await page.getByRole('button', { name: 'Delete' }).click();
|
||||
|
||||
await expect(page.getByRole('dialog')).toBeVisible();
|
||||
await expect(page.getByText('Delete Documents')).toBeVisible();
|
||||
await expect(page.getByText('You are about to delete 2 documents')).toBeVisible();
|
||||
await expect(page.getByText('irreversible')).toBeVisible();
|
||||
|
||||
await page.getByRole('dialog').getByRole('button', { name: 'Delete' }).click();
|
||||
|
||||
await expectToastTextToBeVisible(page, 'Documents deleted');
|
||||
|
||||
await expect(page.getByRole('link', { name: 'Bulk Test Doc 1' })).not.toBeVisible();
|
||||
await expect(page.getByRole('link', { name: 'Bulk Test Doc 2' })).not.toBeVisible();
|
||||
|
||||
await expect(page.getByRole('link', { name: 'Bulk Test Doc 3' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('[BULK_ACTIONS]: selection clears after successful move', async ({ page }) => {
|
||||
const { sender, folder } = await seedBulkActionsTestRequirements();
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: sender.user.email,
|
||||
redirectPath: `/t/${sender.team.url}/documents`,
|
||||
});
|
||||
|
||||
await page.locator('tr', { hasText: 'Bulk Test Doc 1' }).getByRole('checkbox').click();
|
||||
await expect(page.getByText('1 selected')).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Move to Folder' }).click();
|
||||
await page.getByRole('button', { name: folder.name }).click();
|
||||
await page.getByRole('button', { name: 'Move' }).click();
|
||||
|
||||
await expectToastTextToBeVisible(page, 'Selected items have been moved.');
|
||||
await expect(page.getByText(/\d+ selected/)).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('[BULK_ACTIONS]: selection clears after successful delete', async ({ page }) => {
|
||||
const { sender } = await seedBulkActionsTestRequirements();
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: sender.user.email,
|
||||
redirectPath: `/t/${sender.team.url}/documents`,
|
||||
});
|
||||
|
||||
await page.locator('tr', { hasText: 'Bulk Test Doc 1' }).getByRole('checkbox').click();
|
||||
await expect(page.getByText('1 selected')).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Delete' }).click();
|
||||
await page.getByRole('dialog').getByRole('button', { name: 'Delete' }).click();
|
||||
|
||||
await expectToastTextToBeVisible(page, 'Documents deleted');
|
||||
await expect(page.getByText(/\d+ selected/)).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('[BULK_ACTIONS]: can search for folders in move dialog', async ({ page }) => {
|
||||
const { sender, folder } = await seedBulkActionsTestRequirements();
|
||||
|
||||
const otherFolder = await seedBlankFolder(sender.user, sender.team.id, {
|
||||
createFolderOptions: {
|
||||
name: 'Other Folder',
|
||||
teamId: sender.team.id,
|
||||
},
|
||||
});
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: sender.user.email,
|
||||
redirectPath: `/t/${sender.team.url}/documents`,
|
||||
});
|
||||
|
||||
await page.locator('tr', { hasText: 'Bulk Test Doc 1' }).getByRole('checkbox').click();
|
||||
|
||||
await page.getByRole('button', { name: 'Move to Folder' }).click();
|
||||
await expect(page.getByRole('dialog')).toBeVisible();
|
||||
|
||||
await expect(page.getByRole('button', { name: folder.name })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: otherFolder.name })).toBeVisible();
|
||||
|
||||
await page.getByPlaceholder('Search folders...').fill('Target');
|
||||
await expect(page.getByRole('button', { name: folder.name })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: otherFolder.name })).not.toBeVisible();
|
||||
|
||||
await page.getByPlaceholder('Search folders...').fill('Other');
|
||||
await expect(page.getByRole('button', { name: folder.name })).not.toBeVisible();
|
||||
await expect(page.getByRole('button', { name: otherFolder.name })).toBeVisible();
|
||||
|
||||
await page.getByPlaceholder('Search folders...').fill('NonExistent');
|
||||
await expect(page.getByText('No folders found')).toBeVisible();
|
||||
});
|
||||
|
||||
test('[BULK_ACTIONS]: can move documents from folder to home (root)', async ({ page }) => {
|
||||
const { sender, documents, folder } = await seedBulkActionsTestRequirements();
|
||||
|
||||
const { prisma } = await import('@documenso/prisma');
|
||||
|
||||
await prisma.envelope.updateMany({
|
||||
where: { id: documents[0].id },
|
||||
data: { folderId: folder.id },
|
||||
});
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: sender.user.email,
|
||||
redirectPath: `/t/${sender.team.url}/documents/f/${folder.id}`,
|
||||
});
|
||||
|
||||
await expect(page.getByRole('link', { name: 'Bulk Test Doc 1' })).toBeVisible();
|
||||
|
||||
await page.locator('tr', { hasText: 'Bulk Test Doc 1' }).getByRole('checkbox').click();
|
||||
await expect(page.getByText('1 selected')).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Move to Folder' }).click();
|
||||
await expect(page.getByRole('dialog')).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Home (No Folder)' }).click();
|
||||
|
||||
await page.getByRole('button', { name: 'Move' }).click();
|
||||
|
||||
await expectToastTextToBeVisible(page, 'Selected items have been moved.');
|
||||
|
||||
await page.goto(`/t/${sender.team.url}/documents`);
|
||||
await expect(page.getByRole('link', { name: 'Bulk Test Doc 1' })).toBeVisible();
|
||||
|
||||
await page.goto(`/t/${sender.team.url}/documents/f/${folder.id}`);
|
||||
await expect(page.getByRole('link', { name: 'Bulk Test Doc 1' })).not.toBeVisible();
|
||||
});
|
||||
@@ -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,326 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
import type { TCachedLicense, TLicenseClaim } from '@documenso/lib/types/license';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
|
||||
import { apiSignin } from '../fixtures/authentication';
|
||||
|
||||
const LICENSE_FILE_NAME = '.documenso-license.json';
|
||||
const LICENSE_BACKUP_FILE_NAME = '.documenso-license-backup.json';
|
||||
|
||||
/**
|
||||
* Get the path to the license file.
|
||||
*
|
||||
* The server reads from process.cwd() which is apps/remix when the dev server runs.
|
||||
* Tests run from packages/app-tests, so we need to go up to the root then into apps/remix.
|
||||
*/
|
||||
const getLicenseFilePath = () => {
|
||||
// From packages/app-tests/e2e/license -> ../../../../apps/remix/.documenso-license.json
|
||||
return path.join(__dirname, '../../../../apps/remix', LICENSE_FILE_NAME);
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the path to the backup license file.
|
||||
*/
|
||||
const getBackupLicenseFilePath = () => {
|
||||
return path.join(__dirname, '../../../../apps/remix', LICENSE_BACKUP_FILE_NAME);
|
||||
};
|
||||
|
||||
/**
|
||||
* Backup the existing license file if it exists.
|
||||
*/
|
||||
const backupLicenseFile = async () => {
|
||||
const licensePath = getLicenseFilePath();
|
||||
const backupPath = getBackupLicenseFilePath();
|
||||
|
||||
try {
|
||||
await fs.access(licensePath);
|
||||
await fs.rename(licensePath, backupPath);
|
||||
} catch (e) {
|
||||
// File doesn't exist, nothing to backup
|
||||
console.log(e);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Restore the backup license file if it exists.
|
||||
*/
|
||||
const restoreLicenseFile = async () => {
|
||||
const licensePath = getLicenseFilePath();
|
||||
const backupPath = getBackupLicenseFilePath();
|
||||
|
||||
try {
|
||||
await fs.access(backupPath);
|
||||
await fs.rename(backupPath, licensePath);
|
||||
} catch (e) {
|
||||
// Backup doesn't exist, nothing to restore
|
||||
console.log(e);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Write a license file with the given data.
|
||||
* Pass null to delete the license file.
|
||||
*/
|
||||
const writeLicenseFile = async (data: TCachedLicense | null) => {
|
||||
const licensePath = getLicenseFilePath();
|
||||
|
||||
if (data === null) {
|
||||
await fs.unlink(licensePath).catch(() => {
|
||||
// File doesn't exist, ignore
|
||||
});
|
||||
} else {
|
||||
await fs.writeFile(licensePath, JSON.stringify(data, null, 2), 'utf-8');
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a mock license object with the given flags.
|
||||
*/
|
||||
const createMockLicenseWithFlags = (flags: TLicenseClaim): TCachedLicense => {
|
||||
return {
|
||||
lastChecked: new Date().toISOString(),
|
||||
license: {
|
||||
status: 'ACTIVE',
|
||||
createdAt: new Date(),
|
||||
name: 'Test License',
|
||||
periodEnd: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), // 30 days from now
|
||||
cancelAtPeriodEnd: false,
|
||||
licenseKey: 'test-license-key',
|
||||
flags,
|
||||
},
|
||||
requestedLicenseKey: 'test-license-key',
|
||||
derivedStatus: 'ACTIVE',
|
||||
unauthorizedFlagUsage: false,
|
||||
};
|
||||
};
|
||||
|
||||
// Run tests serially to avoid race conditions with the license file
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
|
||||
// SKIPPING TEST UNTIL WE ADD A WAY TO OVERRIDE THE LICENSE FILE.
|
||||
test.describe.skip('Enterprise Feature Restrictions', () => {
|
||||
test.beforeAll(async () => {
|
||||
// Backup any existing license file before running tests
|
||||
await backupLicenseFile();
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
// Restore the backup license file after all tests complete
|
||||
await restoreLicenseFile();
|
||||
});
|
||||
|
||||
test.beforeEach(async () => {
|
||||
// Clean up license file before each test to ensure clean state
|
||||
await writeLicenseFile(null);
|
||||
});
|
||||
|
||||
test.afterEach(async () => {
|
||||
// Clean up license file after each test
|
||||
await writeLicenseFile(null);
|
||||
});
|
||||
|
||||
test('[ADMIN CLAIMS]: shows restricted features with asterisk when no license', async ({
|
||||
page,
|
||||
}) => {
|
||||
// Ensure no license file exists
|
||||
await writeLicenseFile(null);
|
||||
|
||||
const { user: adminUser } = await seedUser({
|
||||
isAdmin: true,
|
||||
});
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: adminUser.email,
|
||||
redirectPath: '/admin/claims',
|
||||
});
|
||||
|
||||
// Click Create claim button to open the dialog
|
||||
await page.getByRole('button', { name: 'Create claim' }).click();
|
||||
|
||||
// Wait for dialog to open
|
||||
await expect(page.getByRole('dialog')).toBeVisible();
|
||||
|
||||
// Check that enterprise features have asterisks (are restricted)
|
||||
// These are the enterprise features that should be marked with *
|
||||
await expect(page.getByText(/Email domains\s¹/)).toBeVisible();
|
||||
await expect(page.getByText(/Embed authoring\s¹/)).toBeVisible();
|
||||
await expect(page.getByText(/White label for embed authoring\s¹/)).toBeVisible();
|
||||
await expect(page.getByText(/21 CFR\s¹/)).toBeVisible();
|
||||
await expect(page.getByText(/Authentication portal\s¹/)).toBeVisible();
|
||||
|
||||
// Check that the alert is visible
|
||||
await expect(
|
||||
page.getByText('Your current license does not include these features.'),
|
||||
).toBeVisible();
|
||||
await expect(page.getByRole('link', { name: 'Learn more' })).toBeVisible();
|
||||
|
||||
// Check that enterprise feature checkboxes are disabled
|
||||
const emailDomainsCheckbox = page.locator('#flag-emailDomains');
|
||||
await expect(emailDomainsCheckbox).toBeDisabled();
|
||||
|
||||
const cfr21Checkbox = page.locator('#flag-cfr21');
|
||||
await expect(cfr21Checkbox).toBeDisabled();
|
||||
|
||||
const authPortalCheckbox = page.locator('#flag-authenticationPortal');
|
||||
await expect(authPortalCheckbox).toBeDisabled();
|
||||
});
|
||||
|
||||
test('[ADMIN CLAIMS]: no restrictions when license has all enterprise features', async ({
|
||||
page,
|
||||
}) => {
|
||||
// Create a license with ALL enterprise features enabled
|
||||
await writeLicenseFile(
|
||||
createMockLicenseWithFlags({
|
||||
emailDomains: true,
|
||||
embedAuthoring: true,
|
||||
embedAuthoringWhiteLabel: true,
|
||||
cfr21: true,
|
||||
authenticationPortal: true,
|
||||
billing: true,
|
||||
}),
|
||||
);
|
||||
|
||||
const { user: adminUser } = await seedUser({
|
||||
isAdmin: true,
|
||||
});
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: adminUser.email,
|
||||
redirectPath: '/admin/claims',
|
||||
});
|
||||
|
||||
// Click Create claim button to open the dialog
|
||||
await page.getByRole('button', { name: 'Create claim' }).click();
|
||||
|
||||
// Wait for dialog to open
|
||||
await expect(page.getByRole('dialog')).toBeVisible();
|
||||
|
||||
// Check that enterprise features do NOT have asterisks
|
||||
// They should show without the * since the license covers them
|
||||
await expect(page.getByText(/Email domains\s¹/)).not.toBeVisible();
|
||||
await expect(page.getByText(/Embed authoring\s¹/)).not.toBeVisible();
|
||||
await expect(page.getByText(/21 CFR\s¹/)).not.toBeVisible();
|
||||
await expect(page.getByText(/Authentication portal\s¹/)).not.toBeVisible();
|
||||
|
||||
// The plain labels should be visible (without asterisks)
|
||||
await expect(page.locator('label[for="flag-emailDomains"]')).toContainText('Email domains');
|
||||
await expect(page.locator('label[for="flag-cfr21"]')).toContainText('21 CFR');
|
||||
|
||||
// The alert should NOT be visible
|
||||
await expect(
|
||||
page.getByText('Your current license does not include these features.'),
|
||||
).not.toBeVisible();
|
||||
|
||||
// Check that enterprise feature checkboxes are enabled
|
||||
const emailDomainsCheckbox = page.locator('#flag-emailDomains');
|
||||
await expect(emailDomainsCheckbox).toBeEnabled();
|
||||
|
||||
const cfr21Checkbox = page.locator('#flag-cfr21');
|
||||
await expect(cfr21Checkbox).toBeEnabled();
|
||||
|
||||
const authPortalCheckbox = page.locator('#flag-authenticationPortal');
|
||||
await expect(authPortalCheckbox).toBeEnabled();
|
||||
});
|
||||
|
||||
test('[ADMIN CLAIMS]: only unlicensed features show asterisk with partial license', async ({
|
||||
page,
|
||||
}) => {
|
||||
// Create a license with SOME enterprise features (emailDomains and cfr21)
|
||||
await writeLicenseFile(
|
||||
createMockLicenseWithFlags({
|
||||
emailDomains: true,
|
||||
cfr21: true,
|
||||
// embedAuthoring, embedAuthoringWhiteLabel, authenticationPortal are NOT included
|
||||
}),
|
||||
);
|
||||
|
||||
const { user: adminUser } = await seedUser({
|
||||
isAdmin: true,
|
||||
});
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: adminUser.email,
|
||||
redirectPath: '/admin/claims',
|
||||
});
|
||||
|
||||
// Click Create claim button to open the dialog
|
||||
await page.getByRole('button', { name: 'Create claim' }).click();
|
||||
|
||||
// Wait for dialog to open
|
||||
await expect(page.getByRole('dialog')).toBeVisible();
|
||||
|
||||
// Features NOT in license should have asterisks
|
||||
await expect(page.getByText(/Embed authoring\s¹/)).toBeVisible();
|
||||
await expect(page.getByText(/White label for embed authoring\s¹/)).toBeVisible();
|
||||
await expect(page.getByText(/Authentication portal\s¹/)).toBeVisible();
|
||||
|
||||
// Features IN license should NOT have asterisks
|
||||
await expect(page.getByText(/Email domains\s¹/)).not.toBeVisible();
|
||||
await expect(page.getByText(/21 CFR\s¹/)).not.toBeVisible();
|
||||
|
||||
// The plain labels for licensed features should be visible
|
||||
await expect(page.locator('label[for="flag-emailDomains"]')).toContainText('Email domains');
|
||||
await expect(page.locator('label[for="flag-cfr21"]')).toContainText('21 CFR');
|
||||
|
||||
// Alert should be visible since some features are restricted
|
||||
await expect(
|
||||
page.getByText('Your current license does not include these features.'),
|
||||
).toBeVisible();
|
||||
|
||||
// Licensed features should be enabled
|
||||
const emailDomainsCheckbox = page.locator('#flag-emailDomains');
|
||||
await expect(emailDomainsCheckbox).toBeEnabled();
|
||||
|
||||
const cfr21Checkbox = page.locator('#flag-cfr21');
|
||||
await expect(cfr21Checkbox).toBeEnabled();
|
||||
|
||||
// Unlicensed features should be disabled
|
||||
const embedAuthoringCheckbox = page.locator('#flag-embedAuthoring');
|
||||
await expect(embedAuthoringCheckbox).toBeDisabled();
|
||||
|
||||
const authPortalCheckbox = page.locator('#flag-authenticationPortal');
|
||||
await expect(authPortalCheckbox).toBeDisabled();
|
||||
});
|
||||
|
||||
test('[ADMIN CLAIMS]: non-enterprise features are always enabled', async ({ page }) => {
|
||||
// Ensure no license file exists
|
||||
await writeLicenseFile(null);
|
||||
|
||||
const { user: adminUser } = await seedUser({
|
||||
isAdmin: true,
|
||||
});
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: adminUser.email,
|
||||
redirectPath: '/admin/claims',
|
||||
});
|
||||
|
||||
// Click Create claim button to open the dialog
|
||||
await page.getByRole('button', { name: 'Create claim' }).click();
|
||||
|
||||
// Wait for dialog to open
|
||||
await expect(page.getByRole('dialog')).toBeVisible();
|
||||
|
||||
// Non-enterprise features should NOT have asterisks
|
||||
await expect(page.getByText(/Unlimited documents\s¹/)).not.toBeVisible();
|
||||
await expect(page.getByText(/Branding\s¹/)).not.toBeVisible();
|
||||
await expect(page.getByText(/Embed signing\s¹/)).not.toBeVisible();
|
||||
|
||||
// Non-enterprise features should always be enabled
|
||||
const unlimitedDocsCheckbox = page.locator('#flag-unlimitedDocuments');
|
||||
await expect(unlimitedDocsCheckbox).toBeEnabled();
|
||||
|
||||
const brandingCheckbox = page.locator('#flag-allowCustomBranding');
|
||||
await expect(brandingCheckbox).toBeEnabled();
|
||||
|
||||
const embedSigningCheckbox = page.locator('#flag-embedSigning');
|
||||
await expect(embedSigningCheckbox).toBeEnabled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,392 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
import type { TCachedLicense } from '@documenso/lib/types/license';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
|
||||
import { apiSignin } from '../fixtures/authentication';
|
||||
|
||||
const LICENSE_FILE_NAME = '.documenso-license.json';
|
||||
const LICENSE_BACKUP_FILE_NAME = '.documenso-license-backup.json';
|
||||
|
||||
/**
|
||||
* Get the path to the license file.
|
||||
*
|
||||
* The server reads from process.cwd() which is apps/remix when the dev server runs.
|
||||
* Tests run from packages/app-tests, so we need to go up to the root then into apps/remix.
|
||||
*/
|
||||
const getLicenseFilePath = () => {
|
||||
// From packages/app-tests/e2e/license -> ../../../../apps/remix/.documenso-license.json
|
||||
return path.join(__dirname, '../../../../apps/remix', LICENSE_FILE_NAME);
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the path to the backup license file.
|
||||
*/
|
||||
const getBackupLicenseFilePath = () => {
|
||||
return path.join(__dirname, '../../../../apps/remix', LICENSE_BACKUP_FILE_NAME);
|
||||
};
|
||||
|
||||
/**
|
||||
* Backup the existing license file if it exists.
|
||||
*/
|
||||
const backupLicenseFile = async () => {
|
||||
const licensePath = getLicenseFilePath();
|
||||
const backupPath = getBackupLicenseFilePath();
|
||||
|
||||
try {
|
||||
await fs.access(licensePath);
|
||||
await fs.rename(licensePath, backupPath);
|
||||
} catch (e) {
|
||||
// File doesn't exist, nothing to backup
|
||||
console.log(e);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Restore the backup license file if it exists.
|
||||
*/
|
||||
const restoreLicenseFile = async () => {
|
||||
const licensePath = getLicenseFilePath();
|
||||
const backupPath = getBackupLicenseFilePath();
|
||||
|
||||
try {
|
||||
await fs.access(backupPath);
|
||||
await fs.rename(backupPath, licensePath);
|
||||
} catch (e) {
|
||||
// Backup doesn't exist, nothing to restore
|
||||
console.log(e);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Write a license file with the given data.
|
||||
* Pass null to delete the license file.
|
||||
*/
|
||||
const writeLicenseFile = async (data: TCachedLicense | null) => {
|
||||
const licensePath = getLicenseFilePath();
|
||||
|
||||
if (data === null) {
|
||||
await fs.unlink(licensePath).catch(() => {
|
||||
// File doesn't exist, ignore
|
||||
});
|
||||
} else {
|
||||
await fs.writeFile(licensePath, JSON.stringify(data, null, 2), 'utf-8');
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a mock license object with the given status and unauthorized flag.
|
||||
*/
|
||||
const createMockLicense = (
|
||||
status: 'ACTIVE' | 'EXPIRED' | 'PAST_DUE',
|
||||
unauthorizedFlagUsage: boolean,
|
||||
): TCachedLicense => {
|
||||
return {
|
||||
lastChecked: new Date().toISOString(),
|
||||
license: {
|
||||
status,
|
||||
createdAt: new Date(),
|
||||
name: 'Test License',
|
||||
periodEnd: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), // 30 days from now
|
||||
cancelAtPeriodEnd: false,
|
||||
licenseKey: 'test-license-key',
|
||||
flags: {},
|
||||
},
|
||||
requestedLicenseKey: 'test-license-key',
|
||||
derivedStatus: unauthorizedFlagUsage ? 'UNAUTHORIZED' : status,
|
||||
unauthorizedFlagUsage,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a mock license object with no license data (only unauthorized flag).
|
||||
*/
|
||||
const createMockUnauthorizedWithoutLicense = (): TCachedLicense => {
|
||||
return {
|
||||
lastChecked: new Date().toISOString(),
|
||||
license: null,
|
||||
unauthorizedFlagUsage: true,
|
||||
derivedStatus: 'UNAUTHORIZED',
|
||||
};
|
||||
};
|
||||
|
||||
// Run tests serially to avoid race conditions with the license file
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
|
||||
// SKIPPING TEST UNTIL WE ADD A WAY TO OVERRIDE THE LICENSE FILE.
|
||||
test.describe.skip('License Status Banner', () => {
|
||||
test.beforeAll(async () => {
|
||||
// Backup any existing license file before running tests
|
||||
await backupLicenseFile();
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
// Restore the backup license file after all tests complete
|
||||
await restoreLicenseFile();
|
||||
});
|
||||
|
||||
test.beforeEach(async () => {
|
||||
// Clean up license file before each test to ensure clean state
|
||||
await writeLicenseFile(null);
|
||||
});
|
||||
|
||||
test.afterEach(async () => {
|
||||
// Clean up license file after each test
|
||||
await writeLicenseFile(null);
|
||||
});
|
||||
|
||||
test('[ADMIN]: no banner when license file is missing', async ({ page }) => {
|
||||
// Ensure no license file exists BEFORE any page loads
|
||||
await writeLicenseFile(null);
|
||||
|
||||
const { user: adminUser } = await seedUser({
|
||||
isAdmin: true,
|
||||
});
|
||||
|
||||
// Navigate to admin page - license is read during page load
|
||||
await apiSignin({
|
||||
page,
|
||||
email: adminUser.email,
|
||||
redirectPath: '/admin',
|
||||
});
|
||||
|
||||
// Verify we're on the admin page
|
||||
await expect(page.getByRole('heading', { name: 'Admin Panel' })).toBeVisible();
|
||||
|
||||
// Global banner should not be visible (no license file)
|
||||
await expect(
|
||||
page.getByText('This is an expired license instance of Documenso'),
|
||||
).not.toBeVisible();
|
||||
|
||||
// Admin banner messages should not be visible (no license file means no banner)
|
||||
await expect(page.getByText('License payment overdue')).not.toBeVisible();
|
||||
await expect(page.getByText('License expired')).not.toBeVisible();
|
||||
await expect(page.getByText('Invalid License Type')).not.toBeVisible();
|
||||
await expect(page.getByText('Missing License')).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('[ADMIN]: no banner when license is ACTIVE', async ({ page }) => {
|
||||
// Create an ACTIVE license BEFORE any page loads
|
||||
await writeLicenseFile(createMockLicense('ACTIVE', false));
|
||||
|
||||
const { user: adminUser } = await seedUser({
|
||||
isAdmin: true,
|
||||
});
|
||||
|
||||
// Navigate to admin page - license is read during page load
|
||||
await apiSignin({
|
||||
page,
|
||||
email: adminUser.email,
|
||||
redirectPath: '/admin',
|
||||
});
|
||||
|
||||
// Verify we're on the admin page
|
||||
await expect(page.getByRole('heading', { name: 'Admin Panel' })).toBeVisible();
|
||||
|
||||
// Global banner should not be visible (license is ACTIVE)
|
||||
await expect(
|
||||
page.getByText('This is an expired license instance of Documenso'),
|
||||
).not.toBeVisible();
|
||||
|
||||
// Admin banner messages should not be visible (license is ACTIVE)
|
||||
await expect(page.getByText('License payment overdue')).not.toBeVisible();
|
||||
await expect(page.getByText('License expired')).not.toBeVisible();
|
||||
await expect(page.getByText('Invalid License Type')).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('[ADMIN]: admin banner shows PAST_DUE warning', async ({ page }) => {
|
||||
// Create a PAST_DUE license BEFORE any page loads
|
||||
await writeLicenseFile(createMockLicense('PAST_DUE', false));
|
||||
|
||||
const { user: adminUser } = await seedUser({
|
||||
isAdmin: true,
|
||||
});
|
||||
|
||||
// Navigate to admin page - license is read during page load
|
||||
await apiSignin({
|
||||
page,
|
||||
email: adminUser.email,
|
||||
redirectPath: '/admin',
|
||||
});
|
||||
|
||||
// Verify we're on the admin page
|
||||
await expect(page.getByRole('heading', { name: 'Admin Panel' })).toBeVisible();
|
||||
|
||||
// Global banner should NOT be visible (only shows for EXPIRED + unauthorized)
|
||||
await expect(
|
||||
page.getByText('This is an expired license instance of Documenso'),
|
||||
).not.toBeVisible();
|
||||
|
||||
// Admin banner should show PAST_DUE message
|
||||
await expect(page.getByText('License payment overdue')).toBeVisible();
|
||||
await expect(
|
||||
page.getByText('Please update your payment to avoid service disruptions.'),
|
||||
).toBeVisible();
|
||||
|
||||
// Should have the "See Documentation" link
|
||||
await expect(page.getByRole('link', { name: 'See Documentation' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('[ADMIN]: admin banner shows EXPIRED error', async ({ page }) => {
|
||||
// Create an EXPIRED license WITHOUT unauthorized usage BEFORE any page loads
|
||||
await writeLicenseFile(createMockLicense('EXPIRED', false));
|
||||
|
||||
const { user: adminUser } = await seedUser({
|
||||
isAdmin: true,
|
||||
});
|
||||
|
||||
// Navigate to admin page - license is read during page load
|
||||
await apiSignin({
|
||||
page,
|
||||
email: adminUser.email,
|
||||
redirectPath: '/admin',
|
||||
});
|
||||
|
||||
// Verify we're on the admin page
|
||||
await expect(page.getByRole('heading', { name: 'Admin Panel' })).toBeVisible();
|
||||
|
||||
// Global banner should NOT be visible (requires BOTH expired AND unauthorized)
|
||||
await expect(
|
||||
page.getByText('This is an expired license instance of Documenso'),
|
||||
).not.toBeVisible();
|
||||
|
||||
// Admin banner should show EXPIRED message
|
||||
await expect(page.getByText('License expired')).toBeVisible();
|
||||
await expect(
|
||||
page.getByText('Please renew your license to continue using enterprise features.'),
|
||||
).toBeVisible();
|
||||
|
||||
// Should have the "See Documentation" link
|
||||
await expect(page.getByRole('link', { name: 'See Documentation' })).toBeVisible();
|
||||
});
|
||||
|
||||
test.skip('[ADMIN]: global banner shows when EXPIRED with unauthorized usage', async ({
|
||||
page,
|
||||
}) => {
|
||||
// Create an EXPIRED license WITH unauthorized usage BEFORE any page loads
|
||||
await writeLicenseFile(createMockLicense('EXPIRED', true));
|
||||
|
||||
const { user: adminUser } = await seedUser({
|
||||
isAdmin: true,
|
||||
});
|
||||
|
||||
// Navigate to admin page - license is read during page load
|
||||
await apiSignin({
|
||||
page,
|
||||
email: adminUser.email,
|
||||
redirectPath: '/admin',
|
||||
});
|
||||
|
||||
// Verify we're on the admin page
|
||||
await expect(page.getByRole('heading', { name: 'Admin Panel' })).toBeVisible();
|
||||
|
||||
// Global banner SHOULD be visible (EXPIRED + unauthorized)
|
||||
await expect(page.getByText('This is an expired license instance of Documenso')).toBeVisible();
|
||||
|
||||
// Admin banner should show UNAUTHORIZED message (takes precedence over EXPIRED)
|
||||
await expect(page.getByText('Invalid License Type')).toBeVisible();
|
||||
await expect(
|
||||
page.getByText(
|
||||
'Your Documenso instance is using features that are not part of your license.',
|
||||
),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('[ADMIN]: admin banner shows UNAUTHORIZED when flags are misused with license', async ({
|
||||
page,
|
||||
}) => {
|
||||
// Create an ACTIVE license but WITH unauthorized flag usage BEFORE any page loads
|
||||
await writeLicenseFile(createMockLicense('ACTIVE', true));
|
||||
|
||||
const { user: adminUser } = await seedUser({
|
||||
isAdmin: true,
|
||||
});
|
||||
|
||||
// Navigate to admin page - license is read during page load
|
||||
await apiSignin({
|
||||
page,
|
||||
email: adminUser.email,
|
||||
redirectPath: '/admin',
|
||||
});
|
||||
|
||||
// Verify we're on the admin page
|
||||
await expect(page.getByRole('heading', { name: 'Admin Panel' })).toBeVisible();
|
||||
|
||||
// Global banner should NOT be visible (requires EXPIRED status)
|
||||
await expect(
|
||||
page.getByText('This is an expired license instance of Documenso'),
|
||||
).not.toBeVisible();
|
||||
|
||||
// Admin banner should show UNAUTHORIZED message
|
||||
await expect(page.getByText('Invalid License Type')).toBeVisible();
|
||||
await expect(
|
||||
page.getByText(
|
||||
'Your Documenso instance is using features that are not part of your license.',
|
||||
),
|
||||
).toBeVisible();
|
||||
|
||||
// Should have the "See Documentation" link
|
||||
await expect(page.getByRole('link', { name: 'See Documentation' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('[ADMIN]: admin banner shows Invalid License Type when unauthorized without license data', async ({
|
||||
page,
|
||||
}) => {
|
||||
// Create a license file with unauthorized flag but no license data BEFORE any page loads
|
||||
// Note: Even without license data, the banner shows "Invalid License Type" because the
|
||||
// license file exists (just with license: null). The "Missing License" message would only
|
||||
// show if the entire license prop was null, which doesn't happen with a valid file.
|
||||
await writeLicenseFile(createMockUnauthorizedWithoutLicense());
|
||||
|
||||
const { user: adminUser } = await seedUser({
|
||||
isAdmin: true,
|
||||
});
|
||||
|
||||
// Navigate to admin page - license is read during page load
|
||||
await apiSignin({
|
||||
page,
|
||||
email: adminUser.email,
|
||||
redirectPath: '/admin',
|
||||
});
|
||||
|
||||
// Verify we're on the admin page
|
||||
await expect(page.getByRole('heading', { name: 'Admin Panel' })).toBeVisible();
|
||||
|
||||
// Global banner should NOT be visible (no EXPIRED status, only unauthorized flag)
|
||||
await expect(
|
||||
page.getByText('This is an expired license instance of Documenso'),
|
||||
).not.toBeVisible();
|
||||
|
||||
// Admin banner should show Invalid License Type message (unauthorized flag is set)
|
||||
await expect(page.getByText('Invalid License Type')).toBeVisible();
|
||||
await expect(
|
||||
page.getByText(
|
||||
'Your Documenso instance is using features that are not part of your license.',
|
||||
),
|
||||
).toBeVisible();
|
||||
|
||||
// Should have the "See Documentation" link
|
||||
await expect(page.getByRole('link', { name: 'See Documentation' })).toBeVisible();
|
||||
});
|
||||
|
||||
test.skip('[ADMIN]: global banner visible on non-admin pages when EXPIRED with unauthorized', async ({
|
||||
page,
|
||||
}) => {
|
||||
// Create an EXPIRED license WITH unauthorized usage BEFORE any page loads
|
||||
await writeLicenseFile(createMockLicense('EXPIRED', true));
|
||||
|
||||
const { user } = await seedUser();
|
||||
|
||||
// Navigate to documents page - license is read during page load
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
redirectPath: '/documents',
|
||||
});
|
||||
|
||||
// Global banner SHOULD be visible on any authenticated page (EXPIRED + unauthorized)
|
||||
await expect(page.getByText('This is an expired license instance of Documenso')).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { PDFDocument } from '@cantoo/pdf-lib';
|
||||
import { PDF } from '@libpdf/core';
|
||||
import { expect, test } from '@playwright/test';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
@@ -39,24 +39,30 @@ const TEST_FORM_VALUES = {
|
||||
* Returns true if the PDF has form fields, false if they've been flattened.
|
||||
*/
|
||||
async function pdfHasFormFields(pdfBuffer: Uint8Array): Promise<boolean> {
|
||||
const pdfDoc = await PDFDocument.load(pdfBuffer);
|
||||
const pdfDoc = await PDF.load(new Uint8Array(pdfBuffer));
|
||||
|
||||
const form = pdfDoc.getForm();
|
||||
const fields = form.getFields();
|
||||
const form = await pdfDoc.getForm();
|
||||
|
||||
return fields.length > 0;
|
||||
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 PDFDocument.load(pdfBuffer);
|
||||
const pdfDoc = await PDF.load(new Uint8Array(pdfBuffer));
|
||||
|
||||
const form = pdfDoc.getForm();
|
||||
const fields = form.getFields();
|
||||
const form = await pdfDoc.getForm();
|
||||
|
||||
return fields.map((field) => field.getName());
|
||||
if (!form) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return form.getFieldNames();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -66,17 +72,21 @@ async function getPdfTextFieldValue(
|
||||
pdfBuffer: Uint8Array,
|
||||
fieldName: string,
|
||||
): Promise<string | undefined> {
|
||||
const pdfDoc = await PDFDocument.load(pdfBuffer);
|
||||
const pdfDoc = await PDF.load(new Uint8Array(pdfBuffer));
|
||||
|
||||
const form = pdfDoc.getForm();
|
||||
const form = await pdfDoc.getForm();
|
||||
|
||||
try {
|
||||
const textField = form.getTextField(fieldName);
|
||||
|
||||
return textField.getText() ?? '';
|
||||
} catch {
|
||||
if (!form) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const textField = form.getTextField(fieldName);
|
||||
|
||||
if (!textField) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return textField.getValue();
|
||||
}
|
||||
|
||||
test.describe.configure({
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { FolderType } from '@documenso/prisma/client';
|
||||
import { seedBlankFolder } from '@documenso/prisma/seed/folders';
|
||||
import { seedBlankTemplate } from '@documenso/prisma/seed/templates';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
|
||||
import { apiSignin } from '../fixtures/authentication';
|
||||
import { expectToastTextToBeVisible } from '../fixtures/generic';
|
||||
|
||||
test.describe.configure({ mode: 'parallel' });
|
||||
|
||||
const seedBulkActionsTestRequirements = async () => {
|
||||
const sender = await seedUser({ setTeamEmailAsOwner: true });
|
||||
|
||||
const [template1, template2, template3] = await Promise.all([
|
||||
seedBlankTemplate(sender.user, sender.team.id, {
|
||||
createTemplateOptions: { title: 'Bulk Test Template 1' },
|
||||
}),
|
||||
seedBlankTemplate(sender.user, sender.team.id, {
|
||||
createTemplateOptions: { title: 'Bulk Test Template 2' },
|
||||
}),
|
||||
seedBlankTemplate(sender.user, sender.team.id, {
|
||||
createTemplateOptions: { title: 'Bulk Test Template 3' },
|
||||
}),
|
||||
]);
|
||||
|
||||
const folder = await seedBlankFolder(sender.user, sender.team.id, {
|
||||
createFolderOptions: {
|
||||
name: 'Target Template Folder',
|
||||
teamId: sender.team.id,
|
||||
type: FolderType.TEMPLATE,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
sender,
|
||||
templates: [template1, template2, template3],
|
||||
folder,
|
||||
};
|
||||
};
|
||||
|
||||
test('[BULK_ACTIONS]: can select multiple templates with checkboxes', async ({ page }) => {
|
||||
const { sender } = await seedBulkActionsTestRequirements();
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: sender.user.email,
|
||||
redirectPath: `/t/${sender.team.url}/templates`,
|
||||
});
|
||||
|
||||
await page.locator('tr', { hasText: 'Bulk Test Template 1' }).getByRole('checkbox').click();
|
||||
await expect(page.getByText('1 selected')).toBeVisible();
|
||||
|
||||
await page.locator('tr', { hasText: 'Bulk Test Template 2' }).getByRole('checkbox').click();
|
||||
await expect(page.getByText('2 selected')).toBeVisible();
|
||||
});
|
||||
|
||||
test('[BULK_ACTIONS]: header checkbox selects all templates on page', async ({ page }) => {
|
||||
const { sender, templates } = await seedBulkActionsTestRequirements();
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: sender.user.email,
|
||||
redirectPath: `/t/${sender.team.url}/templates`,
|
||||
});
|
||||
|
||||
await page.locator('thead').getByRole('checkbox').click();
|
||||
|
||||
await expect(page.getByText(`${templates.length} selected`)).toBeVisible();
|
||||
});
|
||||
|
||||
test('[BULK_ACTIONS]: can clear selection with X button', async ({ page }) => {
|
||||
const { sender } = await seedBulkActionsTestRequirements();
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: sender.user.email,
|
||||
redirectPath: `/t/${sender.team.url}/templates`,
|
||||
});
|
||||
|
||||
await page.locator('thead').getByRole('checkbox').click();
|
||||
await expect(page.getByText(/\d+ selected/)).toBeVisible();
|
||||
|
||||
await page.getByLabel('Clear selection').click();
|
||||
|
||||
await expect(page.getByText(/\d+ selected/)).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('[BULK_ACTIONS]: can move multiple templates to a folder', async ({ page }) => {
|
||||
const { sender, folder } = await seedBulkActionsTestRequirements();
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: sender.user.email,
|
||||
redirectPath: `/t/${sender.team.url}/templates`,
|
||||
});
|
||||
|
||||
await page.locator('tr', { hasText: 'Bulk Test Template 1' }).getByRole('checkbox').click();
|
||||
await page.locator('tr', { hasText: 'Bulk Test Template 2' }).getByRole('checkbox').click();
|
||||
await page.getByRole('button', { name: 'Move to Folder' }).click();
|
||||
|
||||
await expect(page.getByRole('dialog')).toBeVisible();
|
||||
await expect(page.getByText('Move Templates to Folder')).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: folder.name }).click();
|
||||
await page.getByRole('button', { name: 'Move' }).click();
|
||||
|
||||
await expectToastTextToBeVisible(page, 'Selected items have been moved.');
|
||||
|
||||
await page.goto(`/t/${sender.team.url}/templates/f/${folder.id}`);
|
||||
await expect(page.getByRole('link', { name: 'Bulk Test Template 1' })).toBeVisible();
|
||||
await expect(page.getByRole('link', { name: 'Bulk Test Template 2' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('[BULK_ACTIONS]: can delete multiple templates', async ({ page }) => {
|
||||
const { sender } = await seedBulkActionsTestRequirements();
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: sender.user.email,
|
||||
redirectPath: `/t/${sender.team.url}/templates`,
|
||||
});
|
||||
|
||||
await page.locator('tr', { hasText: 'Bulk Test Template 1' }).getByRole('checkbox').click();
|
||||
await page.locator('tr', { hasText: 'Bulk Test Template 2' }).getByRole('checkbox').click();
|
||||
|
||||
await page.getByRole('button', { name: 'Delete' }).click();
|
||||
|
||||
await expect(page.getByRole('dialog')).toBeVisible();
|
||||
await expect(page.getByText('Delete Templates')).toBeVisible();
|
||||
await expect(page.getByText('You are about to delete 2 templates')).toBeVisible();
|
||||
await expect(page.getByText('irreversible')).toBeVisible();
|
||||
|
||||
await page.getByRole('dialog').getByRole('button', { name: 'Delete' }).click();
|
||||
|
||||
await expectToastTextToBeVisible(page, 'Templates deleted');
|
||||
|
||||
await expect(page.getByRole('link', { name: 'Bulk Test Template 1' })).not.toBeVisible();
|
||||
await expect(page.getByRole('link', { name: 'Bulk Test Template 2' })).not.toBeVisible();
|
||||
|
||||
await expect(page.getByRole('link', { name: 'Bulk Test Template 3' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('[BULK_ACTIONS]: selection clears after successful move', async ({ page }) => {
|
||||
const { sender, folder } = await seedBulkActionsTestRequirements();
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: sender.user.email,
|
||||
redirectPath: `/t/${sender.team.url}/templates`,
|
||||
});
|
||||
|
||||
await page.locator('tr', { hasText: 'Bulk Test Template 1' }).getByRole('checkbox').click();
|
||||
await expect(page.getByText('1 selected')).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Move to Folder' }).click();
|
||||
await page.getByRole('button', { name: folder.name }).click();
|
||||
await page.getByRole('button', { name: 'Move' }).click();
|
||||
|
||||
await expectToastTextToBeVisible(page, 'Selected items have been moved.');
|
||||
await expect(page.getByText(/\d+ selected/)).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('[BULK_ACTIONS]: selection clears after successful delete', async ({ page }) => {
|
||||
const { sender } = await seedBulkActionsTestRequirements();
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: sender.user.email,
|
||||
redirectPath: `/t/${sender.team.url}/templates`,
|
||||
});
|
||||
|
||||
await page.locator('tr', { hasText: 'Bulk Test Template 1' }).getByRole('checkbox').click();
|
||||
await expect(page.getByText('1 selected')).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Delete' }).click();
|
||||
await page.getByRole('dialog').getByRole('button', { name: 'Delete' }).click();
|
||||
|
||||
await expectToastTextToBeVisible(page, 'Templates deleted');
|
||||
await expect(page.getByText(/\d+ selected/)).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('[BULK_ACTIONS]: can search for folders in move dialog', async ({ page }) => {
|
||||
const { sender, folder } = await seedBulkActionsTestRequirements();
|
||||
|
||||
const otherFolder = await seedBlankFolder(sender.user, sender.team.id, {
|
||||
createFolderOptions: {
|
||||
name: 'Other Template Folder',
|
||||
teamId: sender.team.id,
|
||||
type: FolderType.TEMPLATE,
|
||||
},
|
||||
});
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: sender.user.email,
|
||||
redirectPath: `/t/${sender.team.url}/templates`,
|
||||
});
|
||||
|
||||
await page.locator('tr', { hasText: 'Bulk Test Template 1' }).getByRole('checkbox').click();
|
||||
|
||||
await page.getByRole('button', { name: 'Move to Folder' }).click();
|
||||
await expect(page.getByRole('dialog')).toBeVisible();
|
||||
|
||||
await expect(page.getByRole('button', { name: folder.name })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: otherFolder.name })).toBeVisible();
|
||||
|
||||
await page.getByPlaceholder('Search folders...').fill('Target');
|
||||
await expect(page.getByRole('button', { name: folder.name })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: otherFolder.name })).not.toBeVisible();
|
||||
|
||||
await page.getByPlaceholder('Search folders...').fill('Other');
|
||||
await expect(page.getByRole('button', { name: folder.name })).not.toBeVisible();
|
||||
await expect(page.getByRole('button', { name: otherFolder.name })).toBeVisible();
|
||||
|
||||
await page.getByPlaceholder('Search folders...').fill('NonExistent');
|
||||
await expect(page.getByText('No folders found')).toBeVisible();
|
||||
});
|
||||
|
||||
test('[BULK_ACTIONS]: can move templates from folder to home (root)', async ({ page }) => {
|
||||
const { sender, templates, folder } = await seedBulkActionsTestRequirements();
|
||||
|
||||
const { prisma } = await import('@documenso/prisma');
|
||||
|
||||
await prisma.envelope.updateMany({
|
||||
where: { id: templates[0].id },
|
||||
data: { folderId: folder.id },
|
||||
});
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: sender.user.email,
|
||||
redirectPath: `/t/${sender.team.url}/templates/f/${folder.id}`,
|
||||
});
|
||||
|
||||
await expect(page.getByRole('link', { name: 'Bulk Test Template 1' })).toBeVisible();
|
||||
|
||||
await page.locator('tr', { hasText: 'Bulk Test Template 1' }).getByRole('checkbox').click();
|
||||
await expect(page.getByText('1 selected')).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Move to Folder' }).click();
|
||||
await expect(page.getByRole('dialog')).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Home (No Folder)' }).click();
|
||||
|
||||
await page.getByRole('button', { name: 'Move' }).click();
|
||||
|
||||
await expectToastTextToBeVisible(page, 'Selected items have been moved.');
|
||||
|
||||
await page.goto(`/t/${sender.team.url}/templates`);
|
||||
await expect(page.getByRole('link', { name: 'Bulk Test Template 1' })).toBeVisible();
|
||||
|
||||
await page.goto(`/t/${sender.team.url}/templates/f/${folder.id}`);
|
||||
await expect(page.getByRole('link', { name: 'Bulk Test Template 1' })).not.toBeVisible();
|
||||
});
|
||||
@@ -23,7 +23,7 @@ test('[USER] can sign up with email and password', async ({ page }: { page: Page
|
||||
|
||||
await signSignaturePad(page);
|
||||
|
||||
await page.getByRole('button', { name: 'Complete', exact: true }).click();
|
||||
await page.getByRole('button', { name: 'Create account', exact: true }).click();
|
||||
|
||||
await page.waitForURL('/unverified-account');
|
||||
|
||||
|
||||
@@ -83,10 +83,21 @@ export default defineConfig({
|
||||
testMatch: /e2e\/api\/.*\.spec\.ts/,
|
||||
workers: 10, // Limited by DB connections before it gets flakey.
|
||||
},
|
||||
// Run UI Tests
|
||||
// License tests that share a single license file - must run serially
|
||||
{
|
||||
name: 'license',
|
||||
testMatch: /e2e\/license\/.*\.spec\.ts/,
|
||||
use: {
|
||||
...devices['Desktop Chrome'],
|
||||
viewport: { width: 1920, height: 1200 },
|
||||
},
|
||||
workers: 1, // Must run serially since they share a license file
|
||||
},
|
||||
// Run UI Tests (excluding license tests which have their own project)
|
||||
{
|
||||
name: 'ui',
|
||||
testMatch: /e2e\/(?!api\/).*\.spec\.ts/,
|
||||
testIgnore: /e2e\/license\/.*\.spec\.ts/,
|
||||
use: {
|
||||
...devices['Desktop Chrome'],
|
||||
viewport: { width: 1920, height: 1200 },
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
@@ -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',
|
||||
|
||||
@@ -2,6 +2,7 @@ This file lists all features currently licensed under the Documenso Enterprise E
|
||||
Copyright (c) 2023 Documenso, Inc
|
||||
|
||||
- The Stripe Billing Module
|
||||
- Organisation Authentication Portal
|
||||
- Document Action Reauthentication (Passkeys and 2FA)
|
||||
- 21 CFR
|
||||
- Email domains
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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,52 @@ 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Flatten the form to bake checkbox/radio appearances into the PDF content
|
||||
// This ensures proper rendering when the PDF is processed by libpdf
|
||||
const form = legacy_pdfLibDoc.getForm();
|
||||
form.flatten();
|
||||
|
||||
await pdfDoc.reload(await legacy_pdfLibDoc.save());
|
||||
}
|
||||
|
||||
// Handle V2 envelope insertions.
|
||||
@@ -410,87 +402,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 +466,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 {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user